mirror of
https://github.com/netbirdio/netbird.git
synced 2026-04-16 07:16:38 +00:00
[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:
@@ -9,4 +9,5 @@ type Manager interface {
|
||||
CreateDomain(ctx context.Context, accountID, userID, domainName, targetCluster string) (*Domain, error)
|
||||
DeleteDomain(ctx context.Context, accountID, userID, domainID string) error
|
||||
ValidateDomain(ctx context.Context, accountID, userID, domainID string)
|
||||
GetClusterDomains() []string
|
||||
}
|
||||
|
||||
@@ -221,6 +221,10 @@ func (m Manager) ValidateDomain(ctx context.Context, accountID, userID, domainID
|
||||
}
|
||||
}
|
||||
|
||||
func (m Manager) GetClusterDomains() []string {
|
||||
return m.proxyURLAllowList()
|
||||
}
|
||||
|
||||
// proxyURLAllowList retrieves a list of currently connected proxies and
|
||||
// their URLs
|
||||
func (m Manager) proxyURLAllowList() []string {
|
||||
|
||||
@@ -21,4 +21,8 @@ type Manager interface {
|
||||
GetServiceByID(ctx context.Context, accountID, serviceID string) (*Service, error)
|
||||
GetAccountServices(ctx context.Context, accountID string) ([]*Service, error)
|
||||
GetServiceIDByTargetID(ctx context.Context, accountID string, resourceID string) (string, error)
|
||||
ValidateExposePermission(ctx context.Context, accountID, peerID string) error
|
||||
CreateServiceFromPeer(ctx context.Context, accountID, peerID string, service *Service) (*Service, error)
|
||||
DeleteServiceFromPeer(ctx context.Context, accountID, peerID, serviceID string) error
|
||||
ExpireServiceFromPeer(ctx context.Context, accountID, peerID, serviceID string) error
|
||||
}
|
||||
|
||||
@@ -63,6 +63,21 @@ func (mr *MockManagerMockRecorder) DeleteAllServices(ctx, accountID, userID inte
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAllServices", reflect.TypeOf((*MockManager)(nil).DeleteAllServices), ctx, accountID, userID)
|
||||
}
|
||||
|
||||
// CreateServiceFromPeer mocks base method.
|
||||
func (m *MockManager) CreateServiceFromPeer(ctx context.Context, accountID, peerID string, service *Service) (*Service, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "CreateServiceFromPeer", ctx, accountID, peerID, service)
|
||||
ret0, _ := ret[0].(*Service)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// CreateServiceFromPeer indicates an expected call of CreateServiceFromPeer.
|
||||
func (mr *MockManagerMockRecorder) CreateServiceFromPeer(ctx, accountID, peerID, service interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateServiceFromPeer", reflect.TypeOf((*MockManager)(nil).CreateServiceFromPeer), ctx, accountID, peerID, service)
|
||||
}
|
||||
|
||||
// DeleteService mocks base method.
|
||||
func (m *MockManager) DeleteService(ctx context.Context, accountID, userID, serviceID string) error {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -77,6 +92,48 @@ func (mr *MockManagerMockRecorder) DeleteService(ctx, accountID, userID, service
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteService", reflect.TypeOf((*MockManager)(nil).DeleteService), ctx, accountID, userID, serviceID)
|
||||
}
|
||||
|
||||
// DeleteServiceFromPeer mocks base method.
|
||||
func (m *MockManager) DeleteServiceFromPeer(ctx context.Context, accountID, peerID, serviceID string) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "DeleteServiceFromPeer", ctx, accountID, peerID, serviceID)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// DeleteServiceFromPeer indicates an expected call of DeleteServiceFromPeer.
|
||||
func (mr *MockManagerMockRecorder) DeleteServiceFromPeer(ctx, accountID, peerID, serviceID interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteServiceFromPeer", reflect.TypeOf((*MockManager)(nil).DeleteServiceFromPeer), ctx, accountID, peerID, serviceID)
|
||||
}
|
||||
|
||||
// ExpireServiceFromPeer mocks base method.
|
||||
func (m *MockManager) ExpireServiceFromPeer(ctx context.Context, accountID, peerID, serviceID string) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "ExpireServiceFromPeer", ctx, accountID, peerID, serviceID)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// ExpireServiceFromPeer indicates an expected call of ExpireServiceFromPeer.
|
||||
func (mr *MockManagerMockRecorder) ExpireServiceFromPeer(ctx, accountID, peerID, serviceID interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExpireServiceFromPeer", reflect.TypeOf((*MockManager)(nil).ExpireServiceFromPeer), ctx, accountID, peerID, serviceID)
|
||||
}
|
||||
|
||||
// ValidateExposePermission mocks base method.
|
||||
func (m *MockManager) ValidateExposePermission(ctx context.Context, accountID, peerID string) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "ValidateExposePermission", ctx, accountID, peerID)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// ValidateExposePermission indicates an expected call of ValidateExposePermission.
|
||||
func (mr *MockManagerMockRecorder) ValidateExposePermission(ctx, accountID, peerID interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidateExposePermission", reflect.TypeOf((*MockManager)(nil).ValidateExposePermission), ctx, accountID, peerID)
|
||||
}
|
||||
|
||||
// GetAccountServices mocks base method.
|
||||
func (m *MockManager) GetAccountServices(ctx context.Context, accountID string) ([]*Service, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package reverseproxy
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -40,6 +43,9 @@ const (
|
||||
TargetTypeHost = "host"
|
||||
TargetTypeDomain = "domain"
|
||||
TargetTypeSubnet = "subnet"
|
||||
|
||||
SourcePermanent = "permanent"
|
||||
SourceEphemeral = "ephemeral"
|
||||
)
|
||||
|
||||
type Target struct {
|
||||
@@ -114,8 +120,9 @@ type OIDCValidationConfig struct {
|
||||
|
||||
type ServiceMeta struct {
|
||||
CreatedAt time.Time
|
||||
CertificateIssuedAt time.Time
|
||||
CertificateIssuedAt *time.Time
|
||||
Status string
|
||||
LastRenewedAt *time.Time
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
@@ -132,6 +139,8 @@ type Service struct {
|
||||
Meta ServiceMeta `gorm:"embedded;embeddedPrefix:meta_"`
|
||||
SessionPrivateKey string `gorm:"column:session_private_key"`
|
||||
SessionPublicKey string `gorm:"column:session_public_key"`
|
||||
Source string `gorm:"default:'permanent'"`
|
||||
SourcePeer string
|
||||
}
|
||||
|
||||
func NewService(accountID, name, domain, proxyCluster string, targets []*Target, enabled bool) *Service {
|
||||
@@ -207,8 +216,8 @@ func (s *Service) ToAPIResponse() *api.Service {
|
||||
Status: api.ServiceMetaStatus(s.Meta.Status),
|
||||
}
|
||||
|
||||
if !s.Meta.CertificateIssuedAt.IsZero() {
|
||||
meta.CertificateIssuedAt = &s.Meta.CertificateIssuedAt
|
||||
if s.Meta.CertificateIssuedAt != nil {
|
||||
meta.CertificateIssuedAt = s.Meta.CertificateIssuedAt
|
||||
}
|
||||
|
||||
resp := &api.Service{
|
||||
@@ -309,6 +318,63 @@ func isDefaultPort(scheme string, port int) bool {
|
||||
return (scheme == "https" && port == 443) || (scheme == "http" && port == 80)
|
||||
}
|
||||
|
||||
// FromExposeRequest builds a Service from a peer expose gRPC request.
|
||||
func FromExposeRequest(req *proto.ExposeServiceRequest, accountID, peerID, serviceName string) *Service {
|
||||
service := &Service{
|
||||
AccountID: accountID,
|
||||
Name: serviceName,
|
||||
Enabled: true,
|
||||
Targets: []*Target{
|
||||
{
|
||||
AccountID: accountID,
|
||||
Port: int(req.Port),
|
||||
Protocol: exposeProtocolToString(req.Protocol),
|
||||
TargetId: peerID,
|
||||
TargetType: TargetTypePeer,
|
||||
Enabled: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if req.Domain != "" {
|
||||
service.Domain = serviceName + "." + req.Domain
|
||||
}
|
||||
|
||||
if req.Pin != "" {
|
||||
service.Auth.PinAuth = &PINAuthConfig{
|
||||
Enabled: true,
|
||||
Pin: req.Pin,
|
||||
}
|
||||
}
|
||||
|
||||
if req.Password != "" {
|
||||
service.Auth.PasswordAuth = &PasswordAuthConfig{
|
||||
Enabled: true,
|
||||
Password: req.Password,
|
||||
}
|
||||
}
|
||||
|
||||
if len(req.UserGroups) > 0 {
|
||||
service.Auth.BearerAuth = &BearerAuthConfig{
|
||||
Enabled: true,
|
||||
DistributionGroups: req.UserGroups,
|
||||
}
|
||||
}
|
||||
|
||||
return service
|
||||
}
|
||||
|
||||
func exposeProtocolToString(p proto.ExposeProtocol) string {
|
||||
switch p {
|
||||
case proto.ExposeProtocol_EXPOSE_HTTP:
|
||||
return "http"
|
||||
case proto.ExposeProtocol_EXPOSE_HTTPS:
|
||||
return "https"
|
||||
default:
|
||||
return "http"
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) FromAPIRequest(req *api.ServiceRequest, accountID string) {
|
||||
s.Name = req.Name
|
||||
s.Domain = req.Domain
|
||||
@@ -403,7 +469,11 @@ func (s *Service) Validate() error {
|
||||
}
|
||||
|
||||
func (s *Service) EventMeta() map[string]any {
|
||||
return map[string]any{"name": s.Name, "domain": s.Domain, "proxy_cluster": s.ProxyCluster}
|
||||
return map[string]any{"name": s.Name, "domain": s.Domain, "proxy_cluster": s.ProxyCluster, "source": s.Source, "auth": s.isAuthEnabled()}
|
||||
}
|
||||
|
||||
func (s *Service) isAuthEnabled() bool {
|
||||
return s.Auth.PasswordAuth != nil || s.Auth.PinAuth != nil || s.Auth.BearerAuth != nil
|
||||
}
|
||||
|
||||
func (s *Service) Copy() *Service {
|
||||
@@ -427,6 +497,8 @@ func (s *Service) Copy() *Service {
|
||||
Meta: s.Meta,
|
||||
SessionPrivateKey: s.SessionPrivateKey,
|
||||
SessionPublicKey: s.SessionPublicKey,
|
||||
Source: s.Source,
|
||||
SourcePeer: s.SourcePeer,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,3 +533,43 @@ func (s *Service) DecryptSensitiveData(enc *crypt.FieldEncrypt) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
const alphanumCharset = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
|
||||
var validNamePrefix = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$`)
|
||||
|
||||
// GenerateExposeName generates a random service name for peer-exposed services.
|
||||
// The prefix, if provided, must be a valid DNS label component (lowercase alphanumeric and hyphens).
|
||||
func GenerateExposeName(prefix string) (string, error) {
|
||||
if prefix != "" && !validNamePrefix.MatchString(prefix) {
|
||||
return "", fmt.Errorf("invalid name prefix %q: must be lowercase alphanumeric with optional hyphens, 1-32 characters", prefix)
|
||||
}
|
||||
|
||||
suffixLen := 12
|
||||
if prefix != "" {
|
||||
suffixLen = 4
|
||||
}
|
||||
|
||||
suffix, err := randomAlphanumeric(suffixLen)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("generate random name: %w", err)
|
||||
}
|
||||
|
||||
if prefix == "" {
|
||||
return suffix, nil
|
||||
}
|
||||
return prefix + "-" + suffix, nil
|
||||
}
|
||||
|
||||
func randomAlphanumeric(n int) (string, error) {
|
||||
result := make([]byte, n)
|
||||
charsetLen := big.NewInt(int64(len(alphanumCharset)))
|
||||
for i := range result {
|
||||
idx, err := rand.Int(rand.Reader, charsetLen)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
result[i] = alphanumCharset[idx.Int64()]
|
||||
}
|
||||
return string(result), nil
|
||||
}
|
||||
|
||||
@@ -403,3 +403,146 @@ func TestAuthConfig_ClearSecrets(t *testing.T) {
|
||||
t.Errorf("PIN not cleared, got: %s", config.PinAuth.Pin)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateExposeName(t *testing.T) {
|
||||
t.Run("no prefix generates 12-char name", func(t *testing.T) {
|
||||
name, err := GenerateExposeName("")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, name, 12)
|
||||
assert.Regexp(t, `^[a-z0-9]+$`, name)
|
||||
})
|
||||
|
||||
t.Run("with prefix generates prefix-XXXX", func(t *testing.T) {
|
||||
name, err := GenerateExposeName("myapp")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, strings.HasPrefix(name, "myapp-"), "name should start with prefix")
|
||||
suffix := strings.TrimPrefix(name, "myapp-")
|
||||
assert.Len(t, suffix, 4, "suffix should be 4 chars")
|
||||
assert.Regexp(t, `^[a-z0-9]+$`, suffix)
|
||||
})
|
||||
|
||||
t.Run("unique names", func(t *testing.T) {
|
||||
names := make(map[string]bool)
|
||||
for i := 0; i < 50; i++ {
|
||||
name, err := GenerateExposeName("")
|
||||
require.NoError(t, err)
|
||||
names[name] = true
|
||||
}
|
||||
assert.Greater(t, len(names), 45, "should generate mostly unique names")
|
||||
})
|
||||
|
||||
t.Run("valid prefixes", func(t *testing.T) {
|
||||
validPrefixes := []string{"a", "ab", "a1", "my-app", "web-server-01", "a-b"}
|
||||
for _, prefix := range validPrefixes {
|
||||
name, err := GenerateExposeName(prefix)
|
||||
assert.NoError(t, err, "prefix %q should be valid", prefix)
|
||||
assert.True(t, strings.HasPrefix(name, prefix+"-"), "name should start with %q-", prefix)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid prefixes", func(t *testing.T) {
|
||||
invalidPrefixes := []string{
|
||||
"-starts-with-dash",
|
||||
"ends-with-dash-",
|
||||
"has.dots",
|
||||
"HAS-UPPER",
|
||||
"has spaces",
|
||||
"has/slash",
|
||||
"a--",
|
||||
}
|
||||
for _, prefix := range invalidPrefixes {
|
||||
_, err := GenerateExposeName(prefix)
|
||||
assert.Error(t, err, "prefix %q should be invalid", prefix)
|
||||
assert.Contains(t, err.Error(), "invalid name prefix")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFromExposeRequest(t *testing.T) {
|
||||
t.Run("basic HTTP service", func(t *testing.T) {
|
||||
req := &proto.ExposeServiceRequest{
|
||||
Port: 8080,
|
||||
Protocol: proto.ExposeProtocol_EXPOSE_HTTP,
|
||||
}
|
||||
|
||||
service := FromExposeRequest(req, "account-1", "peer-1", "mysvc")
|
||||
|
||||
assert.Equal(t, "account-1", service.AccountID)
|
||||
assert.Equal(t, "mysvc", service.Name)
|
||||
assert.True(t, service.Enabled)
|
||||
assert.Empty(t, service.Domain, "domain should be empty when not specified")
|
||||
require.Len(t, service.Targets, 1)
|
||||
|
||||
target := service.Targets[0]
|
||||
assert.Equal(t, 8080, target.Port)
|
||||
assert.Equal(t, "http", target.Protocol)
|
||||
assert.Equal(t, "peer-1", target.TargetId)
|
||||
assert.Equal(t, TargetTypePeer, target.TargetType)
|
||||
assert.True(t, target.Enabled)
|
||||
assert.Equal(t, "account-1", target.AccountID)
|
||||
})
|
||||
|
||||
t.Run("with custom domain", func(t *testing.T) {
|
||||
req := &proto.ExposeServiceRequest{
|
||||
Port: 3000,
|
||||
Domain: "example.com",
|
||||
}
|
||||
|
||||
service := FromExposeRequest(req, "acc", "peer", "web")
|
||||
assert.Equal(t, "web.example.com", service.Domain)
|
||||
})
|
||||
|
||||
t.Run("with PIN auth", func(t *testing.T) {
|
||||
req := &proto.ExposeServiceRequest{
|
||||
Port: 80,
|
||||
Pin: "1234",
|
||||
}
|
||||
|
||||
service := FromExposeRequest(req, "acc", "peer", "svc")
|
||||
require.NotNil(t, service.Auth.PinAuth)
|
||||
assert.True(t, service.Auth.PinAuth.Enabled)
|
||||
assert.Equal(t, "1234", service.Auth.PinAuth.Pin)
|
||||
assert.Nil(t, service.Auth.PasswordAuth)
|
||||
assert.Nil(t, service.Auth.BearerAuth)
|
||||
})
|
||||
|
||||
t.Run("with password auth", func(t *testing.T) {
|
||||
req := &proto.ExposeServiceRequest{
|
||||
Port: 80,
|
||||
Password: "secret",
|
||||
}
|
||||
|
||||
service := FromExposeRequest(req, "acc", "peer", "svc")
|
||||
require.NotNil(t, service.Auth.PasswordAuth)
|
||||
assert.True(t, service.Auth.PasswordAuth.Enabled)
|
||||
assert.Equal(t, "secret", service.Auth.PasswordAuth.Password)
|
||||
})
|
||||
|
||||
t.Run("with user groups (bearer auth)", func(t *testing.T) {
|
||||
req := &proto.ExposeServiceRequest{
|
||||
Port: 80,
|
||||
UserGroups: []string{"admins", "devs"},
|
||||
}
|
||||
|
||||
service := FromExposeRequest(req, "acc", "peer", "svc")
|
||||
require.NotNil(t, service.Auth.BearerAuth)
|
||||
assert.True(t, service.Auth.BearerAuth.Enabled)
|
||||
assert.Equal(t, []string{"admins", "devs"}, service.Auth.BearerAuth.DistributionGroups)
|
||||
})
|
||||
|
||||
t.Run("with all auth types", func(t *testing.T) {
|
||||
req := &proto.ExposeServiceRequest{
|
||||
Port: 443,
|
||||
Domain: "myco.com",
|
||||
Pin: "9999",
|
||||
Password: "pass",
|
||||
UserGroups: []string{"ops"},
|
||||
}
|
||||
|
||||
service := FromExposeRequest(req, "acc", "peer", "full")
|
||||
assert.Equal(t, "full.myco.com", service.Domain)
|
||||
require.NotNil(t, service.Auth.PinAuth)
|
||||
require.NotNil(t, service.Auth.PasswordAuth)
|
||||
require.NotNil(t, service.Auth.BearerAuth)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user