[client,management] Feature/client service expose (#5411)

CLI: new expose command to publish a local port with flags for PIN, password, user groups, custom domain, name prefix and protocol (HTTP default).
Management/API: create/renew/stop expose sessions (streamed status), automatic naming/domain, TTL renewals, background expiration, new management RPCs and client methods.
UI/API: account settings now include peer_expose_enabled and peer_expose_groups; new activity codes for peer expose events.
This commit is contained in:
Maycon Santos
2026-02-24 10:02:16 +01:00
committed by GitHub
parent 37f025c966
commit 63c83aa8d2
44 changed files with 3867 additions and 422 deletions

View File

@@ -3,10 +3,14 @@ package manager
import (
"context"
"fmt"
"math/rand/v2"
"time"
nbpeer "github.com/netbirdio/netbird/management/server/peer"
log "github.com/sirupsen/logrus"
"slices"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/sessionkey"
nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
@@ -15,6 +19,7 @@ import (
"github.com/netbirdio/netbird/management/server/permissions"
"github.com/netbirdio/netbird/management/server/permissions/modules"
"github.com/netbirdio/netbird/management/server/permissions/operations"
"github.com/netbirdio/netbird/management/server/settings"
"github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/shared/management/proto"
"github.com/netbirdio/netbird/shared/management/status"
@@ -25,22 +30,25 @@ const unknownHostPlaceholder = "unknown"
// ClusterDeriver derives the proxy cluster from a domain.
type ClusterDeriver interface {
DeriveClusterFromDomain(ctx context.Context, accountID, domain string) (string, error)
GetClusterDomains() []string
}
type managerImpl struct {
store store.Store
accountManager account.Manager
permissionsManager permissions.Manager
settingsManager settings.Manager
proxyGRPCServer *nbgrpc.ProxyServiceServer
clusterDeriver ClusterDeriver
}
// NewManager creates a new service manager.
func NewManager(store store.Store, accountManager account.Manager, permissionsManager permissions.Manager, proxyGRPCServer *nbgrpc.ProxyServiceServer, clusterDeriver ClusterDeriver) reverseproxy.Manager {
func NewManager(store store.Store, accountManager account.Manager, permissionsManager permissions.Manager, settingsManager settings.Manager, proxyGRPCServer *nbgrpc.ProxyServiceServer, clusterDeriver ClusterDeriver) reverseproxy.Manager {
return &managerImpl{
store: store,
accountManager: accountManager,
permissionsManager: permissionsManager,
settingsManager: settingsManager,
proxyGRPCServer: proxyGRPCServer,
clusterDeriver: clusterDeriver,
}
@@ -475,7 +483,8 @@ func (m *managerImpl) SetCertificateIssuedAt(ctx context.Context, accountID, ser
return fmt.Errorf("failed to get service: %w", err)
}
service.Meta.CertificateIssuedAt = time.Now()
now := time.Now()
service.Meta.CertificateIssuedAt = &now
if err = transaction.UpdateService(ctx, service); err != nil {
return fmt.Errorf("failed to update service certificate timestamp: %w", err)
@@ -607,3 +616,179 @@ func (m *managerImpl) GetServiceIDByTargetID(ctx context.Context, accountID stri
return target.ServiceID, nil
}
// ValidateExposePermission checks whether the peer is allowed to use the expose feature.
// It verifies the account has peer expose enabled and that the peer belongs to an allowed group.
func (m *managerImpl) ValidateExposePermission(ctx context.Context, accountID, peerID string) error {
settings, err := m.store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID)
if err != nil {
log.WithContext(ctx).Errorf("failed to get account settings: %v", err)
return status.Errorf(status.Internal, "get account settings: %v", err)
}
if !settings.PeerExposeEnabled {
return status.Errorf(status.PermissionDenied, "peer expose is not enabled for this account")
}
if len(settings.PeerExposeGroups) == 0 {
return status.Errorf(status.PermissionDenied, "no group is set for peer expose")
}
peerGroupIDs, err := m.store.GetPeerGroupIDs(ctx, store.LockingStrengthNone, accountID, peerID)
if err != nil {
log.WithContext(ctx).Errorf("failed to get peer group IDs: %v", err)
return status.Errorf(status.Internal, "get peer groups: %v", err)
}
for _, pg := range peerGroupIDs {
if slices.Contains(settings.PeerExposeGroups, pg) {
return nil
}
}
return status.Errorf(status.PermissionDenied, "peer is not in an allowed expose group")
}
// CreateServiceFromPeer creates a service initiated by a peer expose request.
// It skips user permission checks since authorization is done at the gRPC handler level.
func (m *managerImpl) CreateServiceFromPeer(ctx context.Context, accountID, peerID string, service *reverseproxy.Service) (*reverseproxy.Service, error) {
service.Source = reverseproxy.SourceEphemeral
if service.Domain == "" {
domain, err := m.buildRandomDomain(service.Name)
if err != nil {
return nil, fmt.Errorf("build random domain for service %s: %w", service.Name, err)
}
service.Domain = domain
}
if service.Auth.BearerAuth != nil && service.Auth.BearerAuth.Enabled {
groupIDs, err := m.getGroupIDsFromNames(ctx, accountID, service.Auth.BearerAuth.DistributionGroups)
if err != nil {
return nil, fmt.Errorf("get group ids for service %s: %w", service.ID, err)
}
service.Auth.BearerAuth.DistributionGroups = groupIDs
}
if err := m.initializeServiceForCreate(ctx, accountID, service); err != nil {
return nil, err
}
peer, err := m.store.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peerID)
if err != nil {
return nil, err
}
now := time.Now()
service.Meta.LastRenewedAt = &now
service.SourcePeer = peerID
if err := m.persistNewService(ctx, accountID, service); err != nil {
return nil, err
}
meta := addPeerInfoToEventMeta(service.EventMeta(), peer)
m.accountManager.StoreEvent(ctx, peerID, service.ID, accountID, activity.PeerServiceExposed, meta)
if err := m.replaceHostByLookup(ctx, accountID, service); err != nil {
return nil, fmt.Errorf("replace host by lookup for service %s: %w", service.ID, err)
}
m.sendServiceUpdate(service, reverseproxy.Create, service.ProxyCluster, "")
m.accountManager.UpdateAccountPeers(ctx, accountID)
return service, nil
}
func (m *managerImpl) getGroupIDsFromNames(ctx context.Context, accountID string, groupNames []string) ([]string, error) {
if len(groupNames) == 0 {
return []string{}, fmt.Errorf("no group names provided")
}
groupIDs := make([]string, 0, len(groupNames))
for _, groupName := range groupNames {
g, err := m.accountManager.GetGroupByName(ctx, groupName, accountID)
if err != nil {
return nil, fmt.Errorf("failed to get group by name %s: %w", groupName, err)
}
groupIDs = append(groupIDs, g.ID)
}
return groupIDs, nil
}
func (m *managerImpl) buildRandomDomain(name string) (string, error) {
clusterDomains := m.clusterDeriver.GetClusterDomains()
if len(clusterDomains) == 0 {
return "", fmt.Errorf("no cluster domains found for service %s", name)
}
index := rand.IntN(len(clusterDomains))
domain := name + "." + clusterDomains[index]
return domain, nil
}
// DeleteServiceFromPeer deletes a peer-initiated service.
// It validates that the service was created by a peer to prevent deleting API-created services.
func (m *managerImpl) DeleteServiceFromPeer(ctx context.Context, accountID, peerID, serviceID string) error {
return m.deletePeerService(ctx, accountID, peerID, serviceID, activity.PeerServiceUnexposed)
}
// ExpireServiceFromPeer deletes a peer-initiated service that was not renewed within the TTL.
func (m *managerImpl) ExpireServiceFromPeer(ctx context.Context, accountID, peerID, serviceID string) error {
return m.deletePeerService(ctx, accountID, peerID, serviceID, activity.PeerServiceExposeExpired)
}
func (m *managerImpl) deletePeerService(ctx context.Context, accountID, peerID, serviceID string, activityCode activity.Activity) error {
var service *reverseproxy.Service
err := m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
var err error
service, err = transaction.GetServiceByID(ctx, store.LockingStrengthUpdate, accountID, serviceID)
if err != nil {
return err
}
if service.Source != reverseproxy.SourceEphemeral {
return status.Errorf(status.PermissionDenied, "cannot delete API-created service via peer expose")
}
if service.SourcePeer != peerID {
return status.Errorf(status.PermissionDenied, "cannot delete service exposed by another peer")
}
if err = transaction.DeleteService(ctx, accountID, serviceID); err != nil {
return fmt.Errorf("delete service: %w", err)
}
return nil
})
if err != nil {
return err
}
peer, err := m.store.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peerID)
if err != nil {
log.WithContext(ctx).Debugf("failed to get peer %s for event metadata: %v", peerID, err)
peer = nil
}
meta := addPeerInfoToEventMeta(service.EventMeta(), peer)
m.accountManager.StoreEvent(ctx, peerID, serviceID, accountID, activityCode, meta)
m.sendServiceUpdate(service, reverseproxy.Delete, service.ProxyCluster, "")
m.accountManager.UpdateAccountPeers(ctx, accountID)
return nil
}
func addPeerInfoToEventMeta(meta map[string]any, peer *nbpeer.Peer) map[string]any {
if peer == nil {
return meta
}
meta["peer_name"] = peer.Name
if peer.IP != nil {
meta["peer_ip"] = peer.IP.String()
}
return meta
}

View File

@@ -3,6 +3,7 @@ package manager
import (
"context"
"errors"
"net"
"testing"
"time"
@@ -11,7 +12,16 @@ import (
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy"
nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
"github.com/netbirdio/netbird/management/server/activity"
"github.com/netbirdio/netbird/management/server/integrations/extra_settings"
"github.com/netbirdio/netbird/management/server/mock_server"
nbpeer "github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/server/permissions"
"github.com/netbirdio/netbird/management/server/settings"
"github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/management/server/users"
"github.com/netbirdio/netbird/shared/management/status"
)
@@ -356,7 +366,7 @@ func TestPreserveServiceMetadata(t *testing.T) {
existing := &reverseproxy.Service{
Meta: reverseproxy.ServiceMeta{
CertificateIssuedAt: time.Now(),
CertificateIssuedAt: func() *time.Time { t := time.Now(); return &t }(),
Status: "active",
},
SessionPrivateKey: "private-key",
@@ -373,3 +383,516 @@ func TestPreserveServiceMetadata(t *testing.T) {
assert.Equal(t, existing.SessionPrivateKey, updated.SessionPrivateKey)
assert.Equal(t, existing.SessionPublicKey, updated.SessionPublicKey)
}
func TestDeletePeerService_SourcePeerValidation(t *testing.T) {
ctx := context.Background()
accountID := "test-account"
ownerPeerID := "peer-owner"
otherPeerID := "peer-other"
serviceID := "service-123"
testPeer := &nbpeer.Peer{
ID: ownerPeerID,
Name: "test-peer",
IP: net.ParseIP("100.64.0.1"),
}
newEphemeralService := func() *reverseproxy.Service {
return &reverseproxy.Service{
ID: serviceID,
AccountID: accountID,
Name: "test-service",
Domain: "test.example.com",
Source: reverseproxy.SourceEphemeral,
SourcePeer: ownerPeerID,
}
}
newPermanentService := func() *reverseproxy.Service {
return &reverseproxy.Service{
ID: serviceID,
AccountID: accountID,
Name: "api-service",
Domain: "api.example.com",
Source: reverseproxy.SourcePermanent,
}
}
newProxyServer := func(t *testing.T) *nbgrpc.ProxyServiceServer {
t.Helper()
tokenStore := nbgrpc.NewOneTimeTokenStore(1 * time.Hour)
srv := nbgrpc.NewProxyServiceServer(nil, tokenStore, nbgrpc.ProxyOIDCConfig{}, nil, nil)
t.Cleanup(srv.Close)
return srv
}
t.Run("owner peer can delete own service", func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
var storedActivity activity.Activity
mockStore := store.NewMockStore(ctrl)
mockAccountMgr := &mock_server.MockAccountManager{
StoreEventFunc: func(_ context.Context, _, _, _ string, activityID activity.ActivityDescriber, _ map[string]any) {
storedActivity = activityID.(activity.Activity)
},
UpdateAccountPeersFunc: func(_ context.Context, _ string) {},
}
mockStore.EXPECT().
ExecuteInTransaction(ctx, gomock.Any()).
DoAndReturn(func(ctx context.Context, fn func(store.Store) error) error {
txMock := store.NewMockStore(ctrl)
txMock.EXPECT().
GetServiceByID(ctx, store.LockingStrengthUpdate, accountID, serviceID).
Return(newEphemeralService(), nil)
txMock.EXPECT().
DeleteService(ctx, accountID, serviceID).
Return(nil)
return fn(txMock)
})
mockStore.EXPECT().
GetPeerByID(ctx, store.LockingStrengthNone, accountID, ownerPeerID).
Return(testPeer, nil)
mgr := &managerImpl{
store: mockStore,
accountManager: mockAccountMgr,
proxyGRPCServer: newProxyServer(t),
}
err := mgr.deletePeerService(ctx, accountID, ownerPeerID, serviceID, activity.PeerServiceUnexposed)
require.NoError(t, err)
assert.Equal(t, activity.PeerServiceUnexposed, storedActivity, "should store unexposed activity")
})
t.Run("different peer cannot delete service", func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockStore := store.NewMockStore(ctrl)
mockStore.EXPECT().
ExecuteInTransaction(ctx, gomock.Any()).
DoAndReturn(func(ctx context.Context, fn func(store.Store) error) error {
txMock := store.NewMockStore(ctrl)
txMock.EXPECT().
GetServiceByID(ctx, store.LockingStrengthUpdate, accountID, serviceID).
Return(newEphemeralService(), nil)
return fn(txMock)
})
mgr := &managerImpl{
store: mockStore,
}
err := mgr.deletePeerService(ctx, accountID, otherPeerID, serviceID, activity.PeerServiceUnexposed)
require.Error(t, err)
sErr, ok := status.FromError(err)
require.True(t, ok, "should be a status error")
assert.Equal(t, status.PermissionDenied, sErr.Type(), "should be permission denied")
assert.Contains(t, err.Error(), "another peer")
})
t.Run("cannot delete API-created service", func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockStore := store.NewMockStore(ctrl)
mockStore.EXPECT().
ExecuteInTransaction(ctx, gomock.Any()).
DoAndReturn(func(ctx context.Context, fn func(store.Store) error) error {
txMock := store.NewMockStore(ctrl)
txMock.EXPECT().
GetServiceByID(ctx, store.LockingStrengthUpdate, accountID, serviceID).
Return(newPermanentService(), nil)
return fn(txMock)
})
mgr := &managerImpl{
store: mockStore,
}
err := mgr.deletePeerService(ctx, accountID, ownerPeerID, serviceID, activity.PeerServiceUnexposed)
require.Error(t, err)
sErr, ok := status.FromError(err)
require.True(t, ok, "should be a status error")
assert.Equal(t, status.PermissionDenied, sErr.Type(), "should be permission denied")
assert.Contains(t, err.Error(), "API-created")
})
t.Run("expire uses correct activity code", func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
var storedActivity activity.Activity
mockStore := store.NewMockStore(ctrl)
mockAccountMgr := &mock_server.MockAccountManager{
StoreEventFunc: func(_ context.Context, _, _, _ string, activityID activity.ActivityDescriber, _ map[string]any) {
storedActivity = activityID.(activity.Activity)
},
UpdateAccountPeersFunc: func(_ context.Context, _ string) {},
}
mockStore.EXPECT().
ExecuteInTransaction(ctx, gomock.Any()).
DoAndReturn(func(ctx context.Context, fn func(store.Store) error) error {
txMock := store.NewMockStore(ctrl)
txMock.EXPECT().
GetServiceByID(ctx, store.LockingStrengthUpdate, accountID, serviceID).
Return(newEphemeralService(), nil)
txMock.EXPECT().
DeleteService(ctx, accountID, serviceID).
Return(nil)
return fn(txMock)
})
mockStore.EXPECT().
GetPeerByID(ctx, store.LockingStrengthNone, accountID, ownerPeerID).
Return(testPeer, nil)
mgr := &managerImpl{
store: mockStore,
accountManager: mockAccountMgr,
proxyGRPCServer: newProxyServer(t),
}
err := mgr.deletePeerService(ctx, accountID, ownerPeerID, serviceID, activity.PeerServiceExposeExpired)
require.NoError(t, err)
assert.Equal(t, activity.PeerServiceExposeExpired, storedActivity, "should store expired activity")
})
t.Run("event meta includes peer info", func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
var storedMeta map[string]any
mockStore := store.NewMockStore(ctrl)
mockAccountMgr := &mock_server.MockAccountManager{
StoreEventFunc: func(_ context.Context, _, _, _ string, _ activity.ActivityDescriber, meta map[string]any) {
storedMeta = meta
},
UpdateAccountPeersFunc: func(_ context.Context, _ string) {},
}
mockStore.EXPECT().
ExecuteInTransaction(ctx, gomock.Any()).
DoAndReturn(func(ctx context.Context, fn func(store.Store) error) error {
txMock := store.NewMockStore(ctrl)
txMock.EXPECT().
GetServiceByID(ctx, store.LockingStrengthUpdate, accountID, serviceID).
Return(newEphemeralService(), nil)
txMock.EXPECT().
DeleteService(ctx, accountID, serviceID).
Return(nil)
return fn(txMock)
})
mockStore.EXPECT().
GetPeerByID(ctx, store.LockingStrengthNone, accountID, ownerPeerID).
Return(testPeer, nil)
mgr := &managerImpl{
store: mockStore,
accountManager: mockAccountMgr,
proxyGRPCServer: newProxyServer(t),
}
err := mgr.deletePeerService(ctx, accountID, ownerPeerID, serviceID, activity.PeerServiceUnexposed)
require.NoError(t, err)
require.NotNil(t, storedMeta)
assert.Equal(t, "test-peer", storedMeta["peer_name"], "meta should contain peer name")
assert.Equal(t, "100.64.0.1", storedMeta["peer_ip"], "meta should contain peer IP")
assert.Equal(t, "test-service", storedMeta["name"], "meta should contain service name")
assert.Equal(t, "test.example.com", storedMeta["domain"], "meta should contain service domain")
})
}
// noopExtraSettings is a minimal extra_settings.Manager for tests without external integrations.
type noopExtraSettings struct{}
func (n *noopExtraSettings) GetExtraSettings(_ context.Context, _ string) (*types.ExtraSettings, error) {
return &types.ExtraSettings{}, nil
}
func (n *noopExtraSettings) UpdateExtraSettings(_ context.Context, _, _ string, _ *types.ExtraSettings) (bool, error) {
return false, nil
}
var _ extra_settings.Manager = (*noopExtraSettings)(nil)
// testClusterDeriver is a minimal ClusterDeriver that returns a fixed domain list.
type testClusterDeriver struct {
domains []string
}
func (d *testClusterDeriver) DeriveClusterFromDomain(_ context.Context, _, domain string) (string, error) {
return "test-cluster", nil
}
func (d *testClusterDeriver) GetClusterDomains() []string {
return d.domains
}
const (
testAccountID = "test-account"
testPeerID = "test-peer-1"
testGroupID = "test-group-1"
testUserID = "test-user"
)
// setupIntegrationTest creates a real SQLite store with seeded test data for integration tests.
func setupIntegrationTest(t *testing.T) (*managerImpl, store.Store) {
t.Helper()
ctx := context.Background()
testStore, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir())
require.NoError(t, err)
t.Cleanup(cleanup)
err = testStore.SaveAccount(ctx, &types.Account{
Id: testAccountID,
CreatedBy: testUserID,
Settings: &types.Settings{
PeerExposeEnabled: true,
PeerExposeGroups: []string{testGroupID},
},
Peers: map[string]*nbpeer.Peer{
testPeerID: {
ID: testPeerID,
AccountID: testAccountID,
Key: "test-key",
DNSLabel: "test-peer",
Name: "test-peer",
IP: net.ParseIP("100.64.0.1"),
Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now()},
Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer"},
},
},
Groups: map[string]*types.Group{
testGroupID: {
ID: testGroupID,
AccountID: testAccountID,
Name: "Expose Group",
},
},
})
require.NoError(t, err)
err = testStore.AddPeerToGroup(ctx, testAccountID, testPeerID, testGroupID)
require.NoError(t, err)
permsMgr := permissions.NewManager(testStore)
usersMgr := users.NewManager(testStore)
settingsMgr := settings.NewManager(testStore, usersMgr, &noopExtraSettings{}, permsMgr, settings.IdpConfig{})
var storedEvents []activity.Activity
accountMgr := &mock_server.MockAccountManager{
StoreEventFunc: func(_ context.Context, _, _, _ string, activityID activity.ActivityDescriber, _ map[string]any) {
storedEvents = append(storedEvents, activityID.(activity.Activity))
},
UpdateAccountPeersFunc: func(_ context.Context, _ string) {},
GetGroupByNameFunc: func(ctx context.Context, accountID, groupName string) (*types.Group, error) {
return testStore.GetGroupByName(ctx, store.LockingStrengthNone, groupName, accountID)
},
}
tokenStore := nbgrpc.NewOneTimeTokenStore(1 * time.Hour)
proxySrv := nbgrpc.NewProxyServiceServer(nil, tokenStore, nbgrpc.ProxyOIDCConfig{}, nil, nil)
t.Cleanup(proxySrv.Close)
mgr := &managerImpl{
store: testStore,
accountManager: accountMgr,
permissionsManager: permsMgr,
settingsManager: settingsMgr,
proxyGRPCServer: proxySrv,
clusterDeriver: &testClusterDeriver{
domains: []string{"test.netbird.io"},
},
}
return mgr, testStore
}
func TestValidateExposePermission(t *testing.T) {
ctx := context.Background()
t.Run("allowed when peer is in expose group", func(t *testing.T) {
mgr, _ := setupIntegrationTest(t)
err := mgr.ValidateExposePermission(ctx, testAccountID, testPeerID)
assert.NoError(t, err)
})
t.Run("denied when peer is not in expose group", func(t *testing.T) {
mgr, testStore := setupIntegrationTest(t)
// Add a peer that is NOT in the expose group
otherPeerID := "other-peer"
err := testStore.AddPeerToAccount(ctx, &nbpeer.Peer{
ID: otherPeerID,
AccountID: testAccountID,
Key: "other-key",
DNSLabel: "other-peer",
Name: "other-peer",
IP: net.ParseIP("100.64.0.2"),
Status: &nbpeer.PeerStatus{LastSeen: time.Now()},
Meta: nbpeer.PeerSystemMeta{Hostname: "other-peer"},
})
require.NoError(t, err)
err = mgr.ValidateExposePermission(ctx, testAccountID, otherPeerID)
require.Error(t, err)
assert.Contains(t, err.Error(), "not in an allowed expose group")
})
t.Run("denied when expose is disabled", func(t *testing.T) {
mgr, testStore := setupIntegrationTest(t)
// Disable peer expose
s, err := testStore.GetAccountSettings(ctx, store.LockingStrengthNone, testAccountID)
require.NoError(t, err)
s.PeerExposeEnabled = false
err = testStore.SaveAccountSettings(ctx, testAccountID, s)
require.NoError(t, err)
err = mgr.ValidateExposePermission(ctx, testAccountID, testPeerID)
require.Error(t, err)
assert.Contains(t, err.Error(), "not enabled")
})
t.Run("disallowed when no groups configured", func(t *testing.T) {
mgr, testStore := setupIntegrationTest(t)
// Enable expose with empty groups — no groups configured means no peer is allowed
s, err := testStore.GetAccountSettings(ctx, store.LockingStrengthNone, testAccountID)
require.NoError(t, err)
s.PeerExposeGroups = []string{}
err = testStore.SaveAccountSettings(ctx, testAccountID, s)
require.NoError(t, err)
err = mgr.ValidateExposePermission(ctx, testAccountID, testPeerID)
assert.Error(t, err)
})
t.Run("error when store returns error", func(t *testing.T) {
ctrl := gomock.NewController(t)
mockStore := store.NewMockStore(ctrl)
mockStore.EXPECT().GetAccountSettings(gomock.Any(), gomock.Any(), testAccountID).Return(nil, errors.New("store error"))
mgr := &managerImpl{store: mockStore}
err := mgr.ValidateExposePermission(ctx, testAccountID, testPeerID)
require.Error(t, err)
assert.Contains(t, err.Error(), "get account settings")
})
}
func TestCreateServiceFromPeer(t *testing.T) {
ctx := context.Background()
t.Run("creates service with random domain", func(t *testing.T) {
mgr, testStore := setupIntegrationTest(t)
service := &reverseproxy.Service{
Name: "my-expose",
Enabled: true,
Targets: []*reverseproxy.Target{
{
AccountID: testAccountID,
Port: 8080,
Protocol: "http",
TargetId: testPeerID,
TargetType: reverseproxy.TargetTypePeer,
Enabled: true,
},
},
}
created, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, service)
require.NoError(t, err)
assert.NotEmpty(t, created.ID, "service should have an ID")
assert.Contains(t, created.Domain, "test.netbird.io", "domain should use cluster domain")
assert.Equal(t, reverseproxy.SourceEphemeral, created.Source, "source should be ephemeral")
assert.Equal(t, testPeerID, created.SourcePeer, "source peer should be set")
assert.NotNil(t, created.Meta.LastRenewedAt, "last renewed should be set")
// Verify service is persisted in store
persisted, err := testStore.GetServiceByID(ctx, store.LockingStrengthNone, testAccountID, created.ID)
require.NoError(t, err)
assert.Equal(t, created.ID, persisted.ID)
assert.Equal(t, created.Domain, persisted.Domain)
})
t.Run("creates service with custom domain", func(t *testing.T) {
mgr, _ := setupIntegrationTest(t)
service := &reverseproxy.Service{
Name: "custom",
Domain: "custom.example.com",
Enabled: true,
Targets: []*reverseproxy.Target{
{
AccountID: testAccountID,
Port: 80,
Protocol: "http",
TargetId: testPeerID,
TargetType: reverseproxy.TargetTypePeer,
Enabled: true,
},
},
}
created, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, service)
require.NoError(t, err)
assert.Equal(t, "custom.example.com", created.Domain, "should keep the provided domain")
})
t.Run("replaces host by peer IP lookup", func(t *testing.T) {
mgr, _ := setupIntegrationTest(t)
service := &reverseproxy.Service{
Name: "lookup-test",
Enabled: true,
Targets: []*reverseproxy.Target{
{
AccountID: testAccountID,
Port: 3000,
Protocol: "http",
TargetId: testPeerID,
TargetType: reverseproxy.TargetTypePeer,
Enabled: true,
},
},
}
created, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, service)
require.NoError(t, err)
require.Len(t, created.Targets, 1)
assert.Equal(t, "100.64.0.1", created.Targets[0].Host, "host should be resolved to peer IP")
})
}
func TestGetGroupIDsFromNames(t *testing.T) {
ctx := context.Background()
t.Run("resolves group names to IDs", func(t *testing.T) {
mgr, _ := setupIntegrationTest(t)
ids, err := mgr.getGroupIDsFromNames(ctx, testAccountID, []string{"Expose Group"})
require.NoError(t, err)
require.Len(t, ids, 1, "should return exactly one group ID")
assert.Equal(t, testGroupID, ids[0])
})
t.Run("returns error for unknown group", func(t *testing.T) {
mgr, _ := setupIntegrationTest(t)
_, err := mgr.getGroupIDsFromNames(ctx, testAccountID, []string{"nonexistent"})
require.Error(t, err)
})
t.Run("returns error for empty group list", func(t *testing.T) {
mgr, _ := setupIntegrationTest(t)
_, err := mgr.getGroupIDsFromNames(ctx, testAccountID, []string{})
require.Error(t, err)
assert.Contains(t, err.Error(), "no group names provided")
})
}