mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-22 13:29:08 +02:00
Merge remote-tracking branch 'origin/main' into embedded-vnc
# Conflicts: # client/cmd/up.go # client/internal/engine.go # client/internal/profilemanager/config.go # client/proto/daemon.pb.go # client/server/mdm.go # client/server/server.go # client/status/status.go # client/ui/main.go # client/ui/services/windowmanager.go # management/internals/shared/grpc/conversion.go # management/server/store/sql_store.go # management/server/store/sql_store_test.go # shared/management/networkmap/envelope.go # shared/management/proto/management.pb.go
This commit is contained in:
+189
-55
@@ -35,7 +35,6 @@ import (
|
||||
"github.com/netbirdio/netbird/management/server/geolocation"
|
||||
"github.com/netbirdio/netbird/management/server/idp"
|
||||
"github.com/netbirdio/netbird/management/server/integrations/integrated_validator"
|
||||
"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
|
||||
"github.com/netbirdio/netbird/management/server/job"
|
||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||
"github.com/netbirdio/netbird/management/server/permissions"
|
||||
@@ -84,7 +83,6 @@ type DefaultAccountManager struct {
|
||||
|
||||
requestBuffer *AccountRequestBuffer
|
||||
|
||||
proxyController port_forwarding.Controller
|
||||
settingsManager settings.Manager
|
||||
serviceManager service.Manager
|
||||
|
||||
@@ -102,6 +100,8 @@ type DefaultAccountManager struct {
|
||||
|
||||
peerInactivityExpiry Scheduler
|
||||
|
||||
certChallenges *certChallengeRefresher
|
||||
|
||||
// userDeleteFromIDPEnabled allows to delete user from IDP when user is deleted from account
|
||||
userDeleteFromIDPEnabled bool
|
||||
|
||||
@@ -112,6 +112,9 @@ type DefaultAccountManager struct {
|
||||
permissionsManager permissions.Manager
|
||||
|
||||
disableDefaultPolicy bool
|
||||
|
||||
deletionHooksMu sync.RWMutex
|
||||
deletionHooks []account.DeletionHook
|
||||
}
|
||||
|
||||
var _ account.Manager = (*DefaultAccountManager)(nil)
|
||||
@@ -120,6 +123,32 @@ func (am *DefaultAccountManager) SetServiceManager(serviceManager service.Manage
|
||||
am.serviceManager = serviceManager
|
||||
}
|
||||
|
||||
// AddAccountDeletionHook registers hook to run on every account deletion. Hooks run in
|
||||
// registration order, and the first one to fail stops the rest and aborts the deletion.
|
||||
// It panics on a nil hook: dropping one silently would skip that hook's cleanup on every
|
||||
// deletion, so the wiring bug surfaces at startup instead.
|
||||
func (am *DefaultAccountManager) AddAccountDeletionHook(hook account.DeletionHook) {
|
||||
if hook == nil {
|
||||
panic("nil account deletion hook")
|
||||
}
|
||||
am.deletionHooksMu.Lock()
|
||||
defer am.deletionHooksMu.Unlock()
|
||||
am.deletionHooks = append(am.deletionHooks, hook)
|
||||
}
|
||||
|
||||
func (am *DefaultAccountManager) runAccountDeletionHooks(ctx context.Context, accountID string) error {
|
||||
am.deletionHooksMu.RLock()
|
||||
hooks := slices.Clone(am.deletionHooks)
|
||||
am.deletionHooksMu.RUnlock()
|
||||
|
||||
for _, hook := range hooks {
|
||||
if err := hook(ctx, accountID); err != nil {
|
||||
return fmt.Errorf("account deletion hook: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isUniqueConstraintError(err error) bool {
|
||||
switch {
|
||||
case strings.Contains(err.Error(), "(SQLSTATE 23505)"),
|
||||
@@ -196,7 +225,6 @@ func BuildManager(
|
||||
userDeleteFromIDPEnabled bool,
|
||||
integratedPeerValidator integrated_validator.IntegratedValidator,
|
||||
metrics telemetry.AppMetrics,
|
||||
proxyController port_forwarding.Controller,
|
||||
settingsManager settings.Manager,
|
||||
permissionsManager permissions.Manager,
|
||||
disableDefaultPolicy bool,
|
||||
@@ -224,12 +252,13 @@ func BuildManager(
|
||||
integratedPeerValidator: integratedPeerValidator,
|
||||
metrics: metrics,
|
||||
requestBuffer: NewAccountRequestBuffer(ctx, store),
|
||||
proxyController: proxyController,
|
||||
settingsManager: settingsManager,
|
||||
permissionsManager: permissionsManager,
|
||||
disableDefaultPolicy: disableDefaultPolicy,
|
||||
}
|
||||
|
||||
am.certChallenges = newCertChallengeRefresher(am.refreshCertificateChallenges)
|
||||
|
||||
am.networkMapController.StartWarmup(ctx)
|
||||
|
||||
accountsCounter, err := store.GetAccountsCounter(ctx)
|
||||
@@ -271,6 +300,10 @@ func BuildManager(
|
||||
am.onPeersInvalidated(ctx, accountID, peerIDs)
|
||||
})
|
||||
|
||||
// Started last: a manager that fails to build is never returned, so nothing would
|
||||
// stop its refresher.
|
||||
am.certChallenges.Start(ctx)
|
||||
|
||||
return am, nil
|
||||
}
|
||||
|
||||
@@ -305,6 +338,9 @@ func (am *DefaultAccountManager) UpdateAccountSettings(ctx context.Context, acco
|
||||
var groupChangesAffectPeers bool
|
||||
var reloadReverseProxy bool
|
||||
var effectiveOldNetworkRange netip.Prefix
|
||||
var ipv6Changed bool
|
||||
var ipv6Snap *affectedpeers.Snapshot
|
||||
var ipv6Change affectedpeers.Change
|
||||
|
||||
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
|
||||
var groupsUpdated bool
|
||||
@@ -350,10 +386,10 @@ func (am *DefaultAccountManager) UpdateAccountSettings(ctx context.Context, acco
|
||||
}
|
||||
|
||||
if ipv6SettingsChanged(oldSettings, newSettings) {
|
||||
if err = am.updatePeerIPv6Addresses(ctx, transaction, accountID, newSettings); err != nil {
|
||||
if ipv6Change, err = am.applyIPv6SettingsChange(ctx, transaction, accountID, oldSettings, newSettings); err != nil {
|
||||
return err
|
||||
}
|
||||
updateAccountPeers = true
|
||||
ipv6Changed = true
|
||||
}
|
||||
|
||||
if oldSettings.RoutingPeerDNSResolutionEnabled != newSettings.RoutingPeerDNSResolutionEnabled ||
|
||||
@@ -390,12 +426,20 @@ func (am *DefaultAccountManager) UpdateAccountSettings(ctx context.Context, acco
|
||||
return err
|
||||
}
|
||||
|
||||
if updateAccountPeers || groupsUpdated {
|
||||
if updateAccountPeers || groupsUpdated || ipv6Changed {
|
||||
if err = transaction.IncrementNetworkSerial(ctx, accountID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// A full account refresh already covers the IPv6 change, so the affected-peers
|
||||
// snapshot is only needed when nothing account-wide changed.
|
||||
if ipv6Changed && !updateAccountPeers && !groupChangesAffectPeers {
|
||||
if ipv6Snap, err = affectedpeers.Load(ctx, transaction, accountID, ipv6Change); err != nil {
|
||||
return fmt.Errorf("load affected peers: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
@@ -415,6 +459,7 @@ func (am *DefaultAccountManager) UpdateAccountSettings(ctx context.Context, acco
|
||||
am.handleAutoUpdateAlwaysSettings(ctx, oldSettings, newSettings, userID, accountID)
|
||||
am.handlePeerExposeSettings(ctx, oldSettings, newSettings, userID, accountID)
|
||||
am.handleMetricsPushSettings(ctx, oldSettings, newSettings, userID, accountID)
|
||||
am.handlePeerHostnameCollisionSettings(ctx, oldSettings, newSettings, userID, accountID)
|
||||
if err = am.handleInactivityExpirationSettings(ctx, oldSettings, newSettings, userID, accountID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -457,13 +502,34 @@ func (am *DefaultAccountManager) UpdateAccountSettings(ctx context.Context, acco
|
||||
}
|
||||
}
|
||||
|
||||
if updateAccountPeers || extraSettingsChanged || groupChangesAffectPeers {
|
||||
go am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceAccountSettings, Operation: types.UpdateOperationUpdate})
|
||||
switch {
|
||||
case updateAccountPeers || extraSettingsChanged || groupChangesAffectPeers:
|
||||
go am.UpdateAccountPeers(context.WithoutCancel(ctx), accountID, types.UpdateReason{Resource: types.UpdateResourceAccountSettings, Operation: types.UpdateOperationUpdate})
|
||||
case ipv6Snap != nil:
|
||||
am.ExpandAndUpdateAffected(ctx, accountID, ipv6Snap, ipv6Change)
|
||||
}
|
||||
|
||||
return newSettings, nil
|
||||
}
|
||||
|
||||
// applyIPv6SettingsChange reconciles peer IPv6 addresses for new IPv6 settings and
|
||||
// returns the affected-peers change: peers whose address changed refresh together
|
||||
// with every peer that reaches them. On a range change every peer holding an address
|
||||
// also refreshes itself, since its interface prefix comes from the account range even
|
||||
// when its address stays inside the new one.
|
||||
func (am *DefaultAccountManager) applyIPv6SettingsChange(ctx context.Context, transaction store.Store, accountID string, oldSettings, newSettings *types.Settings) (affectedpeers.Change, error) {
|
||||
result, err := am.updatePeerIPv6Addresses(ctx, transaction, accountID, newSettings)
|
||||
if err != nil {
|
||||
return affectedpeers.Change{}, err
|
||||
}
|
||||
|
||||
change := affectedpeers.Change{ChangedPeerIDs: result.changed}
|
||||
if oldSettings.NetworkRangeV6 != newSettings.NetworkRangeV6 {
|
||||
change.OutputPeerIDs = result.withIPv6
|
||||
}
|
||||
return change, nil
|
||||
}
|
||||
|
||||
func ipv6SettingsChanged(old, updated *types.Settings) bool {
|
||||
if old.NetworkRangeV6 != updated.NetworkRangeV6 {
|
||||
return true
|
||||
@@ -579,6 +645,16 @@ func (am *DefaultAccountManager) handleMetricsPushSettings(ctx context.Context,
|
||||
}
|
||||
}
|
||||
|
||||
func (am *DefaultAccountManager) handlePeerHostnameCollisionSettings(ctx context.Context, oldSettings, newSettings *types.Settings, userID, accountID string) {
|
||||
if oldSettings.PeerHostnameCollisionRejected != newSettings.PeerHostnameCollisionRejected {
|
||||
if newSettings.PeerHostnameCollisionRejected {
|
||||
am.StoreEvent(ctx, userID, accountID, accountID, activity.AccountPeerHostnameCollisionRejectionEnabled, nil)
|
||||
} else {
|
||||
am.StoreEvent(ctx, userID, accountID, accountID, activity.AccountPeerHostnameCollisionRejectionDisabled, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (am *DefaultAccountManager) handlePeerLoginExpirationSettings(ctx context.Context, oldSettings, newSettings *types.Settings, userID, accountID string) {
|
||||
if oldSettings.PeerLoginExpirationEnabled != newSettings.PeerLoginExpirationEnabled {
|
||||
event := activity.AccountPeerLoginExpirationEnabled
|
||||
@@ -694,7 +770,12 @@ func (am *DefaultAccountManager) peerLoginExpirationJob(ctx context.Context, acc
|
||||
ctx = context.WithValue(ctx, hook.ExecutionContextKey, fmt.Sprintf("%s-PEER-EXPIRATION", hook.SystemSource))
|
||||
|
||||
expiredPeers, err := am.getExpiredPeers(ctx, accountID)
|
||||
if isNotFound(err) {
|
||||
log.WithContext(ctx).Debugf("account %s not found, stopping peer login expiration job", accountID)
|
||||
return 0, false
|
||||
}
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("failed getting expired peers for account %s: %v", accountID, err)
|
||||
return peerSchedulerRetryInterval, true
|
||||
}
|
||||
|
||||
@@ -730,8 +811,12 @@ func (am *DefaultAccountManager) schedulePeerLoginExpiration(ctx context.Context
|
||||
func (am *DefaultAccountManager) peerInactivityExpirationJob(ctx context.Context, accountID string) func() (time.Duration, bool) {
|
||||
return func() (time.Duration, bool) {
|
||||
inactivePeers, err := am.getInactivePeers(ctx, accountID)
|
||||
if isNotFound(err) {
|
||||
log.WithContext(ctx).Debugf("account %s not found, stopping peer inactivity expiration job", accountID)
|
||||
return 0, false
|
||||
}
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("failed getting inactive peers for account %s", accountID)
|
||||
log.WithContext(ctx).Errorf("failed getting inactive peers for account %s: %v", accountID, err)
|
||||
return peerSchedulerRetryInterval, true
|
||||
}
|
||||
|
||||
@@ -751,6 +836,14 @@ func (am *DefaultAccountManager) peerInactivityExpirationJob(ctx context.Context
|
||||
}
|
||||
}
|
||||
|
||||
func isNotFound(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
sErr, ok := status.FromError(err)
|
||||
return ok && sErr.Type() == status.NotFound
|
||||
}
|
||||
|
||||
// checkAndSchedulePeerInactivityExpiration periodically checks for inactive peers to end their sessions
|
||||
func (am *DefaultAccountManager) checkAndSchedulePeerInactivityExpiration(ctx context.Context, accountID string) {
|
||||
am.peerInactivityExpiry.Cancel(ctx, []string{accountID})
|
||||
@@ -889,6 +982,10 @@ func (am *DefaultAccountManager) DeleteAccount(ctx context.Context, accountID, u
|
||||
return status.Errorf(status.Internal, "failed to build user infos for account %s: %v", accountID, err)
|
||||
}
|
||||
|
||||
if err = am.runAccountDeletionHooks(ctx, accountID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = am.deleteAccountUsers(ctx, accountID, userID, account.Users, userInfosMap); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -898,8 +995,9 @@ func (am *DefaultAccountManager) DeleteAccount(ctx context.Context, accountID, u
|
||||
log.WithContext(ctx).Errorf("failed deleting account %s. error: %s", accountID, err)
|
||||
return err
|
||||
}
|
||||
// cancel peer login expiry job
|
||||
am.peerLoginExpiry.Cancel(ctx, []string{account.Id})
|
||||
am.peerInactivityExpiry.Cancel(ctx, []string{account.Id})
|
||||
am.certChallenges.Forget(account.Id)
|
||||
|
||||
meta := map[string]any{"account_id": account.Id, "domain": account.Domain, "created_at": account.CreatedAt}
|
||||
am.StoreEvent(ctx, userID, accountID, accountID, activity.AccountDeleted, meta)
|
||||
@@ -1556,10 +1654,6 @@ func (am *DefaultAccountManager) GetAccountIDFromUserAuth(ctx context.Context, u
|
||||
return "", "", status.Errorf(status.NotFound, "user %s not found", userAuth.UserId)
|
||||
}
|
||||
|
||||
if userAuth.IsChild {
|
||||
return accountID, user.Id, nil
|
||||
}
|
||||
|
||||
ctx, err = am.permissionsManager.ValidateAccountAccess(ctx, accountID, user, false)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
@@ -1611,10 +1705,10 @@ func (am *DefaultAccountManager) updateUserAuthWithSingleMode(ctx context.Contex
|
||||
// and propagates changes to peers if group propagation is enabled.
|
||||
// requires userAuth to have been ValidateAndParseToken and EnsureUserAccessByJWTGroups by the AuthManager
|
||||
func (am *DefaultAccountManager) SyncUserJWTGroups(ctx context.Context, userAuth auth.UserAuth) error {
|
||||
// Child accounts and PAT-authenticated requests do not sync JWT groups.
|
||||
// PAT-authenticated requests do not sync JWT groups.
|
||||
// Embedded-Dex local users also skip sync because local password authentication
|
||||
// does not provide external IdP group claims.
|
||||
if userAuth.IsChild || userAuth.IsPAT || dex.IsLocalUserID(userAuth.UserId) {
|
||||
if userAuth.IsPAT || dex.IsLocalUserID(userAuth.UserId) {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1709,9 +1803,11 @@ func (am *DefaultAccountManager) SyncUserJWTGroups(ctx context.Context, userAuth
|
||||
|
||||
change.LinkGroups = allGroupChanges
|
||||
|
||||
if err = am.reconcileIPv6ForGroupChanges(ctx, transaction, userAuth.AccountId, allGroupChanges); err != nil {
|
||||
ipv6Changed, err := am.reconcileIPv6ForGroupChanges(ctx, transaction, userAuth.AccountId, allGroupChanges)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reconcile IPv6 for group changes: %w", err)
|
||||
}
|
||||
change.ChangedPeerIDs = append(change.ChangedPeerIDs, ipv6Changed...)
|
||||
|
||||
if err = transaction.IncrementNetworkSerial(ctx, userAuth.AccountId); err != nil {
|
||||
return fmt.Errorf("error incrementing network serial: %w", err)
|
||||
@@ -1791,8 +1887,6 @@ func (am *DefaultAccountManager) SyncUserJWTGroups(ctx context.Context, userAuth
|
||||
// Existing user + Existing account + Existing Indexed Domain -> Nothing changes
|
||||
//
|
||||
// Existing user + Existing account + Existing domain reclassified Domain as private -> Nothing changes (index domain)
|
||||
//
|
||||
// UserAuth IsChild -> checks that account exists
|
||||
func (am *DefaultAccountManager) getAccountIDWithAuthorizationClaims(ctx context.Context, userAuth auth.UserAuth) (string, error) {
|
||||
log.WithContext(ctx).Tracef("getting account with authorization claims. User ID: \"%s\", Account ID: \"%s\", Domain: \"%s\", Domain Category: \"%s\"",
|
||||
userAuth.UserId, userAuth.AccountId, userAuth.Domain, userAuth.DomainCategory)
|
||||
@@ -1801,14 +1895,6 @@ func (am *DefaultAccountManager) getAccountIDWithAuthorizationClaims(ctx context
|
||||
return "", errors.New(emptyUserID)
|
||||
}
|
||||
|
||||
if userAuth.IsChild {
|
||||
exists, err := am.Store.AccountExists(ctx, store.LockingStrengthNone, userAuth.AccountId)
|
||||
if err != nil || !exists {
|
||||
return "", err
|
||||
}
|
||||
return userAuth.AccountId, nil
|
||||
}
|
||||
|
||||
if userAuth.DomainCategory != types.PrivateCategory || !isDomainValid(userAuth.Domain) {
|
||||
return am.GetAccountIDByUserID(ctx, userAuth)
|
||||
}
|
||||
@@ -2301,7 +2387,8 @@ func (am *DefaultAccountManager) propagateUserGroupMemberships(ctx context.Conte
|
||||
return false, false, err
|
||||
}
|
||||
|
||||
if err = am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, updatedGroups); err != nil {
|
||||
ipv6Changed, err := am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, updatedGroups)
|
||||
if err != nil {
|
||||
return false, false, fmt.Errorf("reconcile IPv6 for group changes: %w", err)
|
||||
}
|
||||
|
||||
@@ -2310,7 +2397,7 @@ func (am *DefaultAccountManager) propagateUserGroupMemberships(ctx context.Conte
|
||||
return false, false, fmt.Errorf("error checking if group changes affect peers: %w", err)
|
||||
}
|
||||
|
||||
return len(updatedGroups) > 0, peersAffected, nil
|
||||
return len(updatedGroups) > 0, peersAffected || len(ipv6Changed) > 0, nil
|
||||
}
|
||||
|
||||
// propagateAutoGroupsForUsers adds each user's peers to their AutoGroups where not already present.
|
||||
@@ -2358,7 +2445,7 @@ func (am *DefaultAccountManager) reallocateAccountPeerIPs(ctx context.Context, t
|
||||
return err
|
||||
}
|
||||
|
||||
peers, err := transaction.GetAccountPeers(ctx, store.LockingStrengthUpdate, accountID, "", "")
|
||||
peers, err := transaction.GetAccountPeers(ctx, store.LockingStrengthUpdate, accountID, "", "", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -2395,7 +2482,7 @@ func (am *DefaultAccountManager) reallocateAccountPeerIPs(ctx context.Context, t
|
||||
// v6 address get one allocated. When disabled, all v6 addresses are cleared.
|
||||
// When the v6 range changes, all v6 addresses are reallocated.
|
||||
func (am *DefaultAccountManager) checkIPv6Collision(ctx context.Context, transaction store.Store, accountID, peerID string, newIPv6 netip.Addr) error {
|
||||
peers, err := transaction.GetAccountPeers(ctx, store.LockingStrengthShare, accountID, "", "")
|
||||
peers, err := transaction.GetAccountPeers(ctx, store.LockingStrengthShare, accountID, "", "", "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("get peers: %w", err)
|
||||
}
|
||||
@@ -2407,56 +2494,78 @@ func (am *DefaultAccountManager) checkIPv6Collision(ctx context.Context, transac
|
||||
return nil
|
||||
}
|
||||
|
||||
func (am *DefaultAccountManager) updatePeerIPv6Addresses(ctx context.Context, transaction store.Store, accountID string, settings *types.Settings) error {
|
||||
peers, err := transaction.GetAccountPeers(ctx, store.LockingStrengthUpdate, accountID, "", "")
|
||||
// ipv6Reassignment reports the outcome of an IPv6 address reconciliation.
|
||||
type ipv6Reassignment struct {
|
||||
// changed are the peers whose IPv6 address was assigned, removed or reallocated.
|
||||
changed []string
|
||||
// withIPv6 are all peers holding an IPv6 address after the reconciliation.
|
||||
withIPv6 []string
|
||||
}
|
||||
|
||||
func (am *DefaultAccountManager) updatePeerIPv6Addresses(ctx context.Context, transaction store.Store, accountID string, settings *types.Settings) (ipv6Reassignment, error) {
|
||||
peers, err := transaction.GetAccountPeers(ctx, store.LockingStrengthUpdate, accountID, "", "", "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("get peers: %w", err)
|
||||
return ipv6Reassignment{}, fmt.Errorf("get peers: %w", err)
|
||||
}
|
||||
|
||||
network, err := transaction.GetAccountNetwork(ctx, store.LockingStrengthUpdate, accountID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get network: %w", err)
|
||||
return ipv6Reassignment{}, fmt.Errorf("get network: %w", err)
|
||||
}
|
||||
|
||||
if err := am.ensureIPv6Subnet(ctx, transaction, accountID, settings, network); err != nil {
|
||||
return err
|
||||
return ipv6Reassignment{}, err
|
||||
}
|
||||
|
||||
allowedPeers, err := am.buildIPv6AllowedPeers(ctx, transaction, accountID, settings)
|
||||
if err != nil {
|
||||
return err
|
||||
return ipv6Reassignment{}, err
|
||||
}
|
||||
|
||||
v6Prefix, err := netip.ParsePrefix(network.NetV6.String())
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse IPv6 prefix: %w", err)
|
||||
return ipv6Reassignment{}, fmt.Errorf("parse IPv6 prefix: %w", err)
|
||||
}
|
||||
|
||||
if err := am.assignPeerIPv6Addresses(ctx, transaction, accountID, peers, network, allowedPeers, v6Prefix); err != nil {
|
||||
return err
|
||||
changed, err := am.assignPeerIPv6Addresses(ctx, transaction, accountID, peers, network, allowedPeers, v6Prefix)
|
||||
if err != nil {
|
||||
return ipv6Reassignment{}, err
|
||||
}
|
||||
|
||||
log.WithContext(ctx).Infof("updated IPv6 addresses for %d peers in account %s (groups=%d)",
|
||||
len(peers), accountID, len(settings.IPv6EnabledGroups))
|
||||
result := ipv6Reassignment{changed: changed}
|
||||
for _, peer := range peers {
|
||||
if peer.IPv6.IsValid() {
|
||||
result.withIPv6 = append(result.withIPv6, peer.ID)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
log.WithContext(ctx).Infof("updated IPv6 addresses for %d of %d peers in account %s (groups=%d)",
|
||||
len(changed), len(peers), accountID, len(settings.IPv6EnabledGroups))
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// reconcileIPv6ForGroupChanges checks whether the given group IDs overlap with
|
||||
// the account's IPv6EnabledGroups. If they do, it runs a full IPv6 address
|
||||
// reconciliation so that peers gaining or losing membership in an IPv6-enabled
|
||||
// group get their addresses assigned or removed.
|
||||
func (am *DefaultAccountManager) reconcileIPv6ForGroupChanges(ctx context.Context, transaction store.Store, accountID string, groupIDs []string) error {
|
||||
// group get their addresses assigned or removed. It returns the peers whose IPv6
|
||||
// address changed, which callers pass as changed peers so every peer that can
|
||||
// reach them refreshes.
|
||||
func (am *DefaultAccountManager) reconcileIPv6ForGroupChanges(ctx context.Context, transaction store.Store, accountID string, groupIDs []string) ([]string, error) {
|
||||
settings, err := transaction.GetAccountSettings(ctx, store.LockingStrengthNone, accountID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get account settings: %w", err)
|
||||
return nil, fmt.Errorf("get account settings: %w", err)
|
||||
}
|
||||
|
||||
if !ipv6ReconcileNeeded(settings, groupIDs) {
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return am.updatePeerIPv6Addresses(ctx, transaction, accountID, settings)
|
||||
result, err := am.updatePeerIPv6Addresses(ctx, transaction, accountID, settings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.changed, nil
|
||||
}
|
||||
|
||||
// ipv6ReconcileNeeded reports whether changes to the given groups trigger an IPv6
|
||||
@@ -2495,7 +2604,7 @@ func (am *DefaultAccountManager) assignPeerIPv6Addresses(
|
||||
ctx context.Context, transaction store.Store, accountID string,
|
||||
peers []*nbpeer.Peer, network *types.Network,
|
||||
allowedPeers map[string]struct{}, v6Prefix netip.Prefix,
|
||||
) error {
|
||||
) ([]string, error) {
|
||||
takenV6 := make(map[netip.Addr]struct{})
|
||||
for _, peer := range peers {
|
||||
if _, ok := allowedPeers[peer.ID]; ok && peer.IPv6.IsValid() && network.NetV6.Contains(peer.IPv6.AsSlice()) {
|
||||
@@ -2503,6 +2612,7 @@ func (am *DefaultAccountManager) assignPeerIPv6Addresses(
|
||||
}
|
||||
}
|
||||
|
||||
var changed []string
|
||||
for _, peer := range peers {
|
||||
_, allowed := allowedPeers[peer.ID]
|
||||
oldIPv6 := peer.IPv6
|
||||
@@ -2512,7 +2622,7 @@ func (am *DefaultAccountManager) assignPeerIPv6Addresses(
|
||||
} else if !peer.IPv6.IsValid() || !network.NetV6.Contains(peer.IPv6.AsSlice()) {
|
||||
newIP, err := allocateIPv6WithRetry(v6Prefix, takenV6, peer.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
peer.IPv6 = newIP
|
||||
}
|
||||
@@ -2522,10 +2632,11 @@ func (am *DefaultAccountManager) assignPeerIPv6Addresses(
|
||||
}
|
||||
|
||||
if err := transaction.SavePeer(ctx, accountID, peer); err != nil {
|
||||
return fmt.Errorf("save peer %s: %w", peer.ID, err)
|
||||
return nil, fmt.Errorf("save peer %s: %w", peer.ID, err)
|
||||
}
|
||||
changed = append(changed, peer.ID)
|
||||
}
|
||||
return nil
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
func allocateIPv6WithRetry(prefix netip.Prefix, taken map[netip.Addr]struct{}, peerID string) (netip.Addr, error) {
|
||||
@@ -2569,7 +2680,7 @@ func (am *DefaultAccountManager) buildIPv6AllowedPeers(ctx context.Context, tran
|
||||
|
||||
// Embedded proxy peers sit outside regular group membership but must
|
||||
// participate in any v6-enabled overlay to reach v6-only peers.
|
||||
peers, err := transaction.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "")
|
||||
peers, err := transaction.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "", "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get peers: %w", err)
|
||||
}
|
||||
@@ -2586,6 +2697,14 @@ func (am *DefaultAccountManager) validateIPForUpdate(account *types.Account, pee
|
||||
return status.Errorf(status.InvalidArgument, "IP %s is not within the account network range %s", newIP.String(), account.Network.Net.String())
|
||||
}
|
||||
|
||||
prefix, err := netip.ParsePrefix(account.Network.Net.String())
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse account network %s: %w", account.Network.Net.String(), err)
|
||||
}
|
||||
if types.IsReservedPeerIP(prefix, newIP) {
|
||||
return status.Errorf(status.InvalidArgument, "IP %s is reserved in the account network range %s", newIP.String(), account.Network.Net.String())
|
||||
}
|
||||
|
||||
for _, peer := range peers {
|
||||
if peer.ID != peerID && peer.IP == newIP {
|
||||
return status.Errorf(status.InvalidArgument, "IP %s is already assigned to peer %s", newIP.String(), peer.ID)
|
||||
@@ -2609,6 +2728,8 @@ func (am *DefaultAccountManager) UpdatePeerIP(ctx context.Context, accountID, us
|
||||
}
|
||||
|
||||
if updateNetworkMap {
|
||||
am.reloadServicesTargetingPeer(ctx, accountID, peerID)
|
||||
|
||||
peer, err := am.Store.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peerID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -2623,6 +2744,19 @@ func (am *DefaultAccountManager) UpdatePeerIP(ctx context.Context, accountID, us
|
||||
return nil
|
||||
}
|
||||
|
||||
// reloadServicesTargetingPeer resends the account's reverse proxy services unless none of them targets the peer.
|
||||
func (am *DefaultAccountManager) reloadServicesTargetingPeer(ctx context.Context, accountID, peerID string) {
|
||||
serviceID, err := am.serviceManager.GetServiceIDByTargetID(ctx, accountID, peerID)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Warnf("failed to look up services targeting peer %s, reloading all: %v", peerID, err)
|
||||
} else if serviceID == "" {
|
||||
return
|
||||
}
|
||||
if err := am.serviceManager.ReloadAllServicesForAccount(ctx, accountID); err != nil {
|
||||
log.WithContext(ctx).Warnf("failed to reload services for account %s: %v", accountID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (am *DefaultAccountManager) updatePeerIPInTransaction(ctx context.Context, accountID, userID, peerID string, newIP netip.Addr) (bool, error) {
|
||||
var updateNetworkMap bool
|
||||
err := am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
|
||||
@@ -2640,7 +2774,7 @@ func (am *DefaultAccountManager) updatePeerIPInTransaction(ctx context.Context,
|
||||
return nil
|
||||
}
|
||||
|
||||
peers, err := transaction.GetAccountPeers(ctx, store.LockingStrengthShare, accountID, "", "")
|
||||
peers, err := transaction.GetAccountPeers(ctx, store.LockingStrengthShare, accountID, "", "", "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("get account peers: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package account
|
||||
|
||||
import "context"
|
||||
|
||||
// DeletionHook runs when an account is deleted, after the caller's permission to delete
|
||||
// it has been checked and before any of its users or data are removed. It lets code that
|
||||
// keeps per-account state outside the store tear that state down while the account still
|
||||
// exists.
|
||||
//
|
||||
// A hook that returns an error aborts the deletion and the account is kept. The caller
|
||||
// sees the error, so a hook that wants a specific response returns a status error. A
|
||||
// retried deletion runs every hook again, and a later step can still fail after the hooks
|
||||
// succeed, so a hook must be idempotent and must tolerate the account surviving it.
|
||||
type DeletionHook func(ctx context.Context, accountID string) error
|
||||
@@ -62,8 +62,10 @@ type Manager interface {
|
||||
GetUserByID(ctx context.Context, id string) (*types.User, error)
|
||||
GetUserFromUserAuth(ctx context.Context, userAuth auth.UserAuth) (*types.User, error)
|
||||
ListUsers(ctx context.Context, accountID string) ([]*types.User, error)
|
||||
GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error)
|
||||
GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error)
|
||||
MarkPeerConnected(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64, nmap *types.NetworkMap) error
|
||||
TrackCertificateChallenges(ctx context.Context, accountID, peerID string, streamStart time.Time)
|
||||
UntrackCertificateChallenges(accountID, peerID string, streamStart time.Time)
|
||||
MarkPeerDisconnected(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64) error
|
||||
DeletePeer(ctx context.Context, accountID, peerID, userID string) error
|
||||
UpdatePeer(ctx context.Context, accountID, userID string, p *nbpeer.Peer) (*nbpeer.Peer, error)
|
||||
|
||||
@@ -982,18 +982,18 @@ func (mr *MockManagerMockRecorder) GetPeerNetwork(ctx, peerID any) *gomock.Call
|
||||
}
|
||||
|
||||
// GetPeers mocks base method.
|
||||
func (m *MockManager) GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter string) ([]*peer.Peer, error) {
|
||||
func (m *MockManager) GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*peer.Peer, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetPeers", ctx, accountID, userID, nameFilter, ipFilter)
|
||||
ret := m.ctrl.Call(m, "GetPeers", ctx, accountID, userID, nameFilter, ipFilter, macFilter)
|
||||
ret0, _ := ret[0].([]*peer.Peer)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetPeers indicates an expected call of GetPeers.
|
||||
func (mr *MockManagerMockRecorder) GetPeers(ctx, accountID, userID, nameFilter, ipFilter any) *gomock.Call {
|
||||
func (mr *MockManagerMockRecorder) GetPeers(ctx, accountID, userID, nameFilter, ipFilter, macFilter any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeers", reflect.TypeOf((*MockManager)(nil).GetPeers), ctx, accountID, userID, nameFilter, ipFilter)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeers", reflect.TypeOf((*MockManager)(nil).GetPeers), ctx, accountID, userID, nameFilter, ipFilter, macFilter)
|
||||
}
|
||||
|
||||
// GetPolicy mocks base method.
|
||||
@@ -1632,6 +1632,30 @@ func (mr *MockManagerMockRecorder) SyncUserJWTGroups(ctx, userAuth any) *gomock.
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SyncUserJWTGroups", reflect.TypeOf((*MockManager)(nil).SyncUserJWTGroups), ctx, userAuth)
|
||||
}
|
||||
|
||||
// TrackCertificateChallenges mocks base method.
|
||||
func (m *MockManager) TrackCertificateChallenges(ctx context.Context, accountID, peerID string, streamStart time.Time) {
|
||||
m.ctrl.T.Helper()
|
||||
m.ctrl.Call(m, "TrackCertificateChallenges", ctx, accountID, peerID, streamStart)
|
||||
}
|
||||
|
||||
// TrackCertificateChallenges indicates an expected call of TrackCertificateChallenges.
|
||||
func (mr *MockManagerMockRecorder) TrackCertificateChallenges(ctx, accountID, peerID, streamStart any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TrackCertificateChallenges", reflect.TypeOf((*MockManager)(nil).TrackCertificateChallenges), ctx, accountID, peerID, streamStart)
|
||||
}
|
||||
|
||||
// UntrackCertificateChallenges mocks base method.
|
||||
func (m *MockManager) UntrackCertificateChallenges(accountID, peerID string, streamStart time.Time) {
|
||||
m.ctrl.T.Helper()
|
||||
m.ctrl.Call(m, "UntrackCertificateChallenges", accountID, peerID, streamStart)
|
||||
}
|
||||
|
||||
// UntrackCertificateChallenges indicates an expected call of UntrackCertificateChallenges.
|
||||
func (mr *MockManagerMockRecorder) UntrackCertificateChallenges(accountID, peerID, streamStart any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UntrackCertificateChallenges", reflect.TypeOf((*MockManager)(nil).UntrackCertificateChallenges), accountID, peerID, streamStart)
|
||||
}
|
||||
|
||||
// UpdateAccountOnboarding mocks base method.
|
||||
func (m *MockManager) UpdateAccountOnboarding(ctx context.Context, accountID, userID string, newOnboarding *types.AccountOnboarding) (*types.AccountOnboarding, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
@@ -3,6 +3,7 @@ package server
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus/push"
|
||||
@@ -40,6 +42,7 @@ import (
|
||||
"github.com/netbirdio/netbird/management/internals/modules/zones"
|
||||
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
|
||||
networkmapdbfactory "github.com/netbirdio/netbird/management/internals/network_map_db/factory"
|
||||
networkmap_sqlite "github.com/netbirdio/netbird/management/internals/network_map_db/sqlite"
|
||||
"github.com/netbirdio/netbird/management/internals/server/config"
|
||||
nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
|
||||
nbAccount "github.com/netbirdio/netbird/management/server/account"
|
||||
@@ -47,7 +50,6 @@ import (
|
||||
"github.com/netbirdio/netbird/management/server/cache"
|
||||
"github.com/netbirdio/netbird/management/server/http/testing/testing_tools"
|
||||
"github.com/netbirdio/netbird/management/server/idp"
|
||||
"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
|
||||
"github.com/netbirdio/netbird/management/server/job"
|
||||
resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
|
||||
routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
|
||||
@@ -958,6 +960,101 @@ func TestAccountManager_DeleteAccount(t *testing.T) {
|
||||
assert.Len(t, pats, 0)
|
||||
}
|
||||
|
||||
func TestAccountManager_DeleteAccount_RunsDeletionHooks(t *testing.T) {
|
||||
manager, _, err := createManager(t)
|
||||
require.NoError(t, err)
|
||||
|
||||
ownerID := "account_creator"
|
||||
account, err := createAccount(manager, "test_account", ownerID, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Each hook records its call and checks the account is still in the store, which is
|
||||
// the point of running before deletion: a hook must be able to read what it cleans up.
|
||||
var calls []string
|
||||
hook := func(name string) nbAccount.DeletionHook {
|
||||
return func(ctx context.Context, accountID string) error {
|
||||
calls = append(calls, name+":"+accountID)
|
||||
_, err := manager.Store.GetAccount(ctx, accountID)
|
||||
assert.NoError(t, err, "account should still exist while hook %s runs", name)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
manager.AddAccountDeletionHook(hook("first"))
|
||||
manager.AddAccountDeletionHook(hook("second"))
|
||||
|
||||
require.NoError(t, manager.DeleteAccount(context.Background(), account.Id, ownerID))
|
||||
|
||||
assert.Equal(t, []string{"first:" + account.Id, "second:" + account.Id}, calls,
|
||||
"hooks should run once each, in registration order, with the deleted account's ID")
|
||||
_, err = manager.Store.GetAccount(context.Background(), account.Id)
|
||||
assert.Error(t, err, "account should be deleted after the hooks succeed")
|
||||
}
|
||||
|
||||
func TestAccountManager_DeleteAccount_DeletionHookErrorAbortsDeletion(t *testing.T) {
|
||||
manager, _, err := createManager(t)
|
||||
require.NoError(t, err)
|
||||
|
||||
ownerID := "account_creator"
|
||||
account, err := createAccount(manager, "test_account", ownerID, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
manager.AddAccountDeletionHook(func(context.Context, string) error {
|
||||
return status.Errorf(status.PreconditionFailed, "teardown refused")
|
||||
})
|
||||
secondCalled := false
|
||||
manager.AddAccountDeletionHook(func(context.Context, string) error {
|
||||
secondCalled = true
|
||||
return nil
|
||||
})
|
||||
|
||||
err = manager.DeleteAccount(context.Background(), account.Id, ownerID)
|
||||
require.Error(t, err)
|
||||
|
||||
// The hook's status type has to survive the wrapping, since the HTTP layer maps it
|
||||
// to the response code.
|
||||
sErr, ok := status.FromError(err)
|
||||
require.True(t, ok, "error should carry the hook's status error, got %v", err)
|
||||
assert.Equal(t, status.PreconditionFailed, sErr.Type(), "status type should be the hook's")
|
||||
assert.False(t, secondCalled, "hooks after a failing one should not run")
|
||||
|
||||
_, err = manager.Store.GetAccount(context.Background(), account.Id)
|
||||
assert.NoError(t, err, "account should survive a failing hook")
|
||||
_, err = manager.Store.GetUserByUserID(context.Background(), store.LockingStrengthNone, ownerID)
|
||||
assert.NoError(t, err, "account owner should survive a failing hook")
|
||||
}
|
||||
|
||||
func TestAccountManager_AddAccountDeletionHook_RejectsNil(t *testing.T) {
|
||||
manager, _, err := createManager(t)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.PanicsWithValue(t, "nil account deletion hook", func() {
|
||||
manager.AddAccountDeletionHook(nil)
|
||||
}, "registering a nil hook should panic instead of breaking a later deletion")
|
||||
}
|
||||
|
||||
func TestAccountManager_DeleteAccount_DeletionHooksSkippedWithoutPermission(t *testing.T) {
|
||||
manager, _, err := createManager(t)
|
||||
require.NoError(t, err)
|
||||
|
||||
ownerID := "account_creator"
|
||||
account, err := createAccount(manager, "test_account", ownerID, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
adminID := "regular_admin"
|
||||
account.Users[adminID] = types.NewAdminUser(adminID)
|
||||
require.NoError(t, manager.Store.SaveAccount(context.Background(), account))
|
||||
|
||||
called := false
|
||||
manager.AddAccountDeletionHook(func(context.Context, string) error {
|
||||
called = true
|
||||
return nil
|
||||
})
|
||||
|
||||
err = manager.DeleteAccount(context.Background(), account.Id, adminID)
|
||||
require.Error(t, err, "only the owner may delete the account")
|
||||
assert.False(t, called, "hooks should not run for a caller who may not delete the account")
|
||||
}
|
||||
|
||||
func BenchmarkTest_GetAccountWithclaims(b *testing.B) {
|
||||
claims := auth.UserAuth{
|
||||
Domain: "example.com",
|
||||
@@ -1202,7 +1299,9 @@ func TestAccountManager_AddPeerWithUserID(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAccountManager_NetworkUpdates_SaveGroup(t *testing.T) {
|
||||
testAccountManager_NetworkUpdates_SaveGroup(t)
|
||||
runPeerUpdateTest(t, func(t *testing.T) {
|
||||
testAccountManager_NetworkUpdates_SaveGroup(t)
|
||||
})
|
||||
}
|
||||
|
||||
func testAccountManager_NetworkUpdates_SaveGroup(t *testing.T) {
|
||||
@@ -1235,6 +1334,8 @@ func testAccountManager_NetworkUpdates_SaveGroup(t *testing.T) {
|
||||
updMsg := updateManager.CreateChannel(context.Background(), peer1.ID)
|
||||
defer updateManager.CloseChannel(context.Background(), peer1.ID)
|
||||
|
||||
settleAffectedUpdates(updMsg)
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
@@ -1257,7 +1358,9 @@ func testAccountManager_NetworkUpdates_SaveGroup(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAccountManager_NetworkUpdates_DeletePolicy(t *testing.T) {
|
||||
testAccountManager_NetworkUpdates_DeletePolicy(t)
|
||||
runPeerUpdateTest(t, func(t *testing.T) {
|
||||
testAccountManager_NetworkUpdates_DeletePolicy(t)
|
||||
})
|
||||
}
|
||||
|
||||
func testAccountManager_NetworkUpdates_DeletePolicy(t *testing.T) {
|
||||
@@ -1266,13 +1369,7 @@ func testAccountManager_NetworkUpdates_DeletePolicy(t *testing.T) {
|
||||
updMsg := updateManager.CreateChannel(context.Background(), peer1.ID)
|
||||
defer updateManager.CloseChannel(context.Background(), peer1.ID)
|
||||
|
||||
// Ensure that we do not receive an update message before the policy is deleted
|
||||
time.Sleep(time.Second)
|
||||
select {
|
||||
case <-updMsg:
|
||||
t.Logf("received addPeer update message before policy deletion")
|
||||
default:
|
||||
}
|
||||
settleAffectedUpdates(updMsg)
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
wg.Add(1)
|
||||
@@ -1295,7 +1392,9 @@ func testAccountManager_NetworkUpdates_DeletePolicy(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAccountManager_NetworkUpdates_SavePolicy(t *testing.T) {
|
||||
testAccountManager_NetworkUpdates_SavePolicy(t)
|
||||
runPeerUpdateTest(t, func(t *testing.T) {
|
||||
testAccountManager_NetworkUpdates_SavePolicy(t)
|
||||
})
|
||||
}
|
||||
|
||||
func testAccountManager_NetworkUpdates_SavePolicy(t *testing.T) {
|
||||
@@ -1315,6 +1414,8 @@ func testAccountManager_NetworkUpdates_SavePolicy(t *testing.T) {
|
||||
updMsg := updateManager.CreateChannel(context.Background(), peer1.ID)
|
||||
defer updateManager.CloseChannel(context.Background(), peer1.ID)
|
||||
|
||||
settleAffectedUpdates(updMsg)
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
@@ -1348,7 +1449,9 @@ func testAccountManager_NetworkUpdates_SavePolicy(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAccountManager_NetworkUpdates_DeletePeer(t *testing.T) {
|
||||
testAccountManager_NetworkUpdates_DeletePeer(t)
|
||||
runPeerUpdateTest(t, func(t *testing.T) {
|
||||
testAccountManager_NetworkUpdates_DeletePeer(t)
|
||||
})
|
||||
}
|
||||
|
||||
func testAccountManager_NetworkUpdates_DeletePeer(t *testing.T) {
|
||||
@@ -1387,6 +1490,8 @@ func testAccountManager_NetworkUpdates_DeletePeer(t *testing.T) {
|
||||
updMsg := updateManager.CreateChannel(context.Background(), peer1.ID)
|
||||
defer updateManager.CloseChannel(context.Background(), peer1.ID)
|
||||
|
||||
settleAffectedUpdates(updMsg)
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
@@ -1408,7 +1513,9 @@ func testAccountManager_NetworkUpdates_DeletePeer(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAccountManager_NetworkUpdates_DeleteGroup(t *testing.T) {
|
||||
testAccountManager_NetworkUpdates_DeleteGroup(t)
|
||||
runPeerUpdateTest(t, func(t *testing.T) {
|
||||
testAccountManager_NetworkUpdates_DeleteGroup(t)
|
||||
})
|
||||
}
|
||||
|
||||
func testAccountManager_NetworkUpdates_DeleteGroup(t *testing.T) {
|
||||
@@ -1455,6 +1562,8 @@ func testAccountManager_NetworkUpdates_DeleteGroup(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
settleAffectedUpdates(updMsg)
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
@@ -2462,7 +2571,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_PeerApproval(t *testing.T)
|
||||
_, err = manager.UpdateAccountSettings(ctx, accountID, userID, newSettings)
|
||||
require.NoError(t, err)
|
||||
|
||||
accountPeers, err := manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "")
|
||||
accountPeers, err := manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, peer := range accountPeers {
|
||||
@@ -3487,6 +3596,9 @@ func createManagerWithNetworkMapStore(t testing.TB) (*DefaultAccountManager, *up
|
||||
|
||||
nmdataStore, err := networkmapdbfactory.NewNetworkMapDBStore(context.Background(), types.SqliteStoreEngine, dataDir, MockIntegratedValidator{}, newSettingsMockManager(t))
|
||||
require.NoError(t, err)
|
||||
sqliteStore, ok := nmdataStore.Store.(*networkmap_sqlite.SqliteStore)
|
||||
require.True(t, ok, "network map store is %T, want *networkmap_sqlite.SqliteStore", nmdataStore.Store)
|
||||
t.Cleanup(func() { assert.NoError(t, sqliteStore.Db.Close()) })
|
||||
|
||||
manager, updateManager, err := buildTestManager(t, store, nmdataStore)
|
||||
require.NoError(t, err)
|
||||
@@ -3541,20 +3653,24 @@ func buildTestManager(t testing.TB, store store.Store, nmdataStore *networkmapdb
|
||||
Return(nil).
|
||||
AnyTimes()
|
||||
|
||||
cacheStore, err := cache.NewStore(ctx, 100*time.Millisecond, 300*time.Millisecond, 100)
|
||||
// The go-cache janitor only stops via a GC finalizer and would outlive synctest bubbles.
|
||||
cacheStore, err := cache.NewStore(ctx, 100*time.Millisecond, 0, 100)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
updateManager := update_channel.NewPeersUpdateManager(metrics)
|
||||
requestBuffer := NewAccountRequestBuffer(ctx, store)
|
||||
networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nmdataStore)
|
||||
manager, err := BuildManager(ctx, &config.Config{}, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
|
||||
networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nmdataStore)
|
||||
manager, err := BuildManager(ctx, &config.Config{}, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, settingsMockManager, permissionsManager, false, cacheStore)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
cacheManager := manager.cacheManager
|
||||
t.Cleanup(func() { assert.NoError(t, cacheManager.Close()) })
|
||||
|
||||
proxyGrpcServer := nbgrpc.NewProxyServiceServer(nil, nil, nil, nbgrpc.ProxyOIDCConfig{}, peersManager, nil, nil, proxyManager, nil)
|
||||
t.Cleanup(proxyGrpcServer.Close)
|
||||
proxyController, err := proxymanager.NewGRPCController(proxyGrpcServer, noop.Meter{})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -3648,6 +3764,33 @@ func setupNetworkMapTest(t *testing.T) (*DefaultAccountManager, *update_channel.
|
||||
// when the channel delivers.
|
||||
const peerUpdateTimeout = 5 * time.Second
|
||||
|
||||
// peerUpdateSettleTime bounds how far settleAffectedUpdates advances the fake clock. It must exceed
|
||||
// the account request and peer update buffer intervals.
|
||||
const peerUpdateSettleTime = time.Second
|
||||
|
||||
// runPeerUpdateTest runs f inside synctest.Test, so the peer update helpers observe every background
|
||||
// goroutine of the test, and lets the updates still in flight when f returns finish before the bubble
|
||||
// ends, since the bubble's clock stops with it.
|
||||
func runPeerUpdateTest(t *testing.T, f func(t *testing.T)) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
defer settleAffectedUpdates()
|
||||
f(t)
|
||||
})
|
||||
}
|
||||
|
||||
// settleAffectedUpdates runs the synctest bubble's fake clock past every update buffer interval until
|
||||
// all goroutines are blocked, then discards the updates already delivered to chans, so the next
|
||||
// assertion only observes updates from the action under test. It must be called inside synctest.Test.
|
||||
func settleAffectedUpdates(chans ...<-chan *network_map.UpdateMessage) {
|
||||
time.Sleep(peerUpdateSettleTime)
|
||||
synctest.Wait()
|
||||
for _, ch := range chans {
|
||||
for len(ch) > 0 {
|
||||
<-ch
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func drainPeerUpdates(ch <-chan *network_map.UpdateMessage) {
|
||||
for {
|
||||
select {
|
||||
@@ -3661,6 +3804,19 @@ func drainPeerUpdates(ch <-chan *network_map.UpdateMessage) {
|
||||
}
|
||||
}
|
||||
|
||||
// step runs f as one named stage of a test inside synctest.Test, which forbids t.Run, and names the
|
||||
// stage when it fails the test. Stages share t, so a fatal failure ends the remaining stages as well.
|
||||
func step(t *testing.T, name string, f func(t *testing.T)) {
|
||||
t.Helper()
|
||||
failedBefore := t.Failed()
|
||||
defer func() {
|
||||
if !failedBefore && t.Failed() {
|
||||
t.Logf("step %q failed", name)
|
||||
}
|
||||
}()
|
||||
f(t)
|
||||
}
|
||||
|
||||
func peerShouldNotReceiveUpdate(t *testing.T, updateMessage <-chan *network_map.UpdateMessage) {
|
||||
t.Helper()
|
||||
select {
|
||||
@@ -4298,6 +4454,27 @@ func TestDefaultAccountManager_UpdatePeerIP(t *testing.T) {
|
||||
assert.Contains(t, err.Error(), "not within the account network range", "error should mention network range")
|
||||
})
|
||||
|
||||
t.Run("update peer IP to reserved address should fail", func(t *testing.T) {
|
||||
account, err := manager.Store.GetAccount(context.Background(), accountID)
|
||||
require.NoError(t, err, "unable to get account")
|
||||
|
||||
network := account.Network.Net
|
||||
broadcast := make(net.IP, len(network.IP.To4()))
|
||||
for i := range broadcast {
|
||||
broadcast[i] = network.IP.To4()[i] | ^network.Mask[i]
|
||||
}
|
||||
broadcastIP, ok := netip.AddrFromSlice(broadcast)
|
||||
require.True(t, ok, "unable to parse broadcast IP")
|
||||
networkIP, ok := netip.AddrFromSlice(network.IP.To4())
|
||||
require.True(t, ok, "unable to parse network IP")
|
||||
|
||||
for _, reserved := range []netip.Addr{networkIP, broadcastIP.Prev(), broadcastIP} {
|
||||
err = manager.UpdatePeerIP(context.Background(), accountID, userID, peer1.ID, reserved)
|
||||
require.Error(t, err, "should fail for reserved IP %s", reserved)
|
||||
assert.Contains(t, err.Error(), "is reserved", "error should mention reserved IP")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("update peer IP with invalid peer ID should fail", func(t *testing.T) {
|
||||
newAddr := netip.MustParseAddr("100.64.0.101")
|
||||
err := manager.UpdatePeerIP(context.Background(), accountID, userID, "invalid-peer-id", newAddr)
|
||||
@@ -4305,6 +4482,86 @@ func TestDefaultAccountManager_UpdatePeerIP(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestDefaultAccountManager_UpdatePeerIP_ReloadsTargetingServices(t *testing.T) {
|
||||
manager, _, err := createManager(t)
|
||||
require.NoError(t, err, "unable to create account manager")
|
||||
|
||||
ctx := context.Background()
|
||||
accountID, err := manager.GetAccountIDByUserID(ctx, auth.UserAuth{UserId: userID})
|
||||
require.NoError(t, err, "unable to create an account")
|
||||
|
||||
key, err := wgtypes.GenerateKey()
|
||||
require.NoError(t, err, "unable to generate WireGuard key")
|
||||
peer, _, _, _, err := manager.AddPeer(ctx, "", "", userID, &nbpeer.Peer{
|
||||
Key: key.PublicKey().String(),
|
||||
Meta: nbpeer.PeerSystemMeta{Hostname: "proxied-peer"},
|
||||
}, false)
|
||||
require.NoError(t, err, "unable to add peer")
|
||||
|
||||
account, err := manager.Store.GetAccount(ctx, accountID)
|
||||
require.NoError(t, err, "unable to get account")
|
||||
network := netip.MustParsePrefix(account.Network.Net.String())
|
||||
|
||||
t.Run("targeted peer reloads services", func(t *testing.T) {
|
||||
serviceManager := service.NewMockManager(gomock.NewController(t))
|
||||
manager.SetServiceManager(serviceManager)
|
||||
serviceManager.EXPECT().GetServiceIDByTargetID(gomock.Any(), accountID, peer.ID).Return("svc-1", nil)
|
||||
serviceManager.EXPECT().ReloadAllServicesForAccount(gomock.Any(), accountID).Return(nil)
|
||||
|
||||
current, err := manager.Store.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peer.ID)
|
||||
require.NoError(t, err)
|
||||
newIP, err := types.AllocatePeerIP(network, []netip.Addr{current.IP})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, manager.UpdatePeerIP(ctx, accountID, userID, peer.ID, newIP))
|
||||
})
|
||||
|
||||
t.Run("untargeted peer skips reload", func(t *testing.T) {
|
||||
serviceManager := service.NewMockManager(gomock.NewController(t))
|
||||
manager.SetServiceManager(serviceManager)
|
||||
serviceManager.EXPECT().GetServiceIDByTargetID(gomock.Any(), accountID, peer.ID).Return("", nil)
|
||||
|
||||
current, err := manager.Store.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peer.ID)
|
||||
require.NoError(t, err)
|
||||
newIP, err := types.AllocatePeerIP(network, []netip.Addr{current.IP})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, manager.UpdatePeerIP(ctx, accountID, userID, peer.ID, newIP))
|
||||
})
|
||||
|
||||
t.Run("lookup failure reloads services", func(t *testing.T) {
|
||||
serviceManager := service.NewMockManager(gomock.NewController(t))
|
||||
manager.SetServiceManager(serviceManager)
|
||||
serviceManager.EXPECT().GetServiceIDByTargetID(gomock.Any(), accountID, peer.ID).Return("", errors.New("store unavailable"))
|
||||
serviceManager.EXPECT().ReloadAllServicesForAccount(gomock.Any(), accountID).Return(nil)
|
||||
|
||||
current, err := manager.Store.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peer.ID)
|
||||
require.NoError(t, err)
|
||||
newIP, err := types.AllocatePeerIP(network, []netip.Addr{current.IP})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, manager.UpdatePeerIP(ctx, accountID, userID, peer.ID, newIP))
|
||||
})
|
||||
|
||||
t.Run("network map notification failure still reloads services", func(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
serviceManager := service.NewMockManager(ctrl)
|
||||
manager.SetServiceManager(serviceManager)
|
||||
serviceManager.EXPECT().GetServiceIDByTargetID(gomock.Any(), accountID, peer.ID).Return("svc-1", nil)
|
||||
serviceManager.EXPECT().ReloadAllServicesForAccount(gomock.Any(), accountID).Return(nil)
|
||||
|
||||
originalController := manager.networkMapController
|
||||
t.Cleanup(func() { manager.networkMapController = originalController })
|
||||
networkMapController := network_map.NewMockController(ctrl)
|
||||
networkMapController.EXPECT().GetDNSDomain(gomock.Any()).Return("netbird.cloud").AnyTimes()
|
||||
networkMapController.EXPECT().OnPeersUpdated(gomock.Any(), accountID, []string{peer.ID}, gomock.Any()).Return(errors.New("notify failed"))
|
||||
manager.networkMapController = networkMapController
|
||||
|
||||
current, err := manager.Store.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peer.ID)
|
||||
require.NoError(t, err)
|
||||
newIP, err := types.AllocatePeerIP(network, []netip.Addr{current.IP})
|
||||
require.NoError(t, err)
|
||||
require.Error(t, manager.UpdatePeerIP(ctx, accountID, userID, peer.ID, newIP))
|
||||
})
|
||||
}
|
||||
|
||||
func TestAddNewUserToDomainAccountWithApproval(t *testing.T) {
|
||||
manager, _, err := createManager(t)
|
||||
if err != nil {
|
||||
@@ -4462,7 +4719,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_NetworkRangePreserved(t *te
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
peers, err := manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, account.Id, "", "")
|
||||
peers, err := manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, account.Id, "", "", "")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, peers, len(before))
|
||||
for _, p := range peers {
|
||||
@@ -4480,7 +4737,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_NetworkRangePreserved(t *te
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, account.Id, "", "")
|
||||
peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, account.Id, "", "", "")
|
||||
require.NoError(t, err)
|
||||
for _, p := range peers {
|
||||
assert.Equal(t, before[p.ID], p.IP, "peer %s IP should not change for host-bit-set equivalent range", p.ID)
|
||||
@@ -4494,7 +4751,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_NetworkRangePreserved(t *te
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, account.Id, "", "")
|
||||
peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, account.Id, "", "", "")
|
||||
require.NoError(t, err)
|
||||
for _, p := range peers {
|
||||
assert.Equal(t, before[p.ID], p.IP, "peer %s IP should not change when NetworkRange omitted", p.ID)
|
||||
@@ -4510,7 +4767,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_NetworkRangePreserved(t *te
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, account.Id, "", "")
|
||||
peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, account.Id, "", "", "")
|
||||
require.NoError(t, err)
|
||||
for _, p := range peers {
|
||||
assert.True(t, newRange.Contains(p.IP), "peer %s should be in new range %s, got %s", p.ID, newRange, p.IP)
|
||||
@@ -4528,7 +4785,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_IPv6EnabledGroups(t *testin
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, settings.IPv6EnabledGroups, "new account should have IPv6 enabled for All group")
|
||||
|
||||
peers, err := manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "")
|
||||
peers, err := manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "", "")
|
||||
require.NoError(t, err)
|
||||
for _, p := range peers {
|
||||
assert.True(t, p.IPv6.IsValid(), "peer %s should have IPv6 with All group enabled", p.ID)
|
||||
@@ -4556,7 +4813,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_IPv6EnabledGroups(t *testin
|
||||
assert.Equal(t, []string{partialGroup.ID}, updatedSettings.IPv6EnabledGroups)
|
||||
|
||||
// peer1 and peer2 should have IPv6; peer3 should not.
|
||||
peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "")
|
||||
peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "", "")
|
||||
require.NoError(t, err)
|
||||
peerMap := make(map[string]*nbpeer.Peer, len(peers))
|
||||
for _, p := range peers {
|
||||
@@ -4576,7 +4833,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_IPv6EnabledGroups(t *testin
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, updatedSettings.IPv6EnabledGroups)
|
||||
|
||||
peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "")
|
||||
peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "", "")
|
||||
require.NoError(t, err)
|
||||
for _, p := range peers {
|
||||
assert.False(t, p.IPv6.IsValid(), "peer %s should have no IPv6 when groups cleared", p.ID)
|
||||
@@ -4591,7 +4848,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_IPv6EnabledGroups(t *testin
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "")
|
||||
peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "", "")
|
||||
require.NoError(t, err)
|
||||
peerMap = make(map[string]*nbpeer.Peer, len(peers))
|
||||
for _, p := range peers {
|
||||
@@ -4733,3 +4990,27 @@ func TestUpdateUserAuthWithSingleMode(t *testing.T) {
|
||||
assert.Contains(t, err.Error(), "query failed")
|
||||
})
|
||||
}
|
||||
|
||||
func TestDefaultAccountManager_ExpirationJobsStopForDeletedAccount(t *testing.T) {
|
||||
manager, _, err := createManager(t)
|
||||
require.NoError(t, err, "unable to create account manager")
|
||||
|
||||
ctx := context.Background()
|
||||
accountID, err := manager.GetAccountIDByUserID(ctx, auth.UserAuth{UserId: userID})
|
||||
require.NoError(t, err, "unable to create an account")
|
||||
|
||||
account, err := manager.Store.GetAccount(ctx, accountID)
|
||||
require.NoError(t, err, "unable to get account")
|
||||
require.NoError(t, manager.Store.DeleteAccount(ctx, account), "unable to delete account")
|
||||
|
||||
jobs := map[string]func() (time.Duration, bool){
|
||||
"login": manager.peerLoginExpirationJob(ctx, accountID),
|
||||
"inactivity": manager.peerInactivityExpirationJob(ctx, accountID),
|
||||
}
|
||||
for name, job := range jobs {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, reschedule := job()
|
||||
assert.False(t, reschedule, "job must not reschedule for a deleted account")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,6 +287,11 @@ const (
|
||||
// CustomDomainValidationExpired indicates that an unvalidated domain registration expired.
|
||||
CustomDomainValidationExpired Activity = 143
|
||||
|
||||
// AccountPeerHostnameCollisionRejectionEnabled indicates that a user enabled rejecting peer hostname collisions for the account
|
||||
AccountPeerHostnameCollisionRejectionEnabled Activity = 144
|
||||
// AccountPeerHostnameCollisionRejectionDisabled indicates that a user disabled rejecting peer hostname collisions for the account
|
||||
AccountPeerHostnameCollisionRejectionDisabled Activity = 145
|
||||
|
||||
AccountDeleted Activity = 99999
|
||||
)
|
||||
|
||||
@@ -461,8 +466,10 @@ var activityMap = map[Activity]Code{
|
||||
AgentNetworkSettingsUpdated: {"Agent Network settings updated", "agent_network.settings.update"},
|
||||
AgentNetworkSettingsDeleted: {"Agent Network settings deleted", "agent_network.settings.delete"},
|
||||
|
||||
AccountMetricsPushEnabled: {"Account metrics push enabled", "account.setting.metrics.push.enable"},
|
||||
AccountMetricsPushDisabled: {"Account metrics push disabled", "account.setting.metrics.push.disable"},
|
||||
AccountMetricsPushEnabled: {"Account metrics push enabled", "account.setting.metrics.push.enable"},
|
||||
AccountMetricsPushDisabled: {"Account metrics push disabled", "account.setting.metrics.push.disable"},
|
||||
AccountPeerHostnameCollisionRejectionEnabled: {"Account peer hostname collision rejection enabled", "account.setting.peer.hostname.collision.rejection.enable"},
|
||||
AccountPeerHostnameCollisionRejectionDisabled: {"Account peer hostname collision rejection disabled", "account.setting.peer.hostname.collision.rejection.disable"},
|
||||
|
||||
DomainAdded: {"Domain added", "domain.add"},
|
||||
DomainDeleted: {"Domain deleted", "domain.delete"},
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map"
|
||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
)
|
||||
|
||||
const (
|
||||
ipv6GroupA = "ipv6-grp-a"
|
||||
ipv6GroupB = "ipv6-grp-b"
|
||||
ipv6GroupC = "ipv6-grp-c"
|
||||
ipv6GroupD = "ipv6-grp-d"
|
||||
)
|
||||
|
||||
// ipv6AffectedTest holds three peers: peer1 in group A, peer2 in group B, peer3 in
|
||||
// group C, with a single A<->B policy. peer3 is unrelated to peer1 and peer2. Group D
|
||||
// is empty and referenced by nothing.
|
||||
type ipv6AffectedTest struct {
|
||||
manager *DefaultAccountManager
|
||||
accountID string
|
||||
peer1, peer2, peer3 *nbpeer.Peer
|
||||
updMsg1, updMsg2, updMsg3 <-chan *network_map.UpdateMessage
|
||||
}
|
||||
|
||||
func setupIPv6AffectedTest(t *testing.T, ipv6Groups []string) *ipv6AffectedTest {
|
||||
t.Helper()
|
||||
|
||||
manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t)
|
||||
ctx := context.Background()
|
||||
accountID := account.Id
|
||||
|
||||
policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
|
||||
require.NoError(t, err)
|
||||
for _, p := range policies {
|
||||
require.NoError(t, manager.Store.DeletePolicy(ctx, accountID, p.ID))
|
||||
}
|
||||
|
||||
for _, g := range []*types.Group{
|
||||
{ID: ipv6GroupA, Name: "IPv6-A", Peers: []string{peer1.ID}},
|
||||
{ID: ipv6GroupB, Name: "IPv6-B", Peers: []string{peer2.ID}},
|
||||
{ID: ipv6GroupC, Name: "IPv6-C", Peers: []string{peer3.ID}},
|
||||
{ID: ipv6GroupD, Name: "IPv6-D"},
|
||||
} {
|
||||
require.NoError(t, manager.CreateGroup(ctx, accountID, userID, g))
|
||||
}
|
||||
|
||||
_, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{
|
||||
Enabled: true,
|
||||
Rules: []*types.PolicyRule{{
|
||||
Enabled: true,
|
||||
Sources: []string{ipv6GroupA},
|
||||
Destinations: []string{ipv6GroupB},
|
||||
Bidirectional: true,
|
||||
Action: types.PolicyTrafficActionAccept,
|
||||
}},
|
||||
}, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
// New accounts enable IPv6 for the All group; start from the requested groups.
|
||||
updateIPv6TestSettings(t, manager, accountID, func(s *types.Settings) {
|
||||
s.IPv6EnabledGroups = ipv6Groups
|
||||
})
|
||||
|
||||
tc := &ipv6AffectedTest{
|
||||
manager: manager,
|
||||
accountID: accountID,
|
||||
peer1: peer1,
|
||||
peer2: peer2,
|
||||
peer3: peer3,
|
||||
}
|
||||
tc.updMsg1 = updateManager.CreateChannel(ctx, peer1.ID)
|
||||
tc.updMsg2 = updateManager.CreateChannel(ctx, peer2.ID)
|
||||
tc.updMsg3 = updateManager.CreateChannel(ctx, peer3.ID)
|
||||
t.Cleanup(func() {
|
||||
updateManager.CloseChannel(ctx, peer1.ID)
|
||||
updateManager.CloseChannel(ctx, peer2.ID)
|
||||
updateManager.CloseChannel(ctx, peer3.ID)
|
||||
})
|
||||
|
||||
// The setup changes above dispatch asynchronously and can land after the
|
||||
// channels open, so drop them before the test acts.
|
||||
drainPeerUpdates(tc.updMsg1)
|
||||
drainPeerUpdates(tc.updMsg2)
|
||||
drainPeerUpdates(tc.updMsg3)
|
||||
|
||||
return tc
|
||||
}
|
||||
|
||||
// updateIPv6TestSettings applies mutate to a copy of the current settings, so only
|
||||
// the mutated fields differ from what is stored.
|
||||
func updateIPv6TestSettings(t *testing.T, manager *DefaultAccountManager, accountID string, mutate func(*types.Settings)) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
current, err := manager.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
updated := current.Copy()
|
||||
mutate(updated)
|
||||
|
||||
_, err = manager.UpdateAccountSettings(ctx, accountID, userID, updated)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func (tc *ipv6AffectedTest) peerIPv6(t *testing.T, peerID string) netip.Addr {
|
||||
t.Helper()
|
||||
peer, err := tc.manager.Store.GetPeerByID(context.Background(), store.LockingStrengthNone, tc.accountID, peerID)
|
||||
require.NoError(t, err)
|
||||
return peer.IPv6
|
||||
}
|
||||
|
||||
func TestAffectedPeers_IPv6GroupEnabled_RefreshesOnlyReachablePeers(t *testing.T) {
|
||||
tc := setupIPv6AffectedTest(t, nil)
|
||||
|
||||
updateIPv6TestSettings(t, tc.manager, tc.accountID, func(s *types.Settings) {
|
||||
s.IPv6EnabledGroups = []string{ipv6GroupA}
|
||||
})
|
||||
require.True(t, tc.peerIPv6(t, tc.peer1.ID).IsValid(), "peer1 should get an IPv6 address")
|
||||
|
||||
peerShouldReceiveUpdate(t, tc.updMsg1)
|
||||
peerShouldReceiveUpdate(t, tc.updMsg2)
|
||||
peerShouldNotReceiveUpdate(t, tc.updMsg3)
|
||||
}
|
||||
|
||||
func TestAffectedPeers_IPv6GroupDisabled_RefreshesOnlyReachablePeers(t *testing.T) {
|
||||
tc := setupIPv6AffectedTest(t, []string{ipv6GroupA})
|
||||
require.True(t, tc.peerIPv6(t, tc.peer1.ID).IsValid(), "peer1 should start with an IPv6 address")
|
||||
|
||||
updateIPv6TestSettings(t, tc.manager, tc.accountID, func(s *types.Settings) {
|
||||
s.IPv6EnabledGroups = []string{}
|
||||
})
|
||||
require.False(t, tc.peerIPv6(t, tc.peer1.ID).IsValid(), "peer1 should lose its IPv6 address")
|
||||
|
||||
peerShouldReceiveUpdate(t, tc.updMsg1)
|
||||
peerShouldReceiveUpdate(t, tc.updMsg2)
|
||||
peerShouldNotReceiveUpdate(t, tc.updMsg3)
|
||||
}
|
||||
|
||||
// Widening the IPv6 range keeps peer addresses, but each holder's interface prefix
|
||||
// comes from the range, so holders refresh while peers that only reach them do not.
|
||||
func TestAffectedPeers_IPv6RangeWidened_RefreshesAddressHolders(t *testing.T) {
|
||||
tc := setupIPv6AffectedTest(t, []string{ipv6GroupA})
|
||||
oldIPv6 := tc.peerIPv6(t, tc.peer1.ID)
|
||||
require.True(t, oldIPv6.IsValid(), "peer1 should start with an IPv6 address")
|
||||
|
||||
// The range is allocated on the account network; settings may leave it empty.
|
||||
network, err := tc.manager.Store.GetAccountNetwork(context.Background(), store.LockingStrengthNone, tc.accountID)
|
||||
require.NoError(t, err)
|
||||
current := prefixFromIPNet(network.NetV6)
|
||||
require.True(t, current.IsValid(), "account should have an IPv6 range")
|
||||
widened := netip.PrefixFrom(current.Addr(), current.Bits()-8).Masked()
|
||||
|
||||
updateIPv6TestSettings(t, tc.manager, tc.accountID, func(s *types.Settings) {
|
||||
s.NetworkRangeV6 = widened
|
||||
})
|
||||
require.Equal(t, oldIPv6, tc.peerIPv6(t, tc.peer1.ID), "peer1 should keep its address inside the widened range")
|
||||
|
||||
peerShouldReceiveUpdate(t, tc.updMsg1)
|
||||
peerShouldNotReceiveUpdate(t, tc.updMsg2)
|
||||
peerShouldNotReceiveUpdate(t, tc.updMsg3)
|
||||
}
|
||||
|
||||
func TestAffectedPeers_IPv4RangeChange_RefreshesWholeAccount(t *testing.T) {
|
||||
tc := setupIPv6AffectedTest(t, nil)
|
||||
|
||||
updateIPv6TestSettings(t, tc.manager, tc.accountID, func(s *types.Settings) {
|
||||
s.NetworkRange = netip.MustParsePrefix("100.70.0.0/16")
|
||||
})
|
||||
|
||||
peerShouldReceiveUpdate(t, tc.updMsg1)
|
||||
peerShouldReceiveUpdate(t, tc.updMsg2)
|
||||
peerShouldReceiveUpdate(t, tc.updMsg3)
|
||||
}
|
||||
|
||||
func TestAffectedPeers_IPv6WithAccountWideChange_RefreshesWholeAccount(t *testing.T) {
|
||||
tc := setupIPv6AffectedTest(t, nil)
|
||||
|
||||
updateIPv6TestSettings(t, tc.manager, tc.accountID, func(s *types.Settings) {
|
||||
s.IPv6EnabledGroups = []string{ipv6GroupA}
|
||||
s.LazyConnectionEnabled = !s.LazyConnectionEnabled
|
||||
})
|
||||
|
||||
peerShouldReceiveUpdate(t, tc.updMsg1)
|
||||
peerShouldReceiveUpdate(t, tc.updMsg2)
|
||||
peerShouldReceiveUpdate(t, tc.updMsg3)
|
||||
}
|
||||
|
||||
// Joining an IPv6-enabled group that no policy references gives peer1 an address.
|
||||
// peer2 reaches peer1 through group A, not through the joined group, and must still
|
||||
// learn the new address.
|
||||
func TestAffectedPeers_GroupAddPeerIPv6_RefreshesPeersReachingThroughOtherGroups(t *testing.T) {
|
||||
tc := setupIPv6AffectedTest(t, []string{ipv6GroupD})
|
||||
|
||||
require.NoError(t, tc.manager.GroupAddPeer(context.Background(), tc.accountID, ipv6GroupD, tc.peer1.ID))
|
||||
require.True(t, tc.peerIPv6(t, tc.peer1.ID).IsValid(), "peer1 should get an IPv6 address")
|
||||
|
||||
peerShouldReceiveUpdate(t, tc.updMsg1)
|
||||
peerShouldReceiveUpdate(t, tc.updMsg2)
|
||||
peerShouldNotReceiveUpdate(t, tc.updMsg3)
|
||||
}
|
||||
|
||||
func TestAffectedPeers_UpdateGroupIPv6_RefreshesPeersReachingThroughOtherGroups(t *testing.T) {
|
||||
tc := setupIPv6AffectedTest(t, []string{ipv6GroupD})
|
||||
|
||||
require.NoError(t, tc.manager.UpdateGroup(context.Background(), tc.accountID, userID, &types.Group{
|
||||
ID: ipv6GroupD,
|
||||
Name: "IPv6-D",
|
||||
Peers: []string{tc.peer1.ID},
|
||||
}))
|
||||
require.True(t, tc.peerIPv6(t, tc.peer1.ID).IsValid(), "peer1 should get an IPv6 address")
|
||||
|
||||
peerShouldReceiveUpdate(t, tc.updMsg1)
|
||||
peerShouldReceiveUpdate(t, tc.updMsg2)
|
||||
peerShouldNotReceiveUpdate(t, tc.updMsg3)
|
||||
}
|
||||
|
||||
// Deleting an IPv6-enabled group removes its members' addresses after the
|
||||
// pre-delete snapshot was taken.
|
||||
func TestAffectedPeers_DeleteIPv6Group_RefreshesFormerMembersAndReachablePeers(t *testing.T) {
|
||||
tc := setupIPv6AffectedTest(t, []string{ipv6GroupD})
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, tc.manager.GroupAddPeer(ctx, tc.accountID, ipv6GroupD, tc.peer1.ID))
|
||||
require.True(t, tc.peerIPv6(t, tc.peer1.ID).IsValid(), "peer1 should get an IPv6 address")
|
||||
drainPeerUpdates(tc.updMsg1)
|
||||
drainPeerUpdates(tc.updMsg2)
|
||||
drainPeerUpdates(tc.updMsg3)
|
||||
|
||||
require.NoError(t, tc.manager.DeleteGroup(ctx, tc.accountID, userID, ipv6GroupD))
|
||||
require.False(t, tc.peerIPv6(t, tc.peer1.ID).IsValid(), "peer1 should lose its IPv6 address")
|
||||
|
||||
peerShouldReceiveUpdate(t, tc.updMsg1)
|
||||
peerShouldReceiveUpdate(t, tc.updMsg2)
|
||||
peerShouldNotReceiveUpdate(t, tc.updMsg3)
|
||||
}
|
||||
@@ -87,93 +87,93 @@ func TestAffectedPeers_AllowedUsersChange_RefreshesSSHDestinations(t *testing.T)
|
||||
// auto-group change updates only the user's peers and the peers linked to the changed
|
||||
// group through policies, instead of fanning out to the whole account.
|
||||
func TestAffectedPeers_SyncUserJWTGroups_OnlyAffectedPeersUpdated(t *testing.T) {
|
||||
manager, updateManager, account, _, peer2, peer3 := setupNetworkMapTest(t)
|
||||
ctx := context.Background()
|
||||
accountID := account.Id
|
||||
runPeerUpdateTest(t, func(t *testing.T) {
|
||||
manager, updateManager, account, _, peer2, peer3 := setupNetworkMapTest(t)
|
||||
ctx := context.Background()
|
||||
accountID := account.Id
|
||||
|
||||
key, err := wgtypes.GeneratePrivateKey()
|
||||
require.NoError(t, err)
|
||||
userPeer, _, _, _, err := manager.AddPeer(ctx, accountID, "", userID, &nbpeer.Peer{
|
||||
Key: key.PublicKey().String(),
|
||||
Meta: nbpeer.PeerSystemMeta{Hostname: "user-peer"},
|
||||
}, false)
|
||||
require.NoError(t, err)
|
||||
key, err := wgtypes.GeneratePrivateKey()
|
||||
require.NoError(t, err)
|
||||
userPeer, _, _, _, err := manager.AddPeer(ctx, accountID, "", userID, &nbpeer.Peer{
|
||||
Key: key.PublicKey().String(),
|
||||
Meta: nbpeer.PeerSystemMeta{Hostname: "user-peer"},
|
||||
}, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
|
||||
require.NoError(t, err)
|
||||
for _, p := range policies {
|
||||
require.NoError(t, manager.Store.DeletePolicy(ctx, accountID, p.ID))
|
||||
}
|
||||
policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
|
||||
require.NoError(t, err)
|
||||
for _, p := range policies {
|
||||
require.NoError(t, manager.Store.DeletePolicy(ctx, accountID, p.ID))
|
||||
}
|
||||
|
||||
account, err = manager.Store.GetAccount(ctx, accountID)
|
||||
require.NoError(t, err)
|
||||
account.Settings.JWTGroupsEnabled = true
|
||||
account.Settings.JWTGroupsClaimName = "groups"
|
||||
account.Settings.GroupsPropagationEnabled = true
|
||||
require.NoError(t, manager.Store.SaveAccount(ctx, account))
|
||||
account, err = manager.Store.GetAccount(ctx, accountID)
|
||||
require.NoError(t, err)
|
||||
account.Settings.JWTGroupsEnabled = true
|
||||
account.Settings.JWTGroupsClaimName = "groups"
|
||||
account.Settings.GroupsPropagationEnabled = true
|
||||
require.NoError(t, manager.Store.SaveAccount(ctx, account))
|
||||
|
||||
require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "jwt-grp", Name: "jwt-linked", Issued: types.GroupIssuedJWT, Peers: []string{}}))
|
||||
require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "jwt-dest", Name: "jwt-dest", Peers: []string{peer2.ID}}))
|
||||
require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "jwt-grp", Name: "jwt-linked", Issued: types.GroupIssuedJWT, Peers: []string{}}))
|
||||
require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "jwt-dest", Name: "jwt-dest", Peers: []string{peer2.ID}}))
|
||||
|
||||
_, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{
|
||||
Enabled: true,
|
||||
Rules: []*types.PolicyRule{
|
||||
{
|
||||
Enabled: true,
|
||||
Sources: []string{"jwt-grp"},
|
||||
Destinations: []string{"jwt-dest"},
|
||||
Bidirectional: true,
|
||||
Action: types.PolicyTrafficActionAccept,
|
||||
_, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{
|
||||
Enabled: true,
|
||||
Rules: []*types.PolicyRule{
|
||||
{
|
||||
Enabled: true,
|
||||
Sources: []string{"jwt-grp"},
|
||||
Destinations: []string{"jwt-dest"},
|
||||
Bidirectional: true,
|
||||
Action: types.PolicyTrafficActionAccept,
|
||||
},
|
||||
},
|
||||
},
|
||||
}, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
updUser := updateManager.CreateChannel(ctx, userPeer.ID)
|
||||
upd2 := updateManager.CreateChannel(ctx, peer2.ID)
|
||||
upd3 := updateManager.CreateChannel(ctx, peer3.ID)
|
||||
t.Cleanup(func() {
|
||||
updateManager.CloseChannel(ctx, userPeer.ID)
|
||||
updateManager.CloseChannel(ctx, peer2.ID)
|
||||
updateManager.CloseChannel(ctx, peer3.ID)
|
||||
})
|
||||
|
||||
userAuth := auth.UserAuth{
|
||||
AccountId: accountID,
|
||||
UserId: userID,
|
||||
Groups: []string{"jwt-linked"},
|
||||
}
|
||||
|
||||
t.Run("adding JWT group updates only linked peers", func(t *testing.T) {
|
||||
drainPeerUpdates(updUser)
|
||||
drainPeerUpdates(upd2)
|
||||
drainPeerUpdates(upd3)
|
||||
|
||||
require.NoError(t, manager.SyncUserJWTGroups(ctx, userAuth))
|
||||
|
||||
peerShouldReceiveUpdate(t, updUser)
|
||||
peerShouldReceiveUpdate(t, upd2)
|
||||
peerShouldNotReceiveUpdate(t, upd3)
|
||||
|
||||
user, err := manager.Store.GetUserByUserID(ctx, store.LockingStrengthNone, userID)
|
||||
}, true)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, user.AutoGroups, "jwt-grp")
|
||||
})
|
||||
|
||||
t.Run("removing JWT group updates only linked peers", func(t *testing.T) {
|
||||
drainPeerUpdates(updUser)
|
||||
drainPeerUpdates(upd2)
|
||||
drainPeerUpdates(upd3)
|
||||
updUser := updateManager.CreateChannel(ctx, userPeer.ID)
|
||||
upd2 := updateManager.CreateChannel(ctx, peer2.ID)
|
||||
upd3 := updateManager.CreateChannel(ctx, peer3.ID)
|
||||
t.Cleanup(func() {
|
||||
updateManager.CloseChannel(ctx, userPeer.ID)
|
||||
updateManager.CloseChannel(ctx, peer2.ID)
|
||||
updateManager.CloseChannel(ctx, peer3.ID)
|
||||
})
|
||||
|
||||
userAuth.Groups = nil
|
||||
require.NoError(t, manager.SyncUserJWTGroups(ctx, userAuth))
|
||||
userAuth := auth.UserAuth{
|
||||
AccountId: accountID,
|
||||
UserId: userID,
|
||||
Groups: []string{"jwt-linked"},
|
||||
}
|
||||
|
||||
peerShouldReceiveUpdate(t, updUser)
|
||||
peerShouldReceiveUpdate(t, upd2)
|
||||
peerShouldNotReceiveUpdate(t, upd3)
|
||||
step(t, "adding JWT group updates only linked peers", func(t *testing.T) {
|
||||
settleAffectedUpdates(updUser, upd2, upd3)
|
||||
|
||||
user, err := manager.Store.GetUserByUserID(ctx, store.LockingStrengthNone, userID)
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, user.AutoGroups, "jwt-grp")
|
||||
require.NoError(t, manager.SyncUserJWTGroups(ctx, userAuth))
|
||||
|
||||
peerShouldReceiveUpdate(t, updUser)
|
||||
peerShouldReceiveUpdate(t, upd2)
|
||||
peerShouldNotReceiveUpdate(t, upd3)
|
||||
|
||||
user, err := manager.Store.GetUserByUserID(ctx, store.LockingStrengthNone, userID)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, user.AutoGroups, "jwt-grp")
|
||||
})
|
||||
|
||||
step(t, "removing JWT group updates only linked peers", func(t *testing.T) {
|
||||
drainPeerUpdates(updUser)
|
||||
drainPeerUpdates(upd2)
|
||||
drainPeerUpdates(upd3)
|
||||
|
||||
userAuth.Groups = nil
|
||||
require.NoError(t, manager.SyncUserJWTGroups(ctx, userAuth))
|
||||
|
||||
peerShouldReceiveUpdate(t, updUser)
|
||||
peerShouldReceiveUpdate(t, upd2)
|
||||
peerShouldNotReceiveUpdate(t, upd3)
|
||||
|
||||
user, err := manager.Store.GetUserByUserID(ctx, store.LockingStrengthNone, userID)
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, user.AutoGroups, "jwt-grp")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -24,120 +24,124 @@ import (
|
||||
// detaching one of them must still refresh that group's policy source peers, even
|
||||
// though the post-update resource no longer maps to it.
|
||||
func TestAffectedPeers_E2E_UpdateResource_DetachGroup_RefreshesOldGroupSources(t *testing.T) {
|
||||
s := setupRouterScenario(t, true)
|
||||
ctx := context.Background()
|
||||
runPeerUpdateTest(t, func(t *testing.T) {
|
||||
s := setupRouterScenario(t, true)
|
||||
ctx := context.Background()
|
||||
|
||||
// A second resource group + a second source group/peer that reaches the
|
||||
// resource only through that second group.
|
||||
const detachGroupID = "rs-detach-grp"
|
||||
require.NoError(t, s.manager.CreateGroup(ctx, s.accountID, userID, &types.Group{ID: detachGroupID, Name: "rs-detach"}))
|
||||
// A second resource group + a second source group/peer that reaches the
|
||||
// resource only through that second group.
|
||||
const detachGroupID = "rs-detach-grp"
|
||||
require.NoError(t, s.manager.CreateGroup(ctx, s.accountID, userID, &types.Group{ID: detachGroupID, Name: "rs-detach"}))
|
||||
|
||||
const secondSourceGroupID = "rs-source-grp-2"
|
||||
setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "rs-detach-key", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false)
|
||||
require.NoError(t, err)
|
||||
secondSourcePeer := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key)
|
||||
require.NoError(t, s.manager.CreateGroup(ctx, s.accountID, userID, &types.Group{
|
||||
ID: secondSourceGroupID, Name: "rs-source-2", Peers: []string{secondSourcePeer.ID},
|
||||
}))
|
||||
const secondSourceGroupID = "rs-source-grp-2"
|
||||
setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "rs-detach-key", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false)
|
||||
require.NoError(t, err)
|
||||
secondSourcePeer := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key)
|
||||
require.NoError(t, s.manager.CreateGroup(ctx, s.accountID, userID, &types.Group{
|
||||
ID: secondSourceGroupID, Name: "rs-source-2", Peers: []string{secondSourcePeer.ID},
|
||||
}))
|
||||
|
||||
resourcesManager, _, _ := s.managers()
|
||||
resourcesManager, _, _ := s.managers()
|
||||
|
||||
// Attach the resource to the detach group as well: now in [resourceGroup, detachGroup].
|
||||
_, err = resourcesManager.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{
|
||||
ID: s.resourceID,
|
||||
AccountID: s.accountID,
|
||||
NetworkID: s.networkID,
|
||||
Name: "rs-resource-host",
|
||||
Address: "10.20.30.0/24",
|
||||
GroupIDs: []string{s.resourceGroupID, detachGroupID},
|
||||
Enabled: true,
|
||||
// Attach the resource to the detach group as well: now in [resourceGroup, detachGroup].
|
||||
_, err = resourcesManager.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{
|
||||
ID: s.resourceID,
|
||||
AccountID: s.accountID,
|
||||
NetworkID: s.networkID,
|
||||
Name: "rs-resource-host",
|
||||
Address: "10.20.30.0/24",
|
||||
GroupIDs: []string{s.resourceGroupID, detachGroupID},
|
||||
Enabled: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Policy granting the second source group access via the detach group.
|
||||
_, err = s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(secondSourceGroupID, detachGroupID), true)
|
||||
require.NoError(t, err)
|
||||
|
||||
secondSrcCh := s.updateManager.CreateChannel(ctx, secondSourcePeer.ID)
|
||||
t.Cleanup(func() { s.updateManager.CloseChannel(ctx, secondSourcePeer.ID) })
|
||||
settleAffectedUpdates(secondSrcCh)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
// Detaching the resource from detachGroup removes the second source's
|
||||
// access; that source peer must be refreshed even though the post-update
|
||||
// resource no longer maps to detachGroup.
|
||||
peerShouldReceiveUpdate(t, secondSrcCh)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
_, err = resourcesManager.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{
|
||||
ID: s.resourceID,
|
||||
AccountID: s.accountID,
|
||||
NetworkID: s.networkID,
|
||||
Name: "rs-resource-host",
|
||||
Address: "10.20.30.0/24",
|
||||
GroupIDs: []string{s.resourceGroupID}, // detached detachGroup
|
||||
Enabled: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout: detaching a resource group did not refresh the old group's policy source peer")
|
||||
}
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Policy granting the second source group access via the detach group.
|
||||
_, err = s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(secondSourceGroupID, detachGroupID), true)
|
||||
require.NoError(t, err)
|
||||
|
||||
secondSrcCh := s.updateManager.CreateChannel(ctx, secondSourcePeer.ID)
|
||||
t.Cleanup(func() { s.updateManager.CloseChannel(ctx, secondSourcePeer.ID) })
|
||||
settleAffectedUpdates(secondSrcCh)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
// Detaching the resource from detachGroup removes the second source's
|
||||
// access; that source peer must be refreshed even though the post-update
|
||||
// resource no longer maps to detachGroup.
|
||||
peerShouldReceiveUpdate(t, secondSrcCh)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
_, err = resourcesManager.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{
|
||||
ID: s.resourceID,
|
||||
AccountID: s.accountID,
|
||||
NetworkID: s.networkID,
|
||||
Name: "rs-resource-host",
|
||||
Address: "10.20.30.0/24",
|
||||
GroupIDs: []string{s.resourceGroupID}, // detached detachGroup
|
||||
Enabled: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout: detaching a resource group did not refresh the old group's policy source peer")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAffectedPeers_E2E_UpdateRouter_RepointPeer_RefreshesOldRoutingPeer:
|
||||
// changing router.Peer within the same network must still refresh the OLD routing
|
||||
// peer, which loses its routing role.
|
||||
func TestAffectedPeers_E2E_UpdateRouter_RepointPeer_RefreshesOldRoutingPeer(t *testing.T) {
|
||||
s := setupRouterScenario(t, true)
|
||||
ctx := context.Background()
|
||||
runPeerUpdateTest(t, func(t *testing.T) {
|
||||
s := setupRouterScenario(t, true)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true)
|
||||
require.NoError(t, err)
|
||||
_, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, routersManager, _ := s.managers()
|
||||
_, routersManager, _ := s.managers()
|
||||
|
||||
routers, err := s.manager.Store.GetNetworkRoutersByNetID(ctx, store.LockingStrengthNone, s.accountID, s.networkID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, routers, 1)
|
||||
router := routers[0]
|
||||
oldRoutingPeer := router.Peer
|
||||
require.NotEmpty(t, oldRoutingPeer)
|
||||
routers, err := s.manager.Store.GetNetworkRoutersByNetID(ctx, store.LockingStrengthNone, s.accountID, s.networkID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, routers, 1)
|
||||
router := routers[0]
|
||||
oldRoutingPeer := router.Peer
|
||||
require.NotEmpty(t, oldRoutingPeer)
|
||||
|
||||
// A new peer to become the routing peer in place of the old one.
|
||||
setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "rs-newrouter-key", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false)
|
||||
require.NoError(t, err)
|
||||
newRoutingPeer := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key)
|
||||
// A new peer to become the routing peer in place of the old one.
|
||||
setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "rs-newrouter-key", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false)
|
||||
require.NoError(t, err)
|
||||
newRoutingPeer := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key)
|
||||
|
||||
oldCh := s.updateManager.CreateChannel(ctx, oldRoutingPeer)
|
||||
t.Cleanup(func() { s.updateManager.CloseChannel(ctx, oldRoutingPeer) })
|
||||
settleAffectedUpdates(oldCh)
|
||||
oldCh := s.updateManager.CreateChannel(ctx, oldRoutingPeer)
|
||||
t.Cleanup(func() { s.updateManager.CloseChannel(ctx, oldRoutingPeer) })
|
||||
settleAffectedUpdates(oldCh)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
// The old routing peer stops serving the resource and must be refreshed.
|
||||
peerShouldReceiveUpdate(t, oldCh)
|
||||
close(done)
|
||||
}()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
// The old routing peer stops serving the resource and must be refreshed.
|
||||
peerShouldReceiveUpdate(t, oldCh)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
_, err = routersManager.UpdateRouter(ctx, userID, &routerTypes.NetworkRouter{
|
||||
ID: router.ID,
|
||||
NetworkID: s.networkID,
|
||||
AccountID: s.accountID,
|
||||
Peer: newRoutingPeer.ID, // repoint within the same network
|
||||
Masquerade: true,
|
||||
Metric: 9999,
|
||||
Enabled: true,
|
||||
_, err = routersManager.UpdateRouter(ctx, userID, &routerTypes.NetworkRouter{
|
||||
ID: router.ID,
|
||||
NetworkID: s.networkID,
|
||||
AccountID: s.accountID,
|
||||
Peer: newRoutingPeer.ID, // repoint within the same network
|
||||
Masquerade: true,
|
||||
Metric: 9999,
|
||||
Enabled: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout: re-pointing the router peer did not refresh the old routing peer")
|
||||
}
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout: re-pointing the router peer did not refresh the old routing peer")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,148 +165,154 @@ func (s *routerScenario) createPostureCheckGatedPolicy(t *testing.T, ctx context
|
||||
}
|
||||
|
||||
func TestAffectedPeers_E2E_SavePostureCheck_RefreshesRoutingPeer(t *testing.T) {
|
||||
s := setupRouterScenario(t, true)
|
||||
ctx := context.Background()
|
||||
runPeerUpdateTest(t, func(t *testing.T) {
|
||||
s := setupRouterScenario(t, true)
|
||||
ctx := context.Background()
|
||||
|
||||
checkID := s.createPostureCheckGatedPolicy(t, ctx, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID))
|
||||
checkID := s.createPostureCheckGatedPolicy(t, ctx, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID))
|
||||
|
||||
srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID)
|
||||
routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID)
|
||||
unrelatedCh := s.updateManager.CreateChannel(ctx, s.unrelatedPeerID)
|
||||
t.Cleanup(func() {
|
||||
s.updateManager.CloseChannel(ctx, s.sourcePeerID)
|
||||
s.updateManager.CloseChannel(ctx, s.routerPeerID)
|
||||
s.updateManager.CloseChannel(ctx, s.unrelatedPeerID)
|
||||
srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID)
|
||||
routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID)
|
||||
unrelatedCh := s.updateManager.CreateChannel(ctx, s.unrelatedPeerID)
|
||||
t.Cleanup(func() {
|
||||
s.updateManager.CloseChannel(ctx, s.sourcePeerID)
|
||||
s.updateManager.CloseChannel(ctx, s.routerPeerID)
|
||||
s.updateManager.CloseChannel(ctx, s.unrelatedPeerID)
|
||||
})
|
||||
|
||||
settleAffectedUpdates(srcCh, routerCh, unrelatedCh)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, srcCh)
|
||||
peerShouldReceiveUpdate(t, routerCh)
|
||||
peerShouldNotReceiveUpdate(t, unrelatedCh)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
_, err := s.manager.SavePostureChecks(ctx, s.accountID, userID, &posture.Checks{
|
||||
ID: checkID,
|
||||
Name: "rs-min-version",
|
||||
Checks: posture.ChecksDefinition{
|
||||
NBVersionCheck: &posture.NBVersionCheck{MinVersion: "0.31.0"},
|
||||
},
|
||||
}, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout: editing a posture check did not refresh source + routing peers")
|
||||
}
|
||||
})
|
||||
|
||||
settleAffectedUpdates(srcCh, routerCh, unrelatedCh)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, srcCh)
|
||||
peerShouldReceiveUpdate(t, routerCh)
|
||||
peerShouldNotReceiveUpdate(t, unrelatedCh)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
_, err := s.manager.SavePostureChecks(ctx, s.accountID, userID, &posture.Checks{
|
||||
ID: checkID,
|
||||
Name: "rs-min-version",
|
||||
Checks: posture.ChecksDefinition{
|
||||
NBVersionCheck: &posture.NBVersionCheck{MinVersion: "0.31.0"},
|
||||
},
|
||||
}, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout: editing a posture check did not refresh source + routing peers")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAffectedPeers_E2E_UpdateResource_DestinationResourcePolicy_RefreshesSourcePeer(t *testing.T) {
|
||||
s := setupRouterScenario(t, true)
|
||||
ctx := context.Background()
|
||||
runPeerUpdateTest(t, func(t *testing.T) {
|
||||
s := setupRouterScenario(t, true)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByResource(s.sourceGroupID, s.resourceID), true)
|
||||
require.NoError(t, err)
|
||||
_, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByResource(s.sourceGroupID, s.resourceID), true)
|
||||
require.NoError(t, err)
|
||||
|
||||
resourcesManager, _, _ := s.managers()
|
||||
resourcesManager, _, _ := s.managers()
|
||||
|
||||
srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID)
|
||||
routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID)
|
||||
unrelatedCh := s.updateManager.CreateChannel(ctx, s.unrelatedPeerID)
|
||||
t.Cleanup(func() {
|
||||
s.updateManager.CloseChannel(ctx, s.sourcePeerID)
|
||||
s.updateManager.CloseChannel(ctx, s.routerPeerID)
|
||||
s.updateManager.CloseChannel(ctx, s.unrelatedPeerID)
|
||||
srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID)
|
||||
routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID)
|
||||
unrelatedCh := s.updateManager.CreateChannel(ctx, s.unrelatedPeerID)
|
||||
t.Cleanup(func() {
|
||||
s.updateManager.CloseChannel(ctx, s.sourcePeerID)
|
||||
s.updateManager.CloseChannel(ctx, s.routerPeerID)
|
||||
s.updateManager.CloseChannel(ctx, s.unrelatedPeerID)
|
||||
})
|
||||
|
||||
settleAffectedUpdates(srcCh, routerCh, unrelatedCh)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, srcCh)
|
||||
peerShouldReceiveUpdate(t, routerCh)
|
||||
peerShouldNotReceiveUpdate(t, unrelatedCh)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
_, err = resourcesManager.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{
|
||||
ID: s.resourceID,
|
||||
AccountID: s.accountID,
|
||||
NetworkID: s.networkID,
|
||||
Name: "rs-resource-host",
|
||||
Address: "10.20.30.0/25",
|
||||
GroupIDs: []string{s.resourceGroupID},
|
||||
Enabled: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout: updating a DestinationResource-targeted resource did not refresh its policy source peer")
|
||||
}
|
||||
})
|
||||
|
||||
settleAffectedUpdates(srcCh, routerCh, unrelatedCh)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, srcCh)
|
||||
peerShouldReceiveUpdate(t, routerCh)
|
||||
peerShouldNotReceiveUpdate(t, unrelatedCh)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
_, err = resourcesManager.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{
|
||||
ID: s.resourceID,
|
||||
AccountID: s.accountID,
|
||||
NetworkID: s.networkID,
|
||||
Name: "rs-resource-host",
|
||||
Address: "10.20.30.0/25",
|
||||
GroupIDs: []string{s.resourceGroupID},
|
||||
Enabled: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout: updating a DestinationResource-targeted resource did not refresh its policy source peer")
|
||||
}
|
||||
}
|
||||
|
||||
// A disabled sibling router routes to nobody, so updating a resource on its network
|
||||
// must NOT refresh its peer (the enabled router carries the bridge instead).
|
||||
func TestAffectedPeers_E2E_UpdateResource_DisabledSiblingRouterNotBridged(t *testing.T) {
|
||||
s := setupRouterScenario(t, true)
|
||||
ctx := context.Background()
|
||||
runPeerUpdateTest(t, func(t *testing.T) {
|
||||
s := setupRouterScenario(t, true)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true)
|
||||
require.NoError(t, err)
|
||||
_, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true)
|
||||
require.NoError(t, err)
|
||||
|
||||
resourcesManager, routersManager, _ := s.managers()
|
||||
resourcesManager, routersManager, _ := s.managers()
|
||||
|
||||
setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "rs-key-disabled", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false)
|
||||
require.NoError(t, err)
|
||||
disabledRouterPeer := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key)
|
||||
_, err = routersManager.CreateRouter(ctx, userID, &routerTypes.NetworkRouter{
|
||||
NetworkID: s.networkID,
|
||||
AccountID: s.accountID,
|
||||
Peer: disabledRouterPeer.ID,
|
||||
Masquerade: true,
|
||||
Metric: 9000,
|
||||
Enabled: false,
|
||||
setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "rs-key-disabled", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false)
|
||||
require.NoError(t, err)
|
||||
disabledRouterPeer := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key)
|
||||
_, err = routersManager.CreateRouter(ctx, userID, &routerTypes.NetworkRouter{
|
||||
NetworkID: s.networkID,
|
||||
AccountID: s.accountID,
|
||||
Peer: disabledRouterPeer.ID,
|
||||
Masquerade: true,
|
||||
Metric: 9000,
|
||||
Enabled: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
disabledCh := s.updateManager.CreateChannel(ctx, disabledRouterPeer.ID)
|
||||
enabledCh := s.updateManager.CreateChannel(ctx, s.routerPeerID)
|
||||
t.Cleanup(func() {
|
||||
s.updateManager.CloseChannel(ctx, disabledRouterPeer.ID)
|
||||
s.updateManager.CloseChannel(ctx, s.routerPeerID)
|
||||
})
|
||||
|
||||
settleAffectedUpdates(disabledCh, enabledCh)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, enabledCh)
|
||||
peerShouldNotReceiveUpdate(t, disabledCh)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
_, err = resourcesManager.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{
|
||||
ID: s.resourceID,
|
||||
AccountID: s.accountID,
|
||||
NetworkID: s.networkID,
|
||||
Name: "rs-resource-host",
|
||||
Address: "10.20.30.0/25",
|
||||
GroupIDs: []string{s.resourceGroupID},
|
||||
Enabled: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout")
|
||||
}
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
disabledCh := s.updateManager.CreateChannel(ctx, disabledRouterPeer.ID)
|
||||
enabledCh := s.updateManager.CreateChannel(ctx, s.routerPeerID)
|
||||
t.Cleanup(func() {
|
||||
s.updateManager.CloseChannel(ctx, disabledRouterPeer.ID)
|
||||
s.updateManager.CloseChannel(ctx, s.routerPeerID)
|
||||
})
|
||||
|
||||
settleAffectedUpdates(disabledCh, enabledCh)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, enabledCh)
|
||||
peerShouldNotReceiveUpdate(t, disabledCh)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
_, err = resourcesManager.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{
|
||||
ID: s.resourceID,
|
||||
AccountID: s.accountID,
|
||||
NetworkID: s.networkID,
|
||||
Name: "rs-resource-host",
|
||||
Address: "10.20.30.0/25",
|
||||
GroupIDs: []string{s.resourceGroupID},
|
||||
Enabled: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAffectedPeers_GroupChange_RouterInOtherNetworkNotAffected(t *testing.T) {
|
||||
@@ -346,8 +352,10 @@ func TestAffectedPeers_PeerChange_RouterInOtherNetworkNotAffected(t *testing.T)
|
||||
// shortcut (the denied peer's map holds no router) and the allow direction
|
||||
// depends on which meta field moved, leaving the routers with a stale map.
|
||||
func TestAffectedPeers_E2E_PostureFlip_RefreshesRoutingPeer(t *testing.T) {
|
||||
runPostureFlipRefreshesRoutingPeer(t, func(s *routerScenario) *types.Policy {
|
||||
return peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID)
|
||||
runPeerUpdateTest(t, func(t *testing.T) {
|
||||
runPostureFlipRefreshesRoutingPeer(t, func(s *routerScenario) *types.Policy {
|
||||
return peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -355,8 +363,10 @@ func TestAffectedPeers_E2E_PostureFlip_RefreshesRoutingPeer(t *testing.T) {
|
||||
// scenario with the source peer named directly in the rule: it must receive its posture
|
||||
// checks and have its flips detected exactly like a group member.
|
||||
func TestAffectedPeers_E2E_PostureFlip_DirectSourcePeer_RefreshesRoutingPeer(t *testing.T) {
|
||||
runPostureFlipRefreshesRoutingPeer(t, func(s *routerScenario) *types.Policy {
|
||||
return peerToResourcePolicyByPeer(s.sourcePeerID, s.resourceGroupID)
|
||||
runPeerUpdateTest(t, func(t *testing.T) {
|
||||
runPostureFlipRefreshesRoutingPeer(t, func(s *routerScenario) *types.Policy {
|
||||
return peerToResourcePolicyByPeer(s.sourcePeerID, s.resourceGroupID)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map"
|
||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel"
|
||||
"github.com/netbirdio/netbird/management/server/affectedpeers"
|
||||
"github.com/netbirdio/netbird/management/server/groups"
|
||||
@@ -320,190 +319,189 @@ func TestAffectedPeers_PolicyToResource_UnrelatedPeerNotAffected(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAffectedPeers_ResourceSideBridgesToRoutingPeer_DirectRouter(t *testing.T) {
|
||||
s := setupRouterScenario(t, true)
|
||||
ctx := context.Background()
|
||||
runPeerUpdateTest(t, func(t *testing.T) {
|
||||
s := setupRouterScenario(t, true)
|
||||
ctx := context.Background()
|
||||
|
||||
// A pre-existing policy grants the source group access to the resource.
|
||||
_, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true)
|
||||
require.NoError(t, err)
|
||||
// A pre-existing policy grants the source group access to the resource.
|
||||
_, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Drive an update through the resource manager and assert the routing peer
|
||||
// is among the affected set by observing the channel. This path walks
|
||||
// policies whose destinations reference the resource's groups, folds in the
|
||||
// source groups, and loads the network's routers, so it reaches both the
|
||||
// source peer and the routing peer.
|
||||
permissionsManager := permissions.NewManager(s.manager.Store)
|
||||
groupsManager := groups.NewManager(s.manager.Store, permissionsManager, s.manager)
|
||||
rm := resources.NewManager(s.manager.Store, permissionsManager, groupsManager, s.manager, s.manager.serviceManager)
|
||||
// Drive an update through the resource manager and assert the routing peer
|
||||
// is among the affected set by observing the channel. This path walks
|
||||
// policies whose destinations reference the resource's groups, folds in the
|
||||
// source groups, and loads the network's routers, so it reaches both the
|
||||
// source peer and the routing peer.
|
||||
permissionsManager := permissions.NewManager(s.manager.Store)
|
||||
groupsManager := groups.NewManager(s.manager.Store, permissionsManager, s.manager)
|
||||
rm := resources.NewManager(s.manager.Store, permissionsManager, groupsManager, s.manager, s.manager.serviceManager)
|
||||
|
||||
srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID)
|
||||
routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID)
|
||||
t.Cleanup(func() {
|
||||
s.updateManager.CloseChannel(ctx, s.sourcePeerID)
|
||||
s.updateManager.CloseChannel(ctx, s.routerPeerID)
|
||||
srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID)
|
||||
routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID)
|
||||
t.Cleanup(func() {
|
||||
s.updateManager.CloseChannel(ctx, s.sourcePeerID)
|
||||
s.updateManager.CloseChannel(ctx, s.routerPeerID)
|
||||
})
|
||||
|
||||
settleAffectedUpdates(srcCh, routerCh)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, srcCh)
|
||||
peerShouldReceiveUpdate(t, routerCh)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
_, err = rm.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{
|
||||
ID: s.resourceID,
|
||||
AccountID: s.accountID,
|
||||
NetworkID: s.networkID,
|
||||
Name: "rs-resource-host",
|
||||
Address: "10.20.30.0/24",
|
||||
GroupIDs: []string{s.resourceGroupID},
|
||||
Enabled: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout: resource update did not refresh source peer + routing peer")
|
||||
}
|
||||
})
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, srcCh)
|
||||
peerShouldReceiveUpdate(t, routerCh)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
_, err = rm.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{
|
||||
ID: s.resourceID,
|
||||
AccountID: s.accountID,
|
||||
NetworkID: s.networkID,
|
||||
Name: "rs-resource-host",
|
||||
Address: "10.20.30.0/24",
|
||||
GroupIDs: []string{s.resourceGroupID},
|
||||
Enabled: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout: resource update did not refresh source peer + routing peer")
|
||||
}
|
||||
}
|
||||
|
||||
// settleAffectedUpdates waits for in-flight async updates to arrive, then drains
|
||||
// every given channel so subsequent assertions start from a clean slate.
|
||||
//
|
||||
// Setup (CreateNetwork/CreateResource/CreateRouter) fires async UpdateAffectedPeers
|
||||
// goroutines; draining first means the assertion only observes updates from the
|
||||
// action under test, not setup stragglers.
|
||||
func settleAffectedUpdates(chans ...<-chan *network_map.UpdateMessage) {
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
for _, ch := range chans {
|
||||
drainPeerUpdates(ch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAffectedPeers_E2E_CreatePolicy_RoutingPeer_DirectRouter(t *testing.T) {
|
||||
s := setupRouterScenario(t, true)
|
||||
ctx := context.Background()
|
||||
runPeerUpdateTest(t, func(t *testing.T) {
|
||||
s := setupRouterScenario(t, true)
|
||||
ctx := context.Background()
|
||||
|
||||
srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID)
|
||||
routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID)
|
||||
unrelatedCh := s.updateManager.CreateChannel(ctx, s.unrelatedPeerID)
|
||||
t.Cleanup(func() {
|
||||
s.updateManager.CloseChannel(ctx, s.sourcePeerID)
|
||||
s.updateManager.CloseChannel(ctx, s.routerPeerID)
|
||||
s.updateManager.CloseChannel(ctx, s.unrelatedPeerID)
|
||||
srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID)
|
||||
routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID)
|
||||
unrelatedCh := s.updateManager.CreateChannel(ctx, s.unrelatedPeerID)
|
||||
t.Cleanup(func() {
|
||||
s.updateManager.CloseChannel(ctx, s.sourcePeerID)
|
||||
s.updateManager.CloseChannel(ctx, s.routerPeerID)
|
||||
s.updateManager.CloseChannel(ctx, s.unrelatedPeerID)
|
||||
})
|
||||
|
||||
settleAffectedUpdates(srcCh, routerCh, unrelatedCh)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, srcCh)
|
||||
peerShouldReceiveUpdate(t, routerCh)
|
||||
peerShouldNotReceiveUpdate(t, unrelatedCh)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
_, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true)
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout: creating peer->resource policy did not refresh the routing peer")
|
||||
}
|
||||
})
|
||||
|
||||
settleAffectedUpdates(srcCh, routerCh, unrelatedCh)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, srcCh)
|
||||
peerShouldReceiveUpdate(t, routerCh)
|
||||
peerShouldNotReceiveUpdate(t, unrelatedCh)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
_, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true)
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout: creating peer->resource policy did not refresh the routing peer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAffectedPeers_E2E_CreatePolicy_RoutingPeer_RouterPeerGroups(t *testing.T) {
|
||||
s := setupRouterScenario(t, false)
|
||||
ctx := context.Background()
|
||||
runPeerUpdateTest(t, func(t *testing.T) {
|
||||
s := setupRouterScenario(t, false)
|
||||
ctx := context.Background()
|
||||
|
||||
srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID)
|
||||
routerCh := s.updateManager.CreateChannel(ctx, s.routerGroupPeerID)
|
||||
t.Cleanup(func() {
|
||||
s.updateManager.CloseChannel(ctx, s.sourcePeerID)
|
||||
s.updateManager.CloseChannel(ctx, s.routerGroupPeerID)
|
||||
srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID)
|
||||
routerCh := s.updateManager.CreateChannel(ctx, s.routerGroupPeerID)
|
||||
t.Cleanup(func() {
|
||||
s.updateManager.CloseChannel(ctx, s.sourcePeerID)
|
||||
s.updateManager.CloseChannel(ctx, s.routerGroupPeerID)
|
||||
})
|
||||
|
||||
settleAffectedUpdates(srcCh, routerCh)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, srcCh)
|
||||
peerShouldReceiveUpdate(t, routerCh)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
_, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true)
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout: routing peer (PeerGroups) not refreshed on policy create")
|
||||
}
|
||||
})
|
||||
|
||||
settleAffectedUpdates(srcCh, routerCh)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, srcCh)
|
||||
peerShouldReceiveUpdate(t, routerCh)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
_, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true)
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout: routing peer (PeerGroups) not refreshed on policy create")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAffectedPeers_E2E_DestResource_RoutingPeer(t *testing.T) {
|
||||
s := setupRouterScenario(t, true)
|
||||
ctx := context.Background()
|
||||
runPeerUpdateTest(t, func(t *testing.T) {
|
||||
s := setupRouterScenario(t, true)
|
||||
ctx := context.Background()
|
||||
|
||||
srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID)
|
||||
routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID)
|
||||
t.Cleanup(func() {
|
||||
s.updateManager.CloseChannel(ctx, s.sourcePeerID)
|
||||
s.updateManager.CloseChannel(ctx, s.routerPeerID)
|
||||
srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID)
|
||||
routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID)
|
||||
t.Cleanup(func() {
|
||||
s.updateManager.CloseChannel(ctx, s.sourcePeerID)
|
||||
s.updateManager.CloseChannel(ctx, s.routerPeerID)
|
||||
})
|
||||
|
||||
settleAffectedUpdates(srcCh, routerCh)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, srcCh)
|
||||
peerShouldReceiveUpdate(t, routerCh)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
_, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByResource(s.sourceGroupID, s.resourceID), true)
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout: routing peer not refreshed when policy targets DestinationResource")
|
||||
}
|
||||
})
|
||||
|
||||
settleAffectedUpdates(srcCh, routerCh)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, srcCh)
|
||||
peerShouldReceiveUpdate(t, routerCh)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
_, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByResource(s.sourceGroupID, s.resourceID), true)
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout: routing peer not refreshed when policy targets DestinationResource")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAffectedPeers_E2E_DeletePolicy_RoutingPeer(t *testing.T) {
|
||||
s := setupRouterScenario(t, true)
|
||||
ctx := context.Background()
|
||||
runPeerUpdateTest(t, func(t *testing.T) {
|
||||
s := setupRouterScenario(t, true)
|
||||
ctx := context.Background()
|
||||
|
||||
policy, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true)
|
||||
require.NoError(t, err)
|
||||
policy, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true)
|
||||
require.NoError(t, err)
|
||||
|
||||
srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID)
|
||||
routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID)
|
||||
t.Cleanup(func() {
|
||||
s.updateManager.CloseChannel(ctx, s.sourcePeerID)
|
||||
s.updateManager.CloseChannel(ctx, s.routerPeerID)
|
||||
srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID)
|
||||
routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID)
|
||||
t.Cleanup(func() {
|
||||
s.updateManager.CloseChannel(ctx, s.sourcePeerID)
|
||||
s.updateManager.CloseChannel(ctx, s.routerPeerID)
|
||||
})
|
||||
|
||||
settleAffectedUpdates(srcCh, routerCh)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, srcCh)
|
||||
peerShouldReceiveUpdate(t, routerCh)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
require.NoError(t, s.manager.DeletePolicy(ctx, s.accountID, policy.ID, userID))
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout: deleting peer->resource policy did not refresh the routing peer")
|
||||
}
|
||||
})
|
||||
|
||||
settleAffectedUpdates(srcCh, routerCh)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, srcCh)
|
||||
peerShouldReceiveUpdate(t, routerCh)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
require.NoError(t, s.manager.DeletePolicy(ctx, s.accountID, policy.ID, userID))
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout: deleting peer->resource policy did not refresh the routing peer")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *routerScenario) managers() (resources.Manager, routers.Manager, networks.Manager) {
|
||||
@@ -572,113 +570,119 @@ func (s *routerScenario) addSecondTopology(t *testing.T, suffix string) secondTo
|
||||
}
|
||||
|
||||
func TestAffectedPeers_E2E_UpdatePolicy_BothRoutingPeers(t *testing.T) {
|
||||
s := setupRouterScenario(t, true)
|
||||
second := s.addSecondTopology(t, "b")
|
||||
ctx := context.Background()
|
||||
runPeerUpdateTest(t, func(t *testing.T) {
|
||||
s := setupRouterScenario(t, true)
|
||||
second := s.addSecondTopology(t, "b")
|
||||
ctx := context.Background()
|
||||
|
||||
policy, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true)
|
||||
require.NoError(t, err)
|
||||
policy, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true)
|
||||
require.NoError(t, err)
|
||||
|
||||
srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID)
|
||||
routerACh := s.updateManager.CreateChannel(ctx, s.routerPeerID)
|
||||
routerBCh := s.updateManager.CreateChannel(ctx, second.routerPeerID)
|
||||
t.Cleanup(func() {
|
||||
s.updateManager.CloseChannel(ctx, s.sourcePeerID)
|
||||
s.updateManager.CloseChannel(ctx, s.routerPeerID)
|
||||
s.updateManager.CloseChannel(ctx, second.routerPeerID)
|
||||
srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID)
|
||||
routerACh := s.updateManager.CreateChannel(ctx, s.routerPeerID)
|
||||
routerBCh := s.updateManager.CreateChannel(ctx, second.routerPeerID)
|
||||
t.Cleanup(func() {
|
||||
s.updateManager.CloseChannel(ctx, s.sourcePeerID)
|
||||
s.updateManager.CloseChannel(ctx, s.routerPeerID)
|
||||
s.updateManager.CloseChannel(ctx, second.routerPeerID)
|
||||
})
|
||||
|
||||
settleAffectedUpdates(srcCh, routerACh, routerBCh)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, srcCh)
|
||||
peerShouldReceiveUpdate(t, routerACh)
|
||||
peerShouldReceiveUpdate(t, routerBCh)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
policy.Rules[0].Destinations = []string{second.resourceGroupID}
|
||||
_, err = s.manager.SavePolicy(ctx, s.accountID, userID, policy, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout: re-pointing the policy destination did not refresh both routing peers")
|
||||
}
|
||||
})
|
||||
|
||||
settleAffectedUpdates(srcCh, routerACh, routerBCh)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, srcCh)
|
||||
peerShouldReceiveUpdate(t, routerACh)
|
||||
peerShouldReceiveUpdate(t, routerBCh)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
policy.Rules[0].Destinations = []string{second.resourceGroupID}
|
||||
_, err = s.manager.SavePolicy(ctx, s.accountID, userID, policy, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout: re-pointing the policy destination did not refresh both routing peers")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAffectedPeers_E2E_UpdatePolicy_AddSource(t *testing.T) {
|
||||
s := setupRouterScenario(t, true)
|
||||
ctx := context.Background()
|
||||
runPeerUpdateTest(t, func(t *testing.T) {
|
||||
s := setupRouterScenario(t, true)
|
||||
ctx := context.Background()
|
||||
|
||||
const secondSourceGroupID = "rs-source-grp-2"
|
||||
setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "rs-key-2", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false)
|
||||
require.NoError(t, err)
|
||||
secondSourcePeer := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key)
|
||||
require.NoError(t, s.manager.CreateGroup(ctx, s.accountID, userID, &types.Group{
|
||||
ID: secondSourceGroupID, Name: "rs-source-2", Peers: []string{secondSourcePeer.ID},
|
||||
}))
|
||||
const secondSourceGroupID = "rs-source-grp-2"
|
||||
setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "rs-key-2", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false)
|
||||
require.NoError(t, err)
|
||||
secondSourcePeer := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key)
|
||||
require.NoError(t, s.manager.CreateGroup(ctx, s.accountID, userID, &types.Group{
|
||||
ID: secondSourceGroupID, Name: "rs-source-2", Peers: []string{secondSourcePeer.ID},
|
||||
}))
|
||||
|
||||
policy, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true)
|
||||
require.NoError(t, err)
|
||||
policy, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true)
|
||||
require.NoError(t, err)
|
||||
|
||||
newSrcCh := s.updateManager.CreateChannel(ctx, secondSourcePeer.ID)
|
||||
routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID)
|
||||
t.Cleanup(func() {
|
||||
s.updateManager.CloseChannel(ctx, secondSourcePeer.ID)
|
||||
s.updateManager.CloseChannel(ctx, s.routerPeerID)
|
||||
newSrcCh := s.updateManager.CreateChannel(ctx, secondSourcePeer.ID)
|
||||
routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID)
|
||||
t.Cleanup(func() {
|
||||
s.updateManager.CloseChannel(ctx, secondSourcePeer.ID)
|
||||
s.updateManager.CloseChannel(ctx, s.routerPeerID)
|
||||
})
|
||||
|
||||
settleAffectedUpdates(newSrcCh, routerCh)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, newSrcCh)
|
||||
peerShouldReceiveUpdate(t, routerCh)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
policy.Rules[0].Sources = []string{s.sourceGroupID, secondSourceGroupID}
|
||||
_, err = s.manager.SavePolicy(ctx, s.accountID, userID, policy, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout: adding a source group did not refresh the new source peer + routing peer")
|
||||
}
|
||||
})
|
||||
|
||||
settleAffectedUpdates(newSrcCh, routerCh)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, newSrcCh)
|
||||
peerShouldReceiveUpdate(t, routerCh)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
policy.Rules[0].Sources = []string{s.sourceGroupID, secondSourceGroupID}
|
||||
_, err = s.manager.SavePolicy(ctx, s.accountID, userID, policy, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout: adding a source group did not refresh the new source peer + routing peer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAffectedPeers_E2E_DestResource_RouterPeerGroups(t *testing.T) {
|
||||
s := setupRouterScenario(t, false)
|
||||
ctx := context.Background()
|
||||
runPeerUpdateTest(t, func(t *testing.T) {
|
||||
s := setupRouterScenario(t, false)
|
||||
ctx := context.Background()
|
||||
|
||||
srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID)
|
||||
routerCh := s.updateManager.CreateChannel(ctx, s.routerGroupPeerID)
|
||||
t.Cleanup(func() {
|
||||
s.updateManager.CloseChannel(ctx, s.sourcePeerID)
|
||||
s.updateManager.CloseChannel(ctx, s.routerGroupPeerID)
|
||||
srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID)
|
||||
routerCh := s.updateManager.CreateChannel(ctx, s.routerGroupPeerID)
|
||||
t.Cleanup(func() {
|
||||
s.updateManager.CloseChannel(ctx, s.sourcePeerID)
|
||||
s.updateManager.CloseChannel(ctx, s.routerGroupPeerID)
|
||||
})
|
||||
|
||||
settleAffectedUpdates(srcCh, routerCh)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, srcCh)
|
||||
peerShouldReceiveUpdate(t, routerCh)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
_, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByResource(s.sourceGroupID, s.resourceID), true)
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout: DestinationResource policy with PeerGroups router did not refresh the routing peer")
|
||||
}
|
||||
})
|
||||
|
||||
settleAffectedUpdates(srcCh, routerCh)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, srcCh)
|
||||
peerShouldReceiveUpdate(t, routerCh)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
_, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByResource(s.sourceGroupID, s.resourceID), true)
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout: DestinationResource policy with PeerGroups router did not refresh the routing peer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAffectedPeers_AllRoutingPeers_Network(t *testing.T) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,154 +17,152 @@ import (
|
||||
// A user update refreshes only the peers its auto-group change reaches, and a user
|
||||
// update that changes no group membership refreshes nobody.
|
||||
func TestAffectedPeers_SaveUser_OnlyAffectedPeersUpdated(t *testing.T) {
|
||||
manager, updateManager, account, _, peer2, peer3 := setupNetworkMapTest(t)
|
||||
ctx := context.Background()
|
||||
accountID := account.Id
|
||||
runPeerUpdateTest(t, func(t *testing.T) {
|
||||
manager, updateManager, account, _, peer2, peer3 := setupNetworkMapTest(t)
|
||||
ctx := context.Background()
|
||||
accountID := account.Id
|
||||
|
||||
const targetUserID = "target-user"
|
||||
require.NoError(t, manager.Store.SaveUser(ctx, &types.User{
|
||||
Id: targetUserID, AccountID: accountID, Role: types.UserRoleUser,
|
||||
}))
|
||||
|
||||
key, err := wgtypes.GeneratePrivateKey()
|
||||
require.NoError(t, err)
|
||||
targetPeer, _, _, _, err := manager.AddPeer(ctx, accountID, "", targetUserID, &nbpeer.Peer{
|
||||
Key: key.PublicKey().String(),
|
||||
Meta: nbpeer.PeerSystemMeta{Hostname: "target-peer"},
|
||||
}, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
|
||||
require.NoError(t, err)
|
||||
for _, p := range policies {
|
||||
require.NoError(t, manager.Store.DeletePolicy(ctx, accountID, p.ID))
|
||||
}
|
||||
|
||||
account, err = manager.Store.GetAccount(ctx, accountID)
|
||||
require.NoError(t, err)
|
||||
account.Settings.GroupsPropagationEnabled = true
|
||||
require.NoError(t, manager.Store.SaveAccount(ctx, account))
|
||||
|
||||
require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "ug-linked", Name: "ug-linked"}))
|
||||
require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "ug-dest", Name: "ug-dest", Peers: []string{peer2.ID}}))
|
||||
|
||||
_, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{
|
||||
Enabled: true,
|
||||
Rules: []*types.PolicyRule{
|
||||
{
|
||||
Enabled: true,
|
||||
Sources: []string{"ug-linked"},
|
||||
Destinations: []string{"ug-dest"},
|
||||
Bidirectional: true,
|
||||
Action: types.PolicyTrafficActionAccept,
|
||||
},
|
||||
},
|
||||
}, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
updTarget := updateManager.CreateChannel(ctx, targetPeer.ID)
|
||||
upd2 := updateManager.CreateChannel(ctx, peer2.ID)
|
||||
upd3 := updateManager.CreateChannel(ctx, peer3.ID)
|
||||
t.Cleanup(func() {
|
||||
updateManager.CloseChannel(ctx, targetPeer.ID)
|
||||
updateManager.CloseChannel(ctx, peer2.ID)
|
||||
updateManager.CloseChannel(ctx, peer3.ID)
|
||||
})
|
||||
|
||||
t.Run("auto group change updates only linked peers", func(t *testing.T) {
|
||||
drainPeerUpdates(updTarget)
|
||||
drainPeerUpdates(upd2)
|
||||
drainPeerUpdates(upd3)
|
||||
|
||||
_, err := manager.SaveUser(ctx, accountID, activity.SystemInitiator, &types.User{
|
||||
const targetUserID = "target-user"
|
||||
require.NoError(t, manager.Store.SaveUser(ctx, &types.User{
|
||||
Id: targetUserID, AccountID: accountID, Role: types.UserRoleUser,
|
||||
AutoGroups: []string{"ug-linked"},
|
||||
})
|
||||
}))
|
||||
|
||||
key, err := wgtypes.GeneratePrivateKey()
|
||||
require.NoError(t, err)
|
||||
targetPeer, _, _, _, err := manager.AddPeer(ctx, accountID, "", targetUserID, &nbpeer.Peer{
|
||||
Key: key.PublicKey().String(),
|
||||
Meta: nbpeer.PeerSystemMeta{Hostname: "target-peer"},
|
||||
}, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
peerShouldReceiveUpdate(t, updTarget)
|
||||
peerShouldReceiveUpdate(t, upd2)
|
||||
peerShouldNotReceiveUpdate(t, upd3)
|
||||
})
|
||||
|
||||
t.Run("update without group changes refreshes nobody", func(t *testing.T) {
|
||||
drainPeerUpdates(updTarget)
|
||||
drainPeerUpdates(upd2)
|
||||
drainPeerUpdates(upd3)
|
||||
|
||||
_, err := manager.SaveUser(ctx, accountID, activity.SystemInitiator, &types.User{
|
||||
Id: targetUserID, AccountID: accountID, Role: types.UserRoleUser,
|
||||
AutoGroups: []string{"ug-linked"}, Name: "renamed",
|
||||
})
|
||||
policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
|
||||
require.NoError(t, err)
|
||||
for _, p := range policies {
|
||||
require.NoError(t, manager.Store.DeletePolicy(ctx, accountID, p.ID))
|
||||
}
|
||||
|
||||
peerShouldNotReceiveUpdate(t, updTarget)
|
||||
peerShouldNotReceiveUpdate(t, upd2)
|
||||
peerShouldNotReceiveUpdate(t, upd3)
|
||||
|
||||
user, err := manager.Store.GetUserByUserID(ctx, store.LockingStrengthNone, targetUserID)
|
||||
account, err = manager.Store.GetAccount(ctx, accountID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "renamed", user.Name)
|
||||
})
|
||||
|
||||
t.Run("auto group change reassigning IPv6 refreshes the changed peers and their observers", func(t *testing.T) {
|
||||
account, err := manager.Store.GetAccount(ctx, accountID)
|
||||
require.NoError(t, err)
|
||||
account.Settings.IPv6EnabledGroups = []string{"ug-v6"}
|
||||
account.Settings.GroupsPropagationEnabled = true
|
||||
require.NoError(t, manager.Store.SaveAccount(ctx, account))
|
||||
require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "ug-v6", Name: "ug-v6"}))
|
||||
|
||||
drainPeerUpdates(updTarget)
|
||||
drainPeerUpdates(upd2)
|
||||
drainPeerUpdates(upd3)
|
||||
require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "ug-linked", Name: "ug-linked"}))
|
||||
require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "ug-dest", Name: "ug-dest", Peers: []string{peer2.ID}}))
|
||||
|
||||
_, err = manager.SaveUser(ctx, accountID, activity.SystemInitiator, &types.User{
|
||||
Id: targetUserID, AccountID: accountID, Role: types.UserRoleUser,
|
||||
AutoGroups: []string{"ug-linked", "ug-v6"}, Name: "renamed",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// The reassigned peer refreshes with everyone it can reach: peer2 via the
|
||||
// policy, but not peer3, which shares no group or policy with it.
|
||||
peerShouldReceiveUpdate(t, updTarget)
|
||||
peerShouldReceiveUpdate(t, upd2)
|
||||
peerShouldNotReceiveUpdate(t, upd3)
|
||||
})
|
||||
|
||||
t.Run("unblocking a user refreshes only the SSH rule destinations", func(t *testing.T) {
|
||||
// An SSH rule that authorizes no group of its own ships the account's
|
||||
// allowed-user set to its destinations, so those are the peers an unblock
|
||||
// reaches — not the whole account.
|
||||
_, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{
|
||||
_, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{
|
||||
Enabled: true,
|
||||
Rules: []*types.PolicyRule{{
|
||||
Enabled: true,
|
||||
Sources: []string{"ug-linked"},
|
||||
Destinations: []string{"ug-dest"},
|
||||
Protocol: types.PolicyRuleProtocolNetbirdSSH,
|
||||
Action: types.PolicyTrafficActionAccept,
|
||||
}},
|
||||
Rules: []*types.PolicyRule{
|
||||
{
|
||||
Enabled: true,
|
||||
Sources: []string{"ug-linked"},
|
||||
Destinations: []string{"ug-dest"},
|
||||
Bidirectional: true,
|
||||
Action: types.PolicyTrafficActionAccept,
|
||||
},
|
||||
},
|
||||
}, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
blocked, err := manager.Store.GetUserByUserID(ctx, store.LockingStrengthNone, targetUserID)
|
||||
require.NoError(t, err)
|
||||
blocked.Blocked = true
|
||||
require.NoError(t, manager.Store.SaveUser(ctx, blocked))
|
||||
|
||||
drainPeerUpdates(updTarget)
|
||||
drainPeerUpdates(upd2)
|
||||
drainPeerUpdates(upd3)
|
||||
|
||||
// Same auto-groups as the previous subtest left them, so no group change and
|
||||
// no IPv6 reconciliation interferes: the unblock alone drives the refresh.
|
||||
_, err = manager.SaveUser(ctx, accountID, activity.SystemInitiator, &types.User{
|
||||
Id: targetUserID, AccountID: accountID, Role: types.UserRoleUser,
|
||||
AutoGroups: []string{"ug-linked", "ug-v6"}, Name: "renamed",
|
||||
updTarget := updateManager.CreateChannel(ctx, targetPeer.ID)
|
||||
upd2 := updateManager.CreateChannel(ctx, peer2.ID)
|
||||
upd3 := updateManager.CreateChannel(ctx, peer3.ID)
|
||||
t.Cleanup(func() {
|
||||
updateManager.CloseChannel(ctx, targetPeer.ID)
|
||||
updateManager.CloseChannel(ctx, peer2.ID)
|
||||
updateManager.CloseChannel(ctx, peer3.ID)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
peerShouldReceiveUpdate(t, upd2)
|
||||
peerShouldNotReceiveUpdate(t, upd3)
|
||||
step(t, "auto group change updates only linked peers", func(t *testing.T) {
|
||||
settleAffectedUpdates(updTarget, upd2, upd3)
|
||||
|
||||
_, err := manager.SaveUser(ctx, accountID, activity.SystemInitiator, &types.User{
|
||||
Id: targetUserID, AccountID: accountID, Role: types.UserRoleUser,
|
||||
AutoGroups: []string{"ug-linked"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
peerShouldReceiveUpdate(t, updTarget)
|
||||
peerShouldReceiveUpdate(t, upd2)
|
||||
peerShouldNotReceiveUpdate(t, upd3)
|
||||
})
|
||||
|
||||
step(t, "update without group changes refreshes nobody", func(t *testing.T) {
|
||||
drainPeerUpdates(updTarget)
|
||||
drainPeerUpdates(upd2)
|
||||
drainPeerUpdates(upd3)
|
||||
|
||||
_, err := manager.SaveUser(ctx, accountID, activity.SystemInitiator, &types.User{
|
||||
Id: targetUserID, AccountID: accountID, Role: types.UserRoleUser,
|
||||
AutoGroups: []string{"ug-linked"}, Name: "renamed",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
peerShouldNotReceiveUpdate(t, updTarget)
|
||||
peerShouldNotReceiveUpdate(t, upd2)
|
||||
peerShouldNotReceiveUpdate(t, upd3)
|
||||
|
||||
user, err := manager.Store.GetUserByUserID(ctx, store.LockingStrengthNone, targetUserID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "renamed", user.Name)
|
||||
})
|
||||
|
||||
step(t, "auto group change reassigning IPv6 refreshes the changed peers and their observers", func(t *testing.T) {
|
||||
|
||||
require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "ug-v6", Name: "ug-v6"}))// Apply through the settings API so the reconciliation that strips the other
|
||||
// peers' addresses happens here, leaving the target as the only peer the
|
||||
// user update reassigns.
|
||||
updateIPv6TestSettings(t, manager, accountID, func(s *types.Settings) {
|
||||
s.IPv6EnabledGroups = []string{"ug-v6"}
|
||||
})
|
||||
|
||||
settleAffectedUpdates(updTarget, upd2, upd3)
|
||||
|
||||
_, err = manager.SaveUser(ctx, accountID, activity.SystemInitiator, &types.User{
|
||||
Id: targetUserID, AccountID: accountID, Role: types.UserRoleUser,
|
||||
AutoGroups: []string{"ug-linked", "ug-v6"}, Name: "renamed",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// The reassigned peer refreshes with everyone it can reach: peer2 via the
|
||||
// policy, but not peer3, which shares no group or policy with it.
|
||||
peerShouldReceiveUpdate(t, updTarget)
|
||||
peerShouldReceiveUpdate(t, upd2)
|
||||
peerShouldNotReceiveUpdate(t, upd3)
|
||||
})
|
||||
|
||||
step(t, "unblocking a user refreshes only the SSH rule destinations", func(t *testing.T) {
|
||||
// An SSH rule that authorizes no group of its own ships the account's
|
||||
// allowed-user set to its destinations, so those are the peers an unblock
|
||||
// reaches — not the whole account.
|
||||
_, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{
|
||||
Enabled: true,
|
||||
Rules: []*types.PolicyRule{{
|
||||
Enabled: true,
|
||||
Sources: []string{"ug-linked"},
|
||||
Destinations: []string{"ug-dest"},
|
||||
Protocol: types.PolicyRuleProtocolNetbirdSSH,
|
||||
Action: types.PolicyTrafficActionAccept,
|
||||
}},
|
||||
}, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
blocked, err := manager.Store.GetUserByUserID(ctx, store.LockingStrengthNone, targetUserID)
|
||||
require.NoError(t, err)
|
||||
blocked.Blocked = true
|
||||
require.NoError(t, manager.Store.SaveUser(ctx, blocked))
|
||||
|
||||
settleAffectedUpdates(updTarget, upd2, upd3)
|
||||
|
||||
// Same auto-groups as the previous subtest left them, so no group change and
|
||||
// no IPv6 reconciliation interferes: the unblock alone drives the refresh.
|
||||
_, err = manager.SaveUser(ctx, accountID, activity.SystemInitiator, &types.User{
|
||||
Id: targetUserID, AccountID: accountID, Role: types.UserRoleUser,
|
||||
AutoGroups: []string{"ug-linked", "ug-v6"}, Name: "renamed",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
peerShouldReceiveUpdate(t, upd2)
|
||||
peerShouldNotReceiveUpdate(t, upd3)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/zones"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/zones/records"
|
||||
"github.com/netbirdio/netbird/management/server/affectedpeers"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
)
|
||||
|
||||
const affectedZoneDomain = "zone.test"
|
||||
|
||||
// createAffectedZone stores a zone distributed to the given groups, optionally with
|
||||
// one A record so the network map actually ships it.
|
||||
func createAffectedZone(t *testing.T, s store.Store, accountID, domain string, enabled, withRecord bool, groups []string) *zones.Zone {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
zone := zones.NewZone(accountID, domain, domain, enabled, false, groups)
|
||||
require.NoError(t, s.CreateZone(ctx, zone))
|
||||
|
||||
if withRecord {
|
||||
record := records.NewRecord(accountID, zone.ID, "host."+domain, records.RecordTypeA, "10.0.0.1", 300)
|
||||
require.NoError(t, s.CreateDNSRecord(ctx, record))
|
||||
}
|
||||
|
||||
return zone
|
||||
}
|
||||
|
||||
func TestCollectGroupChange_ZoneLinked(t *testing.T) {
|
||||
_, s, accountID, _, groupIDs := setupAffectedPeersTest(t)
|
||||
ctx := context.Background()
|
||||
|
||||
createAffectedZone(t, s, accountID, affectedZoneDomain, true, true, []string{groupIDs[0]})
|
||||
|
||||
groups, _ := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]})
|
||||
assert.Contains(t, groups, groupIDs[0], "group distributed a zone should be affected by its own change")
|
||||
|
||||
groups, _ = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[1]})
|
||||
assert.Empty(t, groups, "group not referenced by any zone should not be affected")
|
||||
}
|
||||
|
||||
func TestCollectGroupChange_UnshippedZoneNotLinked(t *testing.T) {
|
||||
_, s, accountID, _, groupIDs := setupAffectedPeersTest(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Disabled zone and zone without records are never shipped by the network map.
|
||||
createAffectedZone(t, s, accountID, "disabled."+affectedZoneDomain, false, true, []string{groupIDs[0]})
|
||||
createAffectedZone(t, s, accountID, "empty."+affectedZoneDomain, true, false, []string{groupIDs[1]})
|
||||
|
||||
groups, _ := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0], groupIDs[1]})
|
||||
assert.Empty(t, groups, "groups referenced only by unshipped zones should not be affected")
|
||||
}
|
||||
|
||||
func TestResolveAffectedPeers_ZoneGroupMembershipChange(t *testing.T) {
|
||||
_, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
|
||||
|
||||
createAffectedZone(t, s, accountID, affectedZoneDomain, true, true, []string{groupIDs[0]})
|
||||
|
||||
// Same change shape UpdateGroup builds: the group changed as a whole and peer1
|
||||
// left it, so peer1 must refresh to drop the zone.
|
||||
change := affectedpeers.Change{
|
||||
ChangedGroupIDs: []string{groupIDs[0]},
|
||||
RemovedPeersByGroup: map[string][]string{groupIDs[0]: {peerIDs[1]}},
|
||||
}
|
||||
|
||||
result := resolveAffected(t, s, accountID, change)
|
||||
assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1]}, result, "current and removed members of the zone group should be affected")
|
||||
}
|
||||
|
||||
func TestResolveAffectedPeers_ZoneDistributionChange(t *testing.T) {
|
||||
_, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
|
||||
|
||||
// Zone create/update/delete passes old and new distribution groups.
|
||||
change := affectedpeers.Change{DistributionGroupIDs: []string{groupIDs[0], groupIDs[2]}}
|
||||
|
||||
result := resolveAffected(t, s, accountID, change)
|
||||
assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[2]}, result, "only members of the distribution groups should be affected")
|
||||
}
|
||||
|
||||
// TestAffectedPeers_ZoneGroupUpdate_NewMemberReceivesZone verifies that adding a peer
|
||||
// to a group referenced only by a zone pushes the zone to the new member and leaves
|
||||
// unrelated peers alone.
|
||||
func TestAffectedPeers_ZoneGroupUpdate_NewMemberReceivesZone(t *testing.T) {
|
||||
manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t)
|
||||
ctx := context.Background()
|
||||
accountID := account.Id
|
||||
|
||||
policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
|
||||
require.NoError(t, err)
|
||||
for _, p := range policies {
|
||||
require.NoError(t, manager.Store.DeletePolicy(ctx, accountID, p.ID))
|
||||
}
|
||||
|
||||
zoneGroup := &types.Group{ID: "zone-grp", Name: "ZoneGroup", Peers: []string{peer1.ID}}
|
||||
require.NoError(t, manager.CreateGroup(ctx, accountID, userID, zoneGroup))
|
||||
|
||||
createAffectedZone(t, manager.Store, accountID, affectedZoneDomain, true, true, []string{zoneGroup.ID})
|
||||
|
||||
updMsg1 := updateManager.CreateChannel(ctx, peer1.ID)
|
||||
updMsg2 := updateManager.CreateChannel(ctx, peer2.ID)
|
||||
updMsg3 := updateManager.CreateChannel(ctx, peer3.ID)
|
||||
t.Cleanup(func() {
|
||||
updateManager.CloseChannel(ctx, peer1.ID)
|
||||
updateManager.CloseChannel(ctx, peer2.ID)
|
||||
updateManager.CloseChannel(ctx, peer3.ID)
|
||||
})
|
||||
|
||||
zoneGroup.Peers = []string{peer1.ID, peer2.ID}
|
||||
require.NoError(t, manager.UpdateGroup(ctx, accountID, userID, zoneGroup))
|
||||
|
||||
peerShouldReceiveUpdate(t, updMsg1)
|
||||
msg := receivePeerUpdate(t, updMsg2)
|
||||
assert.True(t, syncHasCustomZone(msg, affectedZoneDomain+"."), "new zone group member should receive the zone")
|
||||
peerShouldNotReceiveUpdate(t, updMsg3)
|
||||
}
|
||||
|
||||
func receivePeerUpdate(t *testing.T, ch <-chan *network_map.UpdateMessage) *network_map.UpdateMessage {
|
||||
t.Helper()
|
||||
select {
|
||||
case msg := <-ch:
|
||||
require.NotNil(t, msg, "update message should not be nil")
|
||||
return msg
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
require.FailNow(t, "timed out waiting for update message")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func syncHasCustomZone(msg *network_map.UpdateMessage, domain string) bool {
|
||||
for _, zone := range msg.Update.GetNetworkMap().GetDNSConfig().GetCustomZones() {
|
||||
if zone.GetDomain() == domain {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/zones"
|
||||
resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
|
||||
routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
|
||||
networkTypes "github.com/netbirdio/netbird/management/server/networks/types"
|
||||
@@ -50,6 +51,7 @@ type Snapshot struct {
|
||||
policies []*types.Policy
|
||||
routes []*route.Route
|
||||
nsGroups []*nbdns.NameServerGroup
|
||||
zones []*zones.Zone
|
||||
dnsSettings *types.DNSSettings
|
||||
routers []*routerTypes.NetworkRouter
|
||||
resources []*resourceTypes.NetworkResource
|
||||
@@ -127,12 +129,15 @@ func (snap *Snapshot) loadRoutesAndProxy(ctx context.Context, s store.Store, acc
|
||||
return snap.loadProxyServices(ctx, s, accountID)
|
||||
}
|
||||
|
||||
// loadDNS loads the nameserver groups and account DNS settings.
|
||||
// loadDNS loads the nameserver groups, custom DNS zones and account DNS settings.
|
||||
func (snap *Snapshot) loadDNS(ctx context.Context, s store.Store, accountID string) error {
|
||||
var err error
|
||||
if snap.nsGroups, err = s.GetAccountNameServerGroups(ctx, store.LockingStrengthNone, accountID); err != nil {
|
||||
return err
|
||||
}
|
||||
if snap.zones, err = s.GetAccountZones(ctx, store.LockingStrengthNone, accountID); err != nil {
|
||||
return err
|
||||
}
|
||||
snap.dnsSettings, err = s.GetAccountDNSSettings(ctx, store.LockingStrengthNone, accountID)
|
||||
return err
|
||||
}
|
||||
@@ -262,10 +267,10 @@ func (c Change) isEmpty() bool {
|
||||
}
|
||||
|
||||
// Expand returns the deduplicated affected peer IDs from the preloaded Snapshot,
|
||||
// no store access. Run after the producing tx commits. Logs the full walk at
|
||||
// trace level for diagnosing a miscalculation.
|
||||
// no store access. Run after the producing tx commits. A nil Snapshot expands to
|
||||
// nothing. Logs the full walk at trace level for diagnosing a miscalculation.
|
||||
func (snap *Snapshot) Expand(ctx context.Context, accountID string, c Change) []string {
|
||||
if c.isEmpty() {
|
||||
if snap == nil || c.isEmpty() {
|
||||
return nil
|
||||
}
|
||||
r := newResolver(ctx, snap, accountID, c)
|
||||
@@ -357,7 +362,7 @@ func (s policySide) opposite() policySide {
|
||||
// - a changed router/resource/network sits on a NETWORK -> fold the SOURCE side of
|
||||
// the policies whose destination reaches it (and the routers it implies).
|
||||
//
|
||||
// Routes, nameserver groups, DNS and embedded-proxy services distribute to their own
|
||||
// Routes, nameserver groups, DNS zones, DNS and embedded-proxy services distribute to their own
|
||||
// member peers, outside the policy graph, and are folded here too.
|
||||
func (r *resolver) walk() {
|
||||
for _, policy := range r.bothSidesPolicies() {
|
||||
@@ -369,6 +374,7 @@ func (r *resolver) walk() {
|
||||
r.collectFromPolicies()
|
||||
r.collectFromRoutes()
|
||||
r.collectFromNameServers()
|
||||
r.collectFromZones()
|
||||
r.collectFromDNSSettings()
|
||||
r.collectFromNetworkRouters()
|
||||
r.collectFromProxyServices()
|
||||
@@ -829,6 +835,25 @@ func (r *resolver) collectFromNameServers() {
|
||||
}
|
||||
}
|
||||
|
||||
// collectFromZones folds the distribution groups of the custom DNS zones that
|
||||
// reference a linked group. Like nameserver groups, a zone has no opposite side, so
|
||||
// only a whole-group change folds its groups. Zones the network map does not ship
|
||||
// (disabled or without records) are skipped.
|
||||
func (r *resolver) collectFromZones() {
|
||||
if len(r.linkGroups) == 0 {
|
||||
return
|
||||
}
|
||||
for _, zone := range r.snap.zones {
|
||||
if !zone.Enabled || len(zone.Records) == 0 {
|
||||
continue
|
||||
}
|
||||
if anyInSet(zone.DistributionGroups, r.linkGroups) {
|
||||
log.WithContext(r.ctx).Tracef("collectFromZones: zone %s references a linked group -> folding its groups %v (outputGroups only)", zone.ID, zone.DistributionGroups)
|
||||
r.foldOutputGroups(zone.DistributionGroups)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// collectFromSSHAuthorizedGroups folds the destinations of the enabled SSH rules that
|
||||
// authorize a group whose user membership changed. Those destination peers carry the
|
||||
// group -> user mapping for the groups they authorize, so they refresh even when no
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package affectedpeers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -77,6 +78,11 @@ func TestPolicyGroupsAndPeers_NonPeerResource(t *testing.T) {
|
||||
assert.Empty(t, peers, "domain resource type should not produce direct peer IDs")
|
||||
}
|
||||
|
||||
func TestExpand_NilSnapshot(t *testing.T) {
|
||||
var snap *Snapshot
|
||||
assert.Nil(t, snap.Expand(context.Background(), "acc", Change{ChangedGroupIDs: []string{"g"}}))
|
||||
}
|
||||
|
||||
func TestChangeIsEmpty(t *testing.T) {
|
||||
assert.True(t, Change{}.isEmpty())
|
||||
assert.False(t, Change{ChangedGroupIDs: []string{"g"}}.isEmpty())
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package groups
|
||||
|
||||
import (
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/shared/management/http/apiv1alpha1"
|
||||
)
|
||||
|
||||
func ToGroupsInfoMap(groups []*types.Group, idCount int) map[string][]apiv1alpha1.GroupMinimum {
|
||||
groupsInfoMap := make(map[string][]apiv1alpha1.GroupMinimum, idCount)
|
||||
groupsChecked := make(map[string]struct{}, len(groups)) // not sure why this is needed (left over from old implementation)
|
||||
for _, group := range groups {
|
||||
_, ok := groupsChecked[group.ID]
|
||||
if ok {
|
||||
continue
|
||||
}
|
||||
|
||||
groupsChecked[group.ID] = struct{}{}
|
||||
for _, pk := range group.Peers {
|
||||
info := apiv1alpha1.GroupMinimum{
|
||||
Id: group.ID,
|
||||
Name: group.Name,
|
||||
PeersCount: len(group.Peers),
|
||||
ResourcesCount: len(group.Resources),
|
||||
}
|
||||
groupsInfoMap[pk] = append(groupsInfoMap[pk], info)
|
||||
}
|
||||
for _, rk := range group.Resources {
|
||||
info := apiv1alpha1.GroupMinimum{
|
||||
Id: group.ID,
|
||||
Name: group.Name,
|
||||
PeersCount: len(group.Peers),
|
||||
ResourcesCount: len(group.Resources),
|
||||
}
|
||||
groupsInfoMap[rk.ID] = append(groupsInfoMap[rk.ID], info)
|
||||
}
|
||||
}
|
||||
return groupsInfoMap
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package v1alpha1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map"
|
||||
"github.com/netbirdio/netbird/management/server/account"
|
||||
"github.com/netbirdio/netbird/shared/management/http/apiv1alpha1"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/permissions"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/api/v1alpha1/peers"
|
||||
"github.com/netbirdio/netbird/management/server/api/v1alpha1/users"
|
||||
)
|
||||
|
||||
func NewAPIV1Handler(
|
||||
ctx context.Context,
|
||||
router *mux.Router,
|
||||
accountManager account.Manager,
|
||||
networkMapController network_map.Controller,
|
||||
permissionsManager permissions.Manager) (http.Handler, error) {
|
||||
|
||||
v1validatorMiddleware, err := apiv1alpha1.CreateV1ApiValidatingMiddleware()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
router.Use(v1validatorMiddleware.Handler)
|
||||
|
||||
peersHandler := peers.NewHandler(accountManager, networkMapController, permissionsManager)
|
||||
_ = peersHandler.WithEndpointsForRouter(router)
|
||||
|
||||
usersHandler := users.NewHandler(accountManager)
|
||||
return usersHandler.WithEndpointsForRouter(router), nil
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
package peers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map"
|
||||
"github.com/netbirdio/netbird/management/server/account"
|
||||
"github.com/netbirdio/netbird/management/server/activity"
|
||||
"github.com/netbirdio/netbird/management/server/api/v1alpha1/groups"
|
||||
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||
"github.com/netbirdio/netbird/management/server/permissions"
|
||||
"github.com/netbirdio/netbird/shared/management/http/apiv1alpha1"
|
||||
"github.com/netbirdio/netbird/shared/management/http/util"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
accountManager account.Manager
|
||||
permissionsManager permissions.Manager
|
||||
networkMapController network_map.Controller
|
||||
}
|
||||
|
||||
// NewHandler creates a new peers Handler
|
||||
func NewHandler(accountManager account.Manager, networkMapController network_map.Controller, permissionsManager permissions.Manager) *Handler {
|
||||
return &Handler{
|
||||
accountManager: accountManager,
|
||||
networkMapController: networkMapController,
|
||||
permissionsManager: permissionsManager,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) WithEndpointsForRouter(router *mux.Router) *mux.Router {
|
||||
router.HandleFunc("/peers", h.GetAllPeers).Methods("GET", "OPTIONS")
|
||||
router.HandleFunc("/peers/{peerId}", h.HandlePeer).Methods("GET", "PUT", "DELETE", "OPTIONS")
|
||||
return router
|
||||
}
|
||||
|
||||
func (h *Handler) getPeer(ctx context.Context, accountID, peerID, userID string, w http.ResponseWriter) {
|
||||
peer, err := h.accountManager.GetPeer(ctx, accountID, peerID, userID)
|
||||
if err != nil {
|
||||
util.WriteError(ctx, err, w)
|
||||
return
|
||||
}
|
||||
|
||||
if peer.ProxyMeta.Embedded {
|
||||
util.WriteError(ctx, status.Errorf(status.InvalidArgument, "not allowed to read peer"), w)
|
||||
return
|
||||
}
|
||||
|
||||
settings, err := h.accountManager.GetAccountSettings(ctx, accountID, activity.SystemInitiator)
|
||||
if err != nil {
|
||||
util.WriteError(ctx, err, w)
|
||||
return
|
||||
}
|
||||
|
||||
dnsDomain := h.networkMapController.GetDNSDomain(settings)
|
||||
|
||||
grps, _ := h.accountManager.GetPeerGroups(ctx, accountID, peerID)
|
||||
grpsInfoMap := groups.ToGroupsInfoMap(grps, 0)
|
||||
|
||||
validPeers, invalidPeers, err := h.accountManager.GetValidatedPeers(ctx, accountID)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to list approved peers: %v", err)
|
||||
util.WriteError(ctx, fmt.Errorf("internal error"), w)
|
||||
return
|
||||
}
|
||||
|
||||
_, valid := validPeers[peer.ID]
|
||||
reason := invalidPeers[peer.ID]
|
||||
|
||||
util.WriteJSONObject(ctx, w, toSinglePeerResponse(peer, grpsInfoMap[peerID], dnsDomain, valid, reason))
|
||||
}
|
||||
|
||||
func (h *Handler) updatePeer(ctx context.Context, accountID, userID, peerID string, w http.ResponseWriter, r *http.Request) {
|
||||
req := &apiv1alpha1.PeerRequest{}
|
||||
err := json.NewDecoder(r.Body).Decode(req)
|
||||
if err != nil {
|
||||
util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w)
|
||||
return
|
||||
}
|
||||
|
||||
update := &nbpeer.Peer{
|
||||
ID: peerID,
|
||||
SSHEnabled: req.SshEnabled,
|
||||
Name: req.Name,
|
||||
LoginExpirationEnabled: req.LoginExpirationEnabled,
|
||||
InactivityExpirationEnabled: req.InactivityExpirationEnabled,
|
||||
}
|
||||
|
||||
if req.ApprovalRequired != nil {
|
||||
// todo: looks like that we reset all status property, is it right?
|
||||
update.Status = &nbpeer.PeerStatus{
|
||||
RequiresApproval: *req.ApprovalRequired,
|
||||
}
|
||||
}
|
||||
|
||||
if req.Ip != nil {
|
||||
addr, err := netip.ParseAddr(*req.Ip)
|
||||
if err != nil {
|
||||
util.WriteError(ctx, status.Errorf(status.InvalidArgument, "invalid IP address %s: %v", *req.Ip, err), w)
|
||||
return
|
||||
}
|
||||
|
||||
if err = h.accountManager.UpdatePeerIP(ctx, accountID, userID, peerID, addr); err != nil {
|
||||
util.WriteError(ctx, err, w)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if req.Ipv6 != nil {
|
||||
v6Addr, err := parseIPv6(req.Ipv6)
|
||||
if err != nil {
|
||||
util.WriteError(ctx, status.Errorf(status.InvalidArgument, "%v", err), w)
|
||||
return
|
||||
}
|
||||
if err = h.accountManager.UpdatePeerIPv6(ctx, accountID, userID, peerID, v6Addr); err != nil {
|
||||
util.WriteError(ctx, err, w)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
peer, err := h.accountManager.UpdatePeer(ctx, accountID, userID, update)
|
||||
if err != nil {
|
||||
util.WriteError(ctx, err, w)
|
||||
return
|
||||
}
|
||||
|
||||
settings, err := h.accountManager.GetAccountSettings(ctx, accountID, activity.SystemInitiator)
|
||||
if err != nil {
|
||||
util.WriteError(ctx, err, w)
|
||||
return
|
||||
}
|
||||
dnsDomain := h.networkMapController.GetDNSDomain(settings)
|
||||
|
||||
peerGroups, err := h.accountManager.GetPeerGroups(ctx, accountID, peer.ID)
|
||||
if err != nil {
|
||||
util.WriteError(ctx, err, w)
|
||||
return
|
||||
}
|
||||
|
||||
grpsInfoMap := groups.ToGroupsInfoMap(peerGroups, 0)
|
||||
|
||||
validPeers, invalidPeers, err := h.accountManager.GetValidatedPeers(ctx, accountID)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to get validated peers: %v", err)
|
||||
util.WriteError(ctx, fmt.Errorf("internal error"), w)
|
||||
return
|
||||
}
|
||||
|
||||
_, valid := validPeers[peer.ID]
|
||||
reason := invalidPeers[peer.ID]
|
||||
|
||||
util.WriteJSONObject(r.Context(), w, toSinglePeerResponse(peer, grpsInfoMap[peerID], dnsDomain, valid, reason))
|
||||
}
|
||||
|
||||
func (h *Handler) deletePeer(ctx context.Context, accountID, userID string, peerID string, w http.ResponseWriter) {
|
||||
err := h.accountManager.DeletePeer(ctx, accountID, peerID, userID)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to delete peer: %v", err)
|
||||
util.WriteError(ctx, err, w)
|
||||
return
|
||||
}
|
||||
util.WriteJSONObject(ctx, w, util.EmptyObject{})
|
||||
}
|
||||
|
||||
// HandlePeer handles all peer requests for GET, PUT and DELETE operations
|
||||
func (h *Handler) HandlePeer(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
accountID, userID := userAuth.AccountId, userAuth.UserId
|
||||
vars := mux.Vars(r)
|
||||
peerID := vars["peerId"]
|
||||
if len(peerID) == 0 {
|
||||
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "invalid peer ID"), w)
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodDelete:
|
||||
h.deletePeer(r.Context(), accountID, userID, peerID, w)
|
||||
return
|
||||
case http.MethodGet:
|
||||
h.getPeer(r.Context(), accountID, peerID, userID, w)
|
||||
return
|
||||
case http.MethodPut:
|
||||
h.updatePeer(r.Context(), accountID, userID, peerID, w, r)
|
||||
return
|
||||
default:
|
||||
util.WriteError(r.Context(), status.Errorf(status.NotFound, "unknown METHOD"), w)
|
||||
}
|
||||
}
|
||||
|
||||
// GetAllPeers returns a list of all peers associated with a provided account
|
||||
func (h *Handler) GetAllPeers(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
nameFilter := r.URL.Query().Get("name")
|
||||
ipFilter := r.URL.Query().Get("ip")
|
||||
macFilter := r.URL.Query().Get("mac")
|
||||
|
||||
accountID, userID := userAuth.AccountId, userAuth.UserId
|
||||
|
||||
peers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, nameFilter, ipFilter, macFilter)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
settings, err := h.accountManager.GetAccountSettings(r.Context(), accountID, activity.SystemInitiator)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
dnsDomain := h.networkMapController.GetDNSDomain(settings)
|
||||
|
||||
grps, _ := h.accountManager.GetAllGroups(r.Context(), accountID, userID)
|
||||
|
||||
grpsInfoMap := groups.ToGroupsInfoMap(grps, len(peers))
|
||||
respBody := make([]*apiv1alpha1.PeerBatch, 0, len(peers))
|
||||
for _, peer := range peers {
|
||||
if peer.ProxyMeta.Embedded {
|
||||
continue
|
||||
}
|
||||
respBody = append(respBody, toPeerListItemResponse(peer, grpsInfoMap[peer.ID], dnsDomain, 0))
|
||||
}
|
||||
|
||||
validPeersMap, invalidPeersMap, err := h.accountManager.GetValidatedPeers(r.Context(), accountID)
|
||||
if err != nil {
|
||||
log.WithContext(r.Context()).Errorf("failed to get validated peers: %v", err)
|
||||
util.WriteError(r.Context(), fmt.Errorf("internal error"), w)
|
||||
return
|
||||
}
|
||||
h.setApprovalRequiredFlag(respBody, validPeersMap, invalidPeersMap)
|
||||
|
||||
util.WriteJSONObject(r.Context(), w, respBody)
|
||||
}
|
||||
|
||||
func (h *Handler) setApprovalRequiredFlag(respBody []*apiv1alpha1.PeerBatch, validPeersMap map[string]struct{}, invalidPeersMap map[string]string) {
|
||||
for _, peer := range respBody {
|
||||
_, ok := validPeersMap[peer.Id]
|
||||
if !ok {
|
||||
peer.ApprovalRequired = true
|
||||
|
||||
reason := invalidPeersMap[peer.Id]
|
||||
peer.DisapprovalReason = &reason
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseIPv6(s *string) (netip.Addr, error) {
|
||||
if s == nil {
|
||||
return netip.Addr{}, fmt.Errorf("IPv6 address is nil")
|
||||
}
|
||||
addr, err := netip.ParseAddr(*s)
|
||||
if err != nil {
|
||||
return netip.Addr{}, fmt.Errorf("invalid IPv6 address %s: %w", *s, err)
|
||||
}
|
||||
addr = addr.Unmap()
|
||||
if !addr.Is6() {
|
||||
return netip.Addr{}, fmt.Errorf("address %s is not IPv6", *s)
|
||||
}
|
||||
return addr, nil
|
||||
}
|
||||
|
||||
func toSinglePeerResponse(peer *nbpeer.Peer, groupsInfo []apiv1alpha1.GroupMinimum, dnsDomain string, approved bool, reason string) *apiv1alpha1.Peer {
|
||||
osVersion := peer.Meta.OSVersion
|
||||
if osVersion == "" {
|
||||
osVersion = peer.Meta.Core
|
||||
}
|
||||
|
||||
apiPeer := &apiv1alpha1.Peer{
|
||||
PeerMinimum: apiv1alpha1.PeerMinimum{
|
||||
Id: peer.ID,
|
||||
Name: peer.Name,
|
||||
},
|
||||
CreatedAt: peer.CreatedAt,
|
||||
Ip: peer.IP.String(),
|
||||
Ipv6: peerIPv6String(peer),
|
||||
ConnectionIp: peer.Location.ConnectionIP.String(),
|
||||
Connected: peer.Status.Connected,
|
||||
LastSeen: peer.Status.LastSeen,
|
||||
Os: fmt.Sprintf("%s %s", peer.Meta.OS, osVersion),
|
||||
KernelVersion: peer.Meta.KernelVersion,
|
||||
GeonameId: int(peer.Location.GeoNameID),
|
||||
Version: peer.Meta.WtVersion,
|
||||
Groups: groupsInfo,
|
||||
SshEnabled: peer.SSHEnabled,
|
||||
Hostname: peer.Meta.Hostname,
|
||||
UserId: peer.UserID,
|
||||
UiVersion: peer.Meta.UIVersion,
|
||||
DnsLabel: fqdn(peer, dnsDomain),
|
||||
ExtraDnsLabels: fqdnList(peer.ExtraDNSLabels, dnsDomain),
|
||||
LoginExpirationEnabled: peer.LoginExpirationEnabled,
|
||||
LastLogin: peer.GetLastLogin(),
|
||||
LoginExpired: peer.Status.LoginExpired,
|
||||
ApprovalRequired: !approved,
|
||||
CountryCode: apiv1alpha1.CountryCode(peer.Location.CountryCode),
|
||||
CityName: apiv1alpha1.CityName(peer.Location.CityName),
|
||||
SerialNumber: peer.Meta.SystemSerialNumber,
|
||||
InactivityExpirationEnabled: peer.InactivityExpirationEnabled,
|
||||
Ephemeral: peer.Ephemeral,
|
||||
LocalFlags: &apiv1alpha1.PeerLocalFlags{
|
||||
BlockInbound: &peer.Meta.Flags.BlockInbound,
|
||||
BlockLanAccess: &peer.Meta.Flags.BlockLANAccess,
|
||||
DisableClientRoutes: &peer.Meta.Flags.DisableClientRoutes,
|
||||
DisableDns: &peer.Meta.Flags.DisableDNS,
|
||||
DisableFirewall: &peer.Meta.Flags.DisableFirewall,
|
||||
DisableServerRoutes: &peer.Meta.Flags.DisableServerRoutes,
|
||||
LazyConnectionEnabled: &peer.Meta.Flags.LazyConnectionEnabled,
|
||||
RosenpassEnabled: &peer.Meta.Flags.RosenpassEnabled,
|
||||
RosenpassPermissive: &peer.Meta.Flags.RosenpassPermissive,
|
||||
ServerSshAllowed: &peer.Meta.Flags.ServerSSHAllowed,
|
||||
RemoteJobsAllowed: &peer.Meta.Flags.RemoteJobsAllowed,
|
||||
},
|
||||
}
|
||||
|
||||
if !approved {
|
||||
apiPeer.DisapprovalReason = &reason
|
||||
}
|
||||
|
||||
return apiPeer
|
||||
}
|
||||
|
||||
func toPeerListItemResponse(peer *nbpeer.Peer, groupsInfo []apiv1alpha1.GroupMinimum, dnsDomain string, accessiblePeersCount int) *apiv1alpha1.PeerBatch {
|
||||
osVersion := peer.Meta.OSVersion
|
||||
if osVersion == "" {
|
||||
osVersion = peer.Meta.Core
|
||||
}
|
||||
|
||||
return &apiv1alpha1.PeerBatch{
|
||||
CreatedAt: peer.CreatedAt,
|
||||
AccessiblePeersCount: accessiblePeersCount,
|
||||
Peer: apiv1alpha1.Peer{
|
||||
PeerMinimum: apiv1alpha1.PeerMinimum{
|
||||
Id: peer.ID,
|
||||
Name: peer.Name,
|
||||
},
|
||||
Ip: peer.IP.String(),
|
||||
Ipv6: peerIPv6String(peer),
|
||||
ConnectionIp: peer.Location.ConnectionIP.String(),
|
||||
Connected: peer.Status.Connected,
|
||||
LastSeen: peer.Status.LastSeen,
|
||||
Os: fmt.Sprintf("%s %s", peer.Meta.OS, osVersion),
|
||||
KernelVersion: peer.Meta.KernelVersion,
|
||||
GeonameId: int(peer.Location.GeoNameID),
|
||||
Version: peer.Meta.WtVersion,
|
||||
Groups: groupsInfo,
|
||||
SshEnabled: peer.SSHEnabled,
|
||||
Hostname: peer.Meta.Hostname,
|
||||
UserId: peer.UserID,
|
||||
UiVersion: peer.Meta.UIVersion,
|
||||
DnsLabel: fqdn(peer, dnsDomain),
|
||||
ExtraDnsLabels: fqdnList(peer.ExtraDNSLabels, dnsDomain),
|
||||
LoginExpirationEnabled: peer.LoginExpirationEnabled,
|
||||
LastLogin: peer.GetLastLogin(),
|
||||
LoginExpired: peer.Status.LoginExpired,
|
||||
CountryCode: apiv1alpha1.CountryCode(peer.Location.CountryCode),
|
||||
CityName: apiv1alpha1.CityName(peer.Location.CityName),
|
||||
SerialNumber: peer.Meta.SystemSerialNumber,
|
||||
InactivityExpirationEnabled: peer.InactivityExpirationEnabled,
|
||||
Ephemeral: peer.Ephemeral,
|
||||
LocalFlags: &apiv1alpha1.PeerLocalFlags{
|
||||
BlockInbound: &peer.Meta.Flags.BlockInbound,
|
||||
BlockLanAccess: &peer.Meta.Flags.BlockLANAccess,
|
||||
DisableClientRoutes: &peer.Meta.Flags.DisableClientRoutes,
|
||||
DisableDns: &peer.Meta.Flags.DisableDNS,
|
||||
DisableFirewall: &peer.Meta.Flags.DisableFirewall,
|
||||
DisableServerRoutes: &peer.Meta.Flags.DisableServerRoutes,
|
||||
LazyConnectionEnabled: &peer.Meta.Flags.LazyConnectionEnabled,
|
||||
RosenpassEnabled: &peer.Meta.Flags.RosenpassEnabled,
|
||||
RosenpassPermissive: &peer.Meta.Flags.RosenpassPermissive,
|
||||
ServerSshAllowed: &peer.Meta.Flags.ServerSSHAllowed,
|
||||
RemoteJobsAllowed: &peer.Meta.Flags.RemoteJobsAllowed,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func fqdn(peer *nbpeer.Peer, dnsDomain string) string {
|
||||
fqdn := peer.FQDN(dnsDomain)
|
||||
if fqdn == "" {
|
||||
return peer.DNSLabel
|
||||
} else {
|
||||
return fqdn
|
||||
}
|
||||
}
|
||||
func fqdnList(extraLabels []string, dnsDomain string) []string {
|
||||
fqdnList := make([]string, 0, len(extraLabels))
|
||||
for _, label := range extraLabels {
|
||||
fqdn := fmt.Sprintf("%s.%s", label, dnsDomain)
|
||||
fqdnList = append(fqdnList, fqdn)
|
||||
}
|
||||
return fqdnList
|
||||
}
|
||||
|
||||
func peerIPv6String(peer *nbpeer.Peer) *string {
|
||||
if !peer.IPv6.IsValid() {
|
||||
return nil
|
||||
}
|
||||
s := peer.IPv6.String()
|
||||
return &s
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
package peers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"go.uber.org/mock/gomock"
|
||||
"golang.org/x/exp/maps"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map"
|
||||
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||
"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/types"
|
||||
"github.com/netbirdio/netbird/shared/auth"
|
||||
"github.com/netbirdio/netbird/shared/management/http/apiv1alpha1"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/mock_server"
|
||||
)
|
||||
|
||||
const (
|
||||
testPeerID = "test_peer"
|
||||
noUpdateChannelTestPeerID = "no-update-channel"
|
||||
|
||||
adminUser = "admin_user"
|
||||
regularUser = "regular_user"
|
||||
serviceUser = "service_user"
|
||||
)
|
||||
|
||||
func initTestMetaData(t *testing.T, peers ...*nbpeer.Peer) *Handler {
|
||||
|
||||
peersMap := make(map[string]*nbpeer.Peer)
|
||||
for _, peer := range peers {
|
||||
peersMap[peer.ID] = peer.Copy()
|
||||
}
|
||||
|
||||
policy := &types.Policy{
|
||||
ID: "policy",
|
||||
AccountID: "test_id",
|
||||
Name: "policy",
|
||||
Enabled: true,
|
||||
Rules: []*types.PolicyRule{
|
||||
{
|
||||
ID: "rule",
|
||||
Name: "rule",
|
||||
Enabled: true,
|
||||
Action: "accept",
|
||||
Destinations: []string{"group1"},
|
||||
Sources: []string{"group1"},
|
||||
Bidirectional: true,
|
||||
Protocol: "all",
|
||||
Ports: []string{"80"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
srvUser := types.NewRegularUser(serviceUser, "", "")
|
||||
srvUser.IsServiceUser = true
|
||||
|
||||
account := &types.Account{
|
||||
Id: "test_id",
|
||||
Domain: "hotmail.com",
|
||||
Peers: peersMap,
|
||||
Users: map[string]*types.User{
|
||||
adminUser: types.NewAdminUser(adminUser),
|
||||
regularUser: types.NewRegularUser(regularUser, "", ""),
|
||||
serviceUser: srvUser,
|
||||
},
|
||||
Groups: map[string]*types.Group{
|
||||
"group1": {
|
||||
ID: "group1",
|
||||
AccountID: "test_id",
|
||||
Name: "group1",
|
||||
Issued: "api",
|
||||
Peers: maps.Keys(peersMap),
|
||||
},
|
||||
},
|
||||
Settings: &types.Settings{
|
||||
PeerLoginExpirationEnabled: true,
|
||||
PeerLoginExpiration: time.Hour,
|
||||
},
|
||||
Policies: []*types.Policy{policy},
|
||||
Network: &types.Network{
|
||||
Identifier: "ciclqisab2ss43jdn8q0",
|
||||
Net: net.IPNet{
|
||||
IP: net.ParseIP("100.67.0.0"),
|
||||
Mask: net.IPv4Mask(255, 255, 0, 0),
|
||||
},
|
||||
Serial: 51,
|
||||
},
|
||||
}
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
|
||||
networkMapController := network_map.NewMockController(ctrl)
|
||||
networkMapController.EXPECT().
|
||||
GetDNSDomain(gomock.Any()).
|
||||
Return("domain").
|
||||
AnyTimes()
|
||||
|
||||
ctrl2 := gomock.NewController(t)
|
||||
permissionsManager := permissions.NewMockManager(ctrl2)
|
||||
permissionsManager.EXPECT().ValidateAccountAccess(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(context.Background(), nil).AnyTimes()
|
||||
permissionsManager.EXPECT().
|
||||
ValidateUserPermissions(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Eq(modules.Peers), gomock.Eq(operations.Read)).
|
||||
DoAndReturn(func(ctx context.Context, accountID, userID string, module modules.Module, operation operations.Operation) (bool, context.Context, error) {
|
||||
user, ok := account.Users[userID]
|
||||
if !ok {
|
||||
return false, ctx, fmt.Errorf("user not found")
|
||||
}
|
||||
return user.HasAdminPower() || user.IsServiceUser, ctx, nil
|
||||
}).
|
||||
AnyTimes()
|
||||
|
||||
return &Handler{
|
||||
accountManager: &mock_server.MockAccountManager{
|
||||
UpdatePeerFunc: func(_ context.Context, accountID, userID string, update *nbpeer.Peer) (*nbpeer.Peer, error) {
|
||||
var p *nbpeer.Peer
|
||||
for _, peer := range peers {
|
||||
if update.ID == peer.ID {
|
||||
p = peer.Copy()
|
||||
break
|
||||
}
|
||||
}
|
||||
p.SSHEnabled = update.SSHEnabled
|
||||
p.LoginExpirationEnabled = update.LoginExpirationEnabled
|
||||
p.Name = update.Name
|
||||
return p, nil
|
||||
},
|
||||
UpdatePeerIPFunc: func(_ context.Context, accountID, userID, peerID string, newIP netip.Addr) error {
|
||||
for _, peer := range peers {
|
||||
if peer.ID == peerID {
|
||||
peer.IP = newIP
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("peer not found")
|
||||
},
|
||||
GetPeerFunc: func(_ context.Context, accountID, peerID, userID string) (*nbpeer.Peer, error) {
|
||||
var p *nbpeer.Peer
|
||||
for _, peer := range peers {
|
||||
if peerID == peer.ID {
|
||||
p = peer.Copy()
|
||||
break
|
||||
}
|
||||
}
|
||||
return p, nil
|
||||
},
|
||||
GetUserByIDFunc: func(ctx context.Context, id string) (*types.User, error) {
|
||||
switch id {
|
||||
case adminUser:
|
||||
return account.Users[adminUser], nil
|
||||
case regularUser:
|
||||
return account.Users[regularUser], nil
|
||||
case serviceUser:
|
||||
return account.Users[serviceUser], nil
|
||||
default:
|
||||
return nil, fmt.Errorf("user not found")
|
||||
}
|
||||
},
|
||||
GetPeersFunc: func(_ context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) {
|
||||
return peers, nil
|
||||
},
|
||||
GetPeerGroupsFunc: func(ctx context.Context, accountID, peerID string) ([]*types.Group, error) {
|
||||
peersID := make([]string, len(peers))
|
||||
for _, peer := range peers {
|
||||
peersID = append(peersID, peer.ID)
|
||||
}
|
||||
return []*types.Group{
|
||||
{
|
||||
ID: "group1",
|
||||
AccountID: accountID,
|
||||
Name: "group1",
|
||||
Issued: "api",
|
||||
Peers: peersID,
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
GetDNSDomainFunc: func(settings *types.Settings) string {
|
||||
return "netbird.selfhosted"
|
||||
},
|
||||
GetAccountFunc: func(ctx context.Context, accountID string) (*types.Account, error) {
|
||||
return account, nil
|
||||
},
|
||||
GetAccountByIDFunc: func(ctx context.Context, accountID string, userID string) (*types.Account, error) {
|
||||
return account, nil
|
||||
},
|
||||
HasConnectedChannelFunc: func(peerID string) bool {
|
||||
statuses := make(map[string]struct{})
|
||||
for _, peer := range peers {
|
||||
if peer.ID == noUpdateChannelTestPeerID {
|
||||
break
|
||||
}
|
||||
statuses[peer.ID] = struct{}{}
|
||||
}
|
||||
_, ok := statuses[peerID]
|
||||
return ok
|
||||
},
|
||||
GetAccountSettingsFunc: func(ctx context.Context, accountID string, userID string) (*types.Settings, error) {
|
||||
return account.Settings, nil
|
||||
},
|
||||
},
|
||||
networkMapController: networkMapController,
|
||||
permissionsManager: permissionsManager,
|
||||
}
|
||||
}
|
||||
|
||||
// Tests the GetAllPeers endpoint reachable in the route /api/peers
|
||||
// Use the metadata generated by initTestMetaData() to check for values
|
||||
func TestGetPeers(t *testing.T) {
|
||||
|
||||
peer := &nbpeer.Peer{
|
||||
ID: testPeerID,
|
||||
Key: "key",
|
||||
IP: netip.MustParseAddr("100.64.0.1"),
|
||||
IPv6: netip.MustParseAddr("fd00::1"),
|
||||
Status: &nbpeer.PeerStatus{Connected: true},
|
||||
Name: "PeerName",
|
||||
LoginExpirationEnabled: false,
|
||||
Meta: nbpeer.PeerSystemMeta{
|
||||
Hostname: "hostname",
|
||||
GoOS: "GoOS",
|
||||
Kernel: "kernel",
|
||||
Core: "core",
|
||||
Platform: "platform",
|
||||
OS: "OS",
|
||||
WtVersion: "development",
|
||||
SystemSerialNumber: "C02XJ0J0JGH7",
|
||||
},
|
||||
}
|
||||
|
||||
peer1 := peer.Copy()
|
||||
peer1.ID = noUpdateChannelTestPeerID
|
||||
|
||||
expectedUpdatedPeer := peer.Copy()
|
||||
expectedUpdatedPeer.LoginExpirationEnabled = true
|
||||
expectedUpdatedPeer.SSHEnabled = true
|
||||
expectedUpdatedPeer.Name = "New Name"
|
||||
|
||||
expectedPeer1 := peer1.Copy()
|
||||
expectedPeer1.Status.Connected = false
|
||||
|
||||
tt := []struct {
|
||||
name string
|
||||
expectedStatus int
|
||||
requestType string
|
||||
requestPath string
|
||||
contentType string
|
||||
requestBody io.Reader
|
||||
expectedArray bool
|
||||
expectedPeer *nbpeer.Peer
|
||||
}{
|
||||
{
|
||||
name: "GetPeersMetaData",
|
||||
requestType: http.MethodGet,
|
||||
requestPath: "/peers",
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedArray: true,
|
||||
expectedPeer: peer,
|
||||
},
|
||||
{
|
||||
name: "GetPeer with update channel",
|
||||
requestType: http.MethodGet,
|
||||
requestPath: "/peers/" + testPeerID,
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedArray: false,
|
||||
expectedPeer: peer,
|
||||
},
|
||||
{
|
||||
name: "PutPeer",
|
||||
requestType: http.MethodPut,
|
||||
requestPath: "/peers/" + testPeerID,
|
||||
contentType: "application/json",
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedArray: false,
|
||||
requestBody: bytes.NewBufferString("{\"login_expiration_enabled\":true,\"name\":\"New Name\",\"ssh_enabled\":true, \"inactivity_expiration_enabled\":true}"),
|
||||
expectedPeer: expectedUpdatedPeer,
|
||||
},
|
||||
}
|
||||
|
||||
p := initTestMetaData(t, peer, peer1)
|
||||
|
||||
for _, tc := range tt {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(tc.requestType, tc.requestPath, tc.requestBody)
|
||||
req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{
|
||||
UserId: "admin_user",
|
||||
Domain: "hotmail.com",
|
||||
AccountId: "test_id",
|
||||
})
|
||||
if tc.contentType != "" {
|
||||
req.Header.Set("Content-Type", tc.contentType)
|
||||
}
|
||||
|
||||
v1validator, err := apiv1alpha1.CreateV1ApiValidatingMiddleware()
|
||||
assert.NoError(t, err)
|
||||
router := mux.NewRouter()
|
||||
router.Use(v1validator.Handler)
|
||||
|
||||
router = p.WithEndpointsForRouter(router)
|
||||
router.ServeHTTP(recorder, req)
|
||||
|
||||
res := recorder.Result()
|
||||
defer res.Body.Close()
|
||||
|
||||
if status := recorder.Code; status != tc.expectedStatus {
|
||||
t.Fatalf("handler returned wrong status code: got %v want %v",
|
||||
status, http.StatusOK)
|
||||
}
|
||||
|
||||
content, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("I don't know what I expected; %v", err)
|
||||
}
|
||||
|
||||
var got *apiv1alpha1.Peer
|
||||
if tc.expectedArray {
|
||||
respBody := []*apiv1alpha1.Peer{}
|
||||
err = json.Unmarshal(content, &respBody)
|
||||
if err != nil {
|
||||
t.Fatalf("Sent content is not in correct json format; %v", err)
|
||||
}
|
||||
|
||||
// hardcode this check for now as we only have two peers in this suite
|
||||
assert.Equal(t, len(respBody), 2)
|
||||
|
||||
for _, peer := range respBody {
|
||||
if peer.Id == testPeerID {
|
||||
got = peer
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
got = &apiv1alpha1.Peer{}
|
||||
err = json.Unmarshal(content, got)
|
||||
if err != nil {
|
||||
t.Fatalf("Sent content is not in correct json format; %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
t.Log(got)
|
||||
|
||||
assert.Equal(t, tc.expectedPeer.Name, got.Name)
|
||||
assert.Equal(t, tc.expectedPeer.Meta.WtVersion, got.Version)
|
||||
assert.Equal(t, tc.expectedPeer.IP.String(), got.Ip)
|
||||
assert.Equal(t, "OS core", got.Os)
|
||||
assert.Equal(t, tc.expectedPeer.LoginExpirationEnabled, got.LoginExpirationEnabled)
|
||||
assert.Equal(t, tc.expectedPeer.SSHEnabled, got.SshEnabled)
|
||||
assert.Equal(t, tc.expectedPeer.Status.Connected, got.Connected)
|
||||
assert.Equal(t, tc.expectedPeer.Meta.SystemSerialNumber, got.SerialNumber)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeersHandlerUpdatePeerIP(t *testing.T) {
|
||||
testPeer := &nbpeer.Peer{
|
||||
ID: testPeerID,
|
||||
Key: "key",
|
||||
IP: netip.MustParseAddr("100.64.0.1"),
|
||||
IPv6: netip.MustParseAddr("fd00::1"),
|
||||
Status: &nbpeer.PeerStatus{Connected: false, LastSeen: time.Now()},
|
||||
Name: "test-host@netbird.io",
|
||||
LoginExpirationEnabled: false,
|
||||
UserID: regularUser,
|
||||
Meta: nbpeer.PeerSystemMeta{
|
||||
Hostname: "test-host@netbird.io",
|
||||
Core: "22.04",
|
||||
},
|
||||
}
|
||||
|
||||
p := initTestMetaData(t, testPeer)
|
||||
|
||||
tt := []struct {
|
||||
name string
|
||||
peerID string
|
||||
requestBody string
|
||||
callerUserID string
|
||||
expectedStatus int
|
||||
expectedIP string
|
||||
}{
|
||||
{
|
||||
name: "update peer IP successfully",
|
||||
peerID: testPeerID,
|
||||
requestBody: `{"name":"test", "ssh_enabled":true, "login_expiration_enabled":true, "inactivity_expiration_enabled":true, "ip": "100.64.0.100"}`,
|
||||
callerUserID: adminUser,
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedIP: "100.64.0.100",
|
||||
},
|
||||
{
|
||||
name: "update peer IP with invalid IP",
|
||||
peerID: testPeerID,
|
||||
requestBody: `{"name":"test", "ssh_enabled":true, "login_expiration_enabled":true, "inactivity_expiration_enabled":true, "ip": "invalid-ip"}`,
|
||||
callerUserID: adminUser,
|
||||
expectedStatus: http.StatusUnprocessableEntity,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tt {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/peers/%s", tc.peerID), bytes.NewBuffer([]byte(tc.requestBody)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{
|
||||
UserId: tc.callerUserID,
|
||||
Domain: "hotmail.com",
|
||||
AccountId: "test_id",
|
||||
})
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
v1validator, err := apiv1alpha1.CreateV1ApiValidatingMiddleware()
|
||||
assert.NoError(t, err)
|
||||
router := mux.NewRouter()
|
||||
router.Use(v1validator.Handler)
|
||||
|
||||
router = p.WithEndpointsForRouter(router)
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, tc.expectedStatus, rr.Code)
|
||||
|
||||
if tc.expectedStatus == http.StatusOK && tc.expectedIP != "" {
|
||||
var updatedPeer apiv1alpha1.Peer
|
||||
err := json.Unmarshal(rr.Body.Bytes(), &updatedPeer)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.expectedIP, updatedPeer.Ip)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/account"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/shared/management/http/apiv1alpha1"
|
||||
"github.com/netbirdio/netbird/shared/management/http/util"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
|
||||
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
accountManager account.Manager
|
||||
}
|
||||
|
||||
func NewHandler(accountManager account.Manager) *Handler {
|
||||
return &Handler{
|
||||
accountManager: accountManager,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) WithEndpointsForRouter(router *mux.Router) *mux.Router {
|
||||
router.HandleFunc("/users", h.getAllUsers).Methods("GET", "OPTIONS")
|
||||
router.HandleFunc("/users", h.createUser).Methods("POST", "OPTIONS")
|
||||
router.HandleFunc("/users/{userId}", h.updateUser).Methods("PUT", "OPTIONS")
|
||||
router.HandleFunc("/users/{userId}", h.deleteUser).Methods("DELETE", "OPTIONS")
|
||||
return router
|
||||
}
|
||||
|
||||
// updateUser is a PUT requests to update User data
|
||||
func (h *Handler) updateUser(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut {
|
||||
util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w)
|
||||
return
|
||||
}
|
||||
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
accountID, userID := userAuth.AccountId, userAuth.UserId
|
||||
vars := mux.Vars(r)
|
||||
targetUserID := vars["userId"]
|
||||
if len(targetUserID) == 0 {
|
||||
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "invalid user ID"), w)
|
||||
return
|
||||
}
|
||||
|
||||
existingUser, err := h.accountManager.GetUserByID(r.Context(), targetUserID)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
req := &apiv1alpha1.UserRequest{}
|
||||
err = json.NewDecoder(r.Body).Decode(req)
|
||||
if err != nil {
|
||||
util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w)
|
||||
return
|
||||
}
|
||||
|
||||
if req.AutoGroups == nil {
|
||||
util.WriteErrorResponse("auto_groups field can't be absent", http.StatusBadRequest, w)
|
||||
return
|
||||
}
|
||||
|
||||
userRole := types.StrRoleToUserRole(req.Role)
|
||||
if userRole == types.UserRoleUnknown {
|
||||
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "invalid user role"), w)
|
||||
return
|
||||
}
|
||||
|
||||
newUser, err := h.accountManager.SaveUser(r.Context(), accountID, userID, &types.User{
|
||||
Id: targetUserID,
|
||||
Role: userRole,
|
||||
AutoGroups: req.AutoGroups,
|
||||
Blocked: req.IsBlocked,
|
||||
Issued: existingUser.Issued,
|
||||
IntegrationReference: existingUser.IntegrationReference,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
util.WriteJSONObject(r.Context(), w, toUserResponse(newUser, userID))
|
||||
}
|
||||
|
||||
// deleteUser is a DELETE request to delete a user
|
||||
func (h *Handler) deleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w)
|
||||
return
|
||||
}
|
||||
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
accountID, userID := userAuth.AccountId, userAuth.UserId
|
||||
vars := mux.Vars(r)
|
||||
targetUserID := vars["userId"]
|
||||
if len(targetUserID) == 0 {
|
||||
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "invalid user ID"), w)
|
||||
return
|
||||
}
|
||||
|
||||
err = h.accountManager.DeleteUser(r.Context(), accountID, userID, targetUserID)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
util.WriteJSONObject(r.Context(), w, util.EmptyObject{})
|
||||
}
|
||||
|
||||
// createUser creates a User in the system with a status "invited" (effectively this is a user invite).
|
||||
func (h *Handler) createUser(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w)
|
||||
return
|
||||
}
|
||||
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
accountID, userID := userAuth.AccountId, userAuth.UserId
|
||||
|
||||
req := &apiv1alpha1.UserCreateRequest{}
|
||||
err = json.NewDecoder(r.Body).Decode(req)
|
||||
if err != nil {
|
||||
util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w)
|
||||
return
|
||||
}
|
||||
|
||||
if types.StrRoleToUserRole(req.Role) == types.UserRoleUnknown {
|
||||
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "unknown user role %s", req.Role), w)
|
||||
return
|
||||
}
|
||||
|
||||
email := ""
|
||||
if req.Email != nil {
|
||||
email = *req.Email
|
||||
}
|
||||
|
||||
name := ""
|
||||
if req.Name != nil {
|
||||
name = *req.Name
|
||||
}
|
||||
|
||||
newUser, err := h.accountManager.CreateUser(r.Context(), accountID, userID, &types.UserInfo{
|
||||
Email: email,
|
||||
Name: name,
|
||||
Role: req.Role,
|
||||
AutoGroups: req.AutoGroups,
|
||||
IsServiceUser: req.IsServiceUser,
|
||||
Issued: types.UserIssuedAPI,
|
||||
})
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
util.WriteJSONObject(r.Context(), w, toUserResponse(newUser, userID))
|
||||
}
|
||||
|
||||
// getAllUsers returns a list of users of the account this user belongs to.
|
||||
// It also gathers additional user data (like email and name) from the IDP manager.
|
||||
func (h *Handler) getAllUsers(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w)
|
||||
return
|
||||
}
|
||||
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
accountID, userID := userAuth.AccountId, userAuth.UserId
|
||||
data, err := h.accountManager.GetUsersFromAccount(r.Context(), accountID, userID)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
serviceUser := r.URL.Query().Get("service_user")
|
||||
|
||||
users := make([]*apiv1alpha1.User, 0)
|
||||
for _, d := range data {
|
||||
if d.NonDeletable {
|
||||
continue
|
||||
}
|
||||
if serviceUser == "" {
|
||||
users = append(users, toUserResponse(d, userID))
|
||||
continue
|
||||
}
|
||||
|
||||
includeServiceUser, err := strconv.ParseBool(serviceUser)
|
||||
log.WithContext(r.Context()).Tracef("Should include service user: %v", includeServiceUser)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "invalid service_user query parameter"), w)
|
||||
return
|
||||
}
|
||||
if includeServiceUser == d.IsServiceUser {
|
||||
users = append(users, toUserResponse(d, userID))
|
||||
}
|
||||
}
|
||||
|
||||
util.WriteJSONObject(r.Context(), w, users)
|
||||
}
|
||||
|
||||
func toUserResponse(user *types.UserInfo, currenUserID string) *apiv1alpha1.User {
|
||||
autoGroups := user.AutoGroups
|
||||
if autoGroups == nil {
|
||||
autoGroups = []string{}
|
||||
}
|
||||
|
||||
var userStatus apiv1alpha1.UserStatus
|
||||
switch user.Status {
|
||||
case "active":
|
||||
userStatus = apiv1alpha1.UserStatusActive
|
||||
case "invited":
|
||||
userStatus = apiv1alpha1.UserStatusInvited
|
||||
default:
|
||||
userStatus = apiv1alpha1.UserStatusBlocked
|
||||
}
|
||||
|
||||
if user.IsBlocked {
|
||||
userStatus = apiv1alpha1.UserStatusBlocked
|
||||
}
|
||||
|
||||
isCurrent := user.ID == currenUserID
|
||||
|
||||
var password *string
|
||||
if user.Password != "" {
|
||||
password = &user.Password
|
||||
}
|
||||
|
||||
var idpID *string
|
||||
if user.IdPID != "" {
|
||||
idpID = &user.IdPID
|
||||
}
|
||||
|
||||
return &apiv1alpha1.User{
|
||||
Id: user.ID,
|
||||
Name: user.Name,
|
||||
Email: user.Email,
|
||||
Role: user.Role,
|
||||
AutoGroups: autoGroups,
|
||||
Status: userStatus,
|
||||
IsCurrent: &isCurrent,
|
||||
IsServiceUser: &user.IsServiceUser,
|
||||
IsBlocked: user.IsBlocked,
|
||||
LastLogin: &user.LastLogin,
|
||||
Issued: &user.Issued,
|
||||
PendingApproval: user.PendingApproval,
|
||||
Password: password,
|
||||
IdpId: idpID,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
||||
"github.com/netbirdio/netbird/management/server/mock_server"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/shared/auth"
|
||||
"github.com/netbirdio/netbird/shared/management/http/apiv1alpha1"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
const (
|
||||
existingAccountID = "existingAccountID"
|
||||
notFoundAccountID = "notFoundAccountID"
|
||||
testDomain = "hotmail.com"
|
||||
existingUserID = "existingUserID"
|
||||
notFoundUserID = "notFoundUserID"
|
||||
serviceUserID = "serviceUserID"
|
||||
nonDeletableServiceUserID = "nonDeletableServiceUserID"
|
||||
regularUserID = "regularUserID"
|
||||
)
|
||||
|
||||
var usersTestAccount = &types.Account{
|
||||
Id: existingAccountID,
|
||||
Domain: testDomain,
|
||||
Users: map[string]*types.User{
|
||||
existingUserID: {
|
||||
Id: existingUserID,
|
||||
Role: "admin",
|
||||
IsServiceUser: false,
|
||||
AutoGroups: []string{"group_1"},
|
||||
Issued: types.UserIssuedAPI,
|
||||
},
|
||||
regularUserID: {
|
||||
Id: regularUserID,
|
||||
Role: "user",
|
||||
IsServiceUser: false,
|
||||
AutoGroups: []string{"group_1"},
|
||||
Issued: types.UserIssuedAPI,
|
||||
},
|
||||
serviceUserID: {
|
||||
Id: serviceUserID,
|
||||
Role: "user",
|
||||
IsServiceUser: true,
|
||||
AutoGroups: []string{"group_1"},
|
||||
Issued: types.UserIssuedAPI,
|
||||
},
|
||||
nonDeletableServiceUserID: {
|
||||
Id: nonDeletableServiceUserID,
|
||||
Role: "admin",
|
||||
IsServiceUser: true,
|
||||
NonDeletable: true,
|
||||
Issued: types.UserIssuedIntegration,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func initUsersTestData() *Handler {
|
||||
return &Handler{
|
||||
accountManager: &mock_server.MockAccountManager{
|
||||
GetUserByIDFunc: func(ctx context.Context, id string) (*types.User, error) {
|
||||
return usersTestAccount.Users[id], nil
|
||||
},
|
||||
GetUsersFromAccountFunc: func(_ context.Context, accountID, userID string) (map[string]*types.UserInfo, error) {
|
||||
usersInfos := make(map[string]*types.UserInfo)
|
||||
for _, v := range usersTestAccount.Users {
|
||||
usersInfos[v.Id] = &types.UserInfo{
|
||||
ID: v.Id,
|
||||
Role: string(v.Role),
|
||||
Name: "",
|
||||
Email: "",
|
||||
IsServiceUser: v.IsServiceUser,
|
||||
NonDeletable: v.NonDeletable,
|
||||
Issued: v.Issued,
|
||||
}
|
||||
}
|
||||
return usersInfos, nil
|
||||
},
|
||||
CreateUserFunc: func(_ context.Context, accountID, userID string, key *types.UserInfo) (*types.UserInfo, error) {
|
||||
if userID != existingUserID {
|
||||
return nil, status.Errorf(status.NotFound, "user with ID %s does not exists", userID)
|
||||
}
|
||||
return key, nil
|
||||
},
|
||||
DeleteUserFunc: func(_ context.Context, accountID string, initiatorUserID string, targetUserID string) error {
|
||||
if targetUserID == notFoundUserID {
|
||||
return status.Errorf(status.NotFound, "user with ID %s does not exists", targetUserID)
|
||||
}
|
||||
if !usersTestAccount.Users[targetUserID].IsServiceUser {
|
||||
return status.Errorf(status.PermissionDenied, "user with ID %s is not a service user and can not be deleted", targetUserID)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
SaveUserFunc: func(_ context.Context, accountID, userID string, update *types.User) (*types.UserInfo, error) {
|
||||
if update.Id == notFoundUserID {
|
||||
return nil, status.Errorf(status.NotFound, "user with ID %s does not exists", update.Id)
|
||||
}
|
||||
|
||||
if userID != existingUserID {
|
||||
return nil, status.Errorf(status.NotFound, "user with ID %s does not exists", userID)
|
||||
}
|
||||
|
||||
info, err := update.Copy().ToUserInfo(nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return info, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUsers(t *testing.T) {
|
||||
tt := []struct {
|
||||
name string
|
||||
expectedStatus int
|
||||
requestType string
|
||||
requestPath string
|
||||
expectedUserIDs []string
|
||||
}{
|
||||
{name: "getAllUsers", requestType: http.MethodGet, requestPath: "/users", expectedStatus: http.StatusOK, expectedUserIDs: []string{existingUserID, regularUserID, serviceUserID}},
|
||||
{name: "GetOnlyServiceUsers", requestType: http.MethodGet, requestPath: "/users?service_user=true", expectedStatus: http.StatusOK, expectedUserIDs: []string{serviceUserID}},
|
||||
{name: "GetOnlyRegularUsers", requestType: http.MethodGet, requestPath: "/users?service_user=false", expectedStatus: http.StatusOK, expectedUserIDs: []string{existingUserID, regularUserID}},
|
||||
}
|
||||
|
||||
userHandler := initUsersTestData()
|
||||
|
||||
for _, tc := range tt {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(tc.requestType, tc.requestPath, nil)
|
||||
req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{
|
||||
UserId: existingUserID,
|
||||
Domain: testDomain,
|
||||
AccountId: existingAccountID,
|
||||
})
|
||||
|
||||
v1validator, err := apiv1alpha1.CreateV1ApiValidatingMiddleware()
|
||||
assert.NoError(t, err)
|
||||
router := mux.NewRouter()
|
||||
router.Use(v1validator.Handler)
|
||||
|
||||
router = userHandler.WithEndpointsForRouter(router)
|
||||
router.ServeHTTP(recorder, req)
|
||||
|
||||
res := recorder.Result()
|
||||
defer res.Body.Close()
|
||||
|
||||
content, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("I don't know what I expected; %v", err)
|
||||
}
|
||||
|
||||
if status := recorder.Code; status != tc.expectedStatus {
|
||||
t.Errorf("handler returned wrong status code: got %v want %v, content: %s",
|
||||
status, tc.expectedStatus, string(content))
|
||||
return
|
||||
}
|
||||
|
||||
respBody := []*types.UserInfo{}
|
||||
err = json.Unmarshal(content, &respBody)
|
||||
if err != nil {
|
||||
t.Fatalf("Sent content is not in correct json format; %v", err)
|
||||
}
|
||||
|
||||
assert.Equal(t, len(respBody), len(tc.expectedUserIDs))
|
||||
for _, v := range respBody {
|
||||
assert.Contains(t, tc.expectedUserIDs, v.ID)
|
||||
assert.Equal(t, v.ID, usersTestAccount.Users[v.ID].Id)
|
||||
assert.Equal(t, v.Role, string(usersTestAccount.Users[v.ID].Role))
|
||||
assert.Equal(t, v.IsServiceUser, usersTestAccount.Users[v.ID].IsServiceUser)
|
||||
assert.Equal(t, v.Issued, usersTestAccount.Users[v.ID].Issued)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUser(t *testing.T) {
|
||||
tt := []struct {
|
||||
name string
|
||||
expectedStatusCode int
|
||||
requestType string
|
||||
requestPath string
|
||||
requestBody io.Reader
|
||||
expectedUserID string
|
||||
expectedRole string
|
||||
expectedStatus string
|
||||
expectedBlocked bool
|
||||
expectedIsServiceUser bool
|
||||
expectedGroups []string
|
||||
}{
|
||||
{
|
||||
name: "Update_Block_User",
|
||||
requestType: http.MethodPut,
|
||||
requestPath: "/users/" + regularUserID,
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedUserID: regularUserID,
|
||||
expectedBlocked: true,
|
||||
expectedRole: "user",
|
||||
expectedStatus: "blocked",
|
||||
expectedGroups: []string{"group_1"},
|
||||
requestBody: bytes.NewBufferString("{\"role\":\"user\",\"auto_groups\":[\"group_1\"],\"is_service_user\":false, \"is_blocked\": true}"),
|
||||
},
|
||||
{
|
||||
name: "Update_Change_Role_To_Admin",
|
||||
requestType: http.MethodPut,
|
||||
requestPath: "/users/" + regularUserID,
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedUserID: regularUserID,
|
||||
expectedBlocked: false,
|
||||
expectedRole: "admin",
|
||||
expectedStatus: "blocked",
|
||||
expectedGroups: []string{"group_1"},
|
||||
requestBody: bytes.NewBufferString("{\"role\":\"admin\",\"auto_groups\":[\"group_1\"],\"is_service_user\":false, \"is_blocked\": false}"),
|
||||
},
|
||||
{
|
||||
name: "Update_Groups",
|
||||
requestType: http.MethodPut,
|
||||
requestPath: "/users/" + regularUserID,
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedUserID: regularUserID,
|
||||
expectedBlocked: false,
|
||||
expectedRole: "admin",
|
||||
expectedStatus: "blocked",
|
||||
expectedGroups: []string{"group_2", "group_3"},
|
||||
requestBody: bytes.NewBufferString("{\"role\":\"admin\",\"auto_groups\":[\"group_3\", \"group_2\"],\"is_service_user\":false, \"is_blocked\": false}"),
|
||||
},
|
||||
{
|
||||
name: "Should_Fail_Because_AutoGroups_Is_Absent",
|
||||
requestType: http.MethodPut,
|
||||
requestPath: "/users/" + regularUserID,
|
||||
expectedStatusCode: http.StatusUnprocessableEntity,
|
||||
expectedUserID: regularUserID,
|
||||
expectedBlocked: false,
|
||||
expectedRole: "admin",
|
||||
expectedStatus: "blocked",
|
||||
expectedGroups: []string{"group_2", "group_3"},
|
||||
requestBody: bytes.NewBufferString("{\"role\":\"admin\",\"is_service_user\":false, \"is_blocked\": false}"),
|
||||
},
|
||||
}
|
||||
|
||||
userHandler := initUsersTestData()
|
||||
|
||||
for _, tc := range tt {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(tc.requestType, tc.requestPath, tc.requestBody)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{
|
||||
UserId: existingUserID,
|
||||
Domain: testDomain,
|
||||
AccountId: existingAccountID,
|
||||
})
|
||||
|
||||
v1validator, err := apiv1alpha1.CreateV1ApiValidatingMiddleware()
|
||||
assert.NoError(t, err)
|
||||
|
||||
router := mux.NewRouter()
|
||||
router.Use(v1validator.Handler)
|
||||
|
||||
router = userHandler.WithEndpointsForRouter(router)
|
||||
router.ServeHTTP(recorder, req)
|
||||
|
||||
res := recorder.Result()
|
||||
defer res.Body.Close()
|
||||
|
||||
if status := recorder.Code; status != tc.expectedStatusCode {
|
||||
t.Fatalf("handler returned wrong status code: got %v want %v",
|
||||
status, http.StatusOK)
|
||||
}
|
||||
|
||||
if tc.expectedStatusCode == 200 {
|
||||
|
||||
content, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("I don't know what I expected; %v", err)
|
||||
}
|
||||
|
||||
respBody := &apiv1alpha1.User{}
|
||||
err = json.Unmarshal(content, &respBody)
|
||||
if err != nil {
|
||||
t.Fatalf("response content is not in correct json format; %v", err)
|
||||
}
|
||||
|
||||
assert.Equal(t, tc.expectedUserID, respBody.Id)
|
||||
assert.Equal(t, tc.expectedRole, respBody.Role)
|
||||
assert.Equal(t, tc.expectedIsServiceUser, *respBody.IsServiceUser)
|
||||
assert.Equal(t, tc.expectedBlocked, respBody.IsBlocked)
|
||||
assert.Len(t, respBody.AutoGroups, len(tc.expectedGroups))
|
||||
|
||||
for _, expectedGroup := range tc.expectedGroups {
|
||||
exists := false
|
||||
for _, actualGroup := range respBody.AutoGroups {
|
||||
if expectedGroup == actualGroup {
|
||||
exists = true
|
||||
}
|
||||
}
|
||||
assert.True(t, exists, fmt.Sprintf("group %s not found in the response", expectedGroup))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateUser(t *testing.T) {
|
||||
name := "name"
|
||||
email := "email"
|
||||
serviceUserToAdd := apiv1alpha1.UserCreateRequest{
|
||||
AutoGroups: []string{},
|
||||
Email: nil,
|
||||
IsServiceUser: true,
|
||||
Name: &name,
|
||||
Role: "admin",
|
||||
}
|
||||
serviceUserString, err := json.Marshal(serviceUserToAdd)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
regularUserToAdd := apiv1alpha1.UserCreateRequest{
|
||||
AutoGroups: []string{},
|
||||
Email: &email,
|
||||
IsServiceUser: true,
|
||||
Name: &name,
|
||||
Role: "admin",
|
||||
}
|
||||
regularUserString, err := json.Marshal(regularUserToAdd)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tt := []struct {
|
||||
name string
|
||||
expectedStatus int
|
||||
requestType string
|
||||
requestPath string
|
||||
requestBody io.Reader
|
||||
expectedResult []*types.User
|
||||
}{
|
||||
{name: "CreateServiceUser", requestType: http.MethodPost, requestPath: "/users", expectedStatus: http.StatusOK, requestBody: bytes.NewBuffer(serviceUserString)},
|
||||
// right now creation is blocked in AC middleware, will be refactored in the future
|
||||
{name: "CreateRegularUser", requestType: http.MethodPost, requestPath: "/users", expectedStatus: http.StatusOK, requestBody: bytes.NewBuffer(regularUserString)},
|
||||
}
|
||||
|
||||
userHandler := initUsersTestData()
|
||||
|
||||
for _, tc := range tt {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(tc.requestType, tc.requestPath, tc.requestBody)
|
||||
rr := httptest.NewRecorder()
|
||||
req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{
|
||||
UserId: existingUserID,
|
||||
Domain: testDomain,
|
||||
AccountId: existingAccountID,
|
||||
})
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
v1validator, err := apiv1alpha1.CreateV1ApiValidatingMiddleware()
|
||||
assert.NoError(t, err)
|
||||
|
||||
router := mux.NewRouter()
|
||||
router.Use(v1validator.Handler)
|
||||
|
||||
router = userHandler.WithEndpointsForRouter(router)
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
res := rr.Result()
|
||||
defer res.Body.Close()
|
||||
|
||||
if status := rr.Code; status != tc.expectedStatus {
|
||||
t.Fatalf("handler returned wrong status code: got %v want %v",
|
||||
status, tc.expectedStatus)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteUser(t *testing.T) {
|
||||
tt := []struct {
|
||||
name string
|
||||
expectedStatus int
|
||||
expectedBody bool
|
||||
requestType string
|
||||
requestPath string
|
||||
requestVars map[string]string
|
||||
requestBody io.Reader
|
||||
}{
|
||||
{
|
||||
name: "Delete Regular User",
|
||||
requestType: http.MethodDelete,
|
||||
requestPath: "/users/" + regularUserID,
|
||||
requestVars: map[string]string{"userId": regularUserID},
|
||||
expectedStatus: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "Delete Service User",
|
||||
requestType: http.MethodDelete,
|
||||
requestPath: "/users/" + serviceUserID,
|
||||
requestVars: map[string]string{"userId": serviceUserID},
|
||||
expectedStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "Delete Not Existing User",
|
||||
requestType: http.MethodDelete,
|
||||
requestPath: "/users/" + notFoundUserID,
|
||||
requestVars: map[string]string{"userId": notFoundUserID},
|
||||
expectedStatus: http.StatusNotFound,
|
||||
},
|
||||
}
|
||||
|
||||
userHandler := initUsersTestData()
|
||||
for _, tc := range tt {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(tc.requestType, tc.requestPath, nil)
|
||||
req = mux.SetURLVars(req, tc.requestVars)
|
||||
req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{
|
||||
UserId: existingUserID,
|
||||
Domain: testDomain,
|
||||
AccountId: existingAccountID,
|
||||
})
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
v1validator, err := apiv1alpha1.CreateV1ApiValidatingMiddleware()
|
||||
assert.NoError(t, err)
|
||||
|
||||
router := mux.NewRouter()
|
||||
router.Use(v1validator.Handler)
|
||||
router = userHandler.WithEndpointsForRouter(router)
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
res := rr.Result()
|
||||
defer res.Body.Close()
|
||||
|
||||
if status := rr.Code; status != tc.expectedStatus {
|
||||
t.Fatalf("handler returned wrong status code: got %v want %v",
|
||||
status, tc.expectedStatus)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -75,10 +75,10 @@ func (m *manager) ValidateAndParseToken(ctx context.Context, value string) (auth
|
||||
}
|
||||
|
||||
func (m *manager) EnsureUserAccessByJWTGroups(ctx context.Context, userAuth auth.UserAuth, token *jwt.Token) (auth.UserAuth, error) {
|
||||
// Child accounts and PAT-authenticated requests do not use JWT group access checks.
|
||||
// PAT-authenticated requests do not use JWT group access checks.
|
||||
// Embedded-Dex local users also skip them because local password authentication
|
||||
// does not provide external IdP group claims.
|
||||
if userAuth.IsChild || userAuth.IsPAT || dex.IsLocalUserID(userAuth.UserId) {
|
||||
if userAuth.IsPAT || dex.IsLocalUserID(userAuth.UserId) {
|
||||
return userAuth, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,11 @@ package auth
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -52,6 +54,22 @@ func (s *SessionStore) RegisterToken(ctx context.Context, token string, expiresA
|
||||
}
|
||||
|
||||
func hashToken(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
sum := sha256.Sum256([]byte(canonicalizeToken(token)))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// canonicalizeToken re-encodes the JWT signature segment so noncanonical
|
||||
// spellings of the same signature map to one stable cache key.
|
||||
func canonicalizeToken(token string) string {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return token
|
||||
}
|
||||
|
||||
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
|
||||
if err != nil {
|
||||
return token
|
||||
}
|
||||
|
||||
return parts[0] + "." + parts[1] + "." + base64.RawURLEncoding.EncodeToString(sig)
|
||||
}
|
||||
|
||||
@@ -2,10 +2,15 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
@@ -131,3 +136,49 @@ func TestHashToken_StableAndDoesNotLeak(t *testing.T) {
|
||||
assert.Len(t, a, 64, "sha256 hex must be 64 chars")
|
||||
assert.NotContains(t, a, "tokenA", "raw token must not appear in hash")
|
||||
}
|
||||
|
||||
func TestSessionStore_NoncanonicalSpellingIsRejectedAsReplay(t *testing.T) {
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
|
||||
"sub": "user",
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
})
|
||||
canonical, err := token.SignedString(privateKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
parts := strings.Split(canonical, ".")
|
||||
require.Len(t, parts, 3)
|
||||
|
||||
// A 256-byte RSA signature (256 mod 3 == 1) leaves unused bits in the final
|
||||
// base64url character; flip one without changing the decoded signature.
|
||||
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
|
||||
last := strings.IndexByte(alphabet, parts[2][len(parts[2])-1])
|
||||
require.GreaterOrEqual(t, last, 0)
|
||||
require.Equal(t, 0, last&3, "unexpected canonical RSA signature encoding")
|
||||
|
||||
equivalentSig := parts[2][:len(parts[2])-1] + string(alphabet[last|1])
|
||||
equivalent := parts[0] + "." + parts[1] + "." + equivalentSig
|
||||
require.NotEqual(t, canonical, equivalent, "spellings must differ as strings")
|
||||
|
||||
// Same decoded signature bytes, so they verify as the same JWT.
|
||||
canonicalSig, err := base64.RawURLEncoding.DecodeString(parts[2])
|
||||
require.NoError(t, err)
|
||||
altSig, err := base64.RawURLEncoding.DecodeString(equivalentSig)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, canonicalSig, altSig, "spellings must decode to identical signature bytes")
|
||||
|
||||
// The replay-cache key must be identical for both spellings.
|
||||
assert.Equal(t, hashToken(canonical), hashToken(equivalent),
|
||||
"noncanonical spelling must map to the same replay-cache key")
|
||||
|
||||
s := newTestSessionStore(t)
|
||||
ctx := context.Background()
|
||||
exp := time.Now().Add(time.Hour)
|
||||
|
||||
require.NoError(t, s.RegisterToken(ctx, canonical, exp), "first claim should succeed")
|
||||
err = s.RegisterToken(ctx, equivalent, exp)
|
||||
require.Error(t, err, "alternate spelling must be treated as a replay")
|
||||
assert.ErrorIs(t, err, ErrTokenAlreadyUsed)
|
||||
}
|
||||
|
||||
Vendored
+9
-3
@@ -88,7 +88,8 @@ func NewUserDataCache(store store.StoreInterface) *UserDataCacheImpl {
|
||||
|
||||
// AccountUserDataCache wraps the basic Get, Set and Delete methods for []*idp.UserData objects.
|
||||
type AccountUserDataCache struct {
|
||||
cache Marshaler
|
||||
cache Marshaler
|
||||
loadable *cache.LoadableCache[any]
|
||||
}
|
||||
|
||||
func (a *AccountUserDataCache) Get(ctx context.Context, key string) ([]*idp.UserData, error) {
|
||||
@@ -127,13 +128,18 @@ func (a *AccountUserDataCache) Delete(ctx context.Context, key string) error {
|
||||
return a.cache.Delete(ctx, key)
|
||||
}
|
||||
|
||||
// Close stops the goroutine that stores loaded values. The cache must not be used afterwards.
|
||||
func (a *AccountUserDataCache) Close() error {
|
||||
return a.loadable.Close()
|
||||
}
|
||||
|
||||
// NewAccountUserDataCache creates a new AccountUserDataCache object.
|
||||
func NewAccountUserDataCache(loadableFunc cache.LoadFunction[any], store store.StoreInterface) *AccountUserDataCache {
|
||||
simpleCache := cache.New[any](store)
|
||||
loadable := cache.NewLoadable[any](loadableFunc, simpleCache)
|
||||
if store.GetType() == redis.RedisType {
|
||||
m := marshaler.New(loadable)
|
||||
return &AccountUserDataCache{cache: m}
|
||||
return &AccountUserDataCache{cache: m, loadable: loadable}
|
||||
}
|
||||
return &AccountUserDataCache{cache: &marshalerWraper{loadable}}
|
||||
return &AccountUserDataCache{cache: &marshalerWraper{loadable}, loadable: loadable}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"hash/fnv"
|
||||
"maps"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/shared/management/certposture"
|
||||
)
|
||||
|
||||
const (
|
||||
minCertChallengeTick = time.Second
|
||||
maxCertChallengeTick = 15 * time.Minute
|
||||
|
||||
maxCertChallengeRefresh = 30 * time.Second
|
||||
)
|
||||
|
||||
// certChallengePeriod is how often an account whose policies carry a certificate
|
||||
// posture check is pushed a fresh challenge. A nonce stays valid through the window
|
||||
// after its own, so one issued at the very end of a window lives only one window. A
|
||||
// third of that leaves a missed run well clear of the edge, where a half would put it
|
||||
// exactly on it.
|
||||
func certChallengePeriod() time.Duration {
|
||||
return certposture.EffectiveWindow() / 3
|
||||
}
|
||||
|
||||
// certChallengeTick is how often the refresher looks for accounts that are due. It is
|
||||
// derived from the period rather than fixed, so shortening the challenge window for a
|
||||
// test shortens this with it; the bounds keep a tiny window from spinning and a normal
|
||||
// one from checking less often than is useful.
|
||||
func certChallengeTick(period time.Duration) time.Duration {
|
||||
return min(max(period/10, minCertChallengeTick), maxCertChallengeTick)
|
||||
}
|
||||
|
||||
// certChallengeRefresh is how long one account's refresh is given before it is
|
||||
// abandoned. Resolving the target peers reads the store and accounts are swept one
|
||||
// after another, so an unbounded refresh lets one wedged read starve all the others.
|
||||
func certChallengeRefresh(tick time.Duration) time.Duration {
|
||||
return min(tick, maxCertChallengeRefresh)
|
||||
}
|
||||
|
||||
// certChallengeRefresher pushes a fresh certificate challenge to the peers connected to
|
||||
// this instance that answer one, from a single goroutine.
|
||||
//
|
||||
// A nonce only reaches a peer attached to a network map, and a quiet account sends no
|
||||
// map, so without this the peer eventually re-sends an expired nonce, has its whole
|
||||
// proof set rejected, and silently leaves the policies the check gates.
|
||||
//
|
||||
// Each instance renews only the peers whose sync stream it holds, so a peer is renewed
|
||||
// once however many instances run, and an account is dropped as soon as none of its
|
||||
// peers here answers a challenge.
|
||||
type certChallengeRefresher struct {
|
||||
mu sync.Mutex
|
||||
accounts map[string]*certChallengeAccount
|
||||
// refreshing holds the accounts whose refresh is running, and whether Track was
|
||||
// called for one meanwhile, which makes its refresh's verdict stale.
|
||||
refreshing map[string]bool
|
||||
|
||||
period time.Duration
|
||||
tick time.Duration
|
||||
timeout time.Duration
|
||||
now func() time.Time
|
||||
// refresh pushes an update to those of peerIDs that answer a certificate challenge,
|
||||
// and reports whether any of them does.
|
||||
refresh func(ctx context.Context, accountID string, peerIDs []string) bool
|
||||
}
|
||||
|
||||
// certChallengeAccount is one account's renewal schedule and the peers it covers, each
|
||||
// with the start of the sync stream it was stamped on.
|
||||
type certChallengeAccount struct {
|
||||
due time.Time
|
||||
peers map[string]time.Time
|
||||
}
|
||||
|
||||
// certChallengeDue is an account handed out for refresh, with its peers at that moment.
|
||||
type certChallengeDue struct {
|
||||
accountID string
|
||||
peerIDs []string
|
||||
}
|
||||
|
||||
func newCertChallengeRefresher(refresh func(ctx context.Context, accountID string, peerIDs []string) bool) *certChallengeRefresher {
|
||||
period := certChallengePeriod()
|
||||
tick := certChallengeTick(period)
|
||||
return &certChallengeRefresher{
|
||||
accounts: map[string]*certChallengeAccount{},
|
||||
refreshing: map[string]bool{},
|
||||
period: period,
|
||||
tick: tick,
|
||||
timeout: certChallengeRefresh(tick),
|
||||
now: time.Now,
|
||||
refresh: refresh,
|
||||
}
|
||||
}
|
||||
|
||||
// Start runs the refresh loop until ctx is done.
|
||||
func (r *certChallengeRefresher) Start(ctx context.Context) {
|
||||
go r.run(ctx)
|
||||
}
|
||||
|
||||
// Track renews the challenge of peerID, which was stamped one on the sync stream that
|
||||
// started at streamStart. An account seen for the first time has its first run spread
|
||||
// over one period, so that a global window rollover does not fan out to every account in
|
||||
// the same moment; an account already tracked keeps its schedule. A stamp from an older
|
||||
// stream than the one recorded for the peer is ignored.
|
||||
func (r *certChallengeRefresher) Track(ctx context.Context, accountID, peerID string, streamStart time.Time) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if _, ok := r.refreshing[accountID]; ok {
|
||||
r.refreshing[accountID] = true
|
||||
}
|
||||
account, ok := r.accounts[accountID]
|
||||
if !ok {
|
||||
account = &certChallengeAccount{
|
||||
due: r.now().Add(offsetWithin(accountID, r.period)),
|
||||
peers: map[string]time.Time{},
|
||||
}
|
||||
r.accounts[accountID] = account
|
||||
log.WithContext(ctx).Debugf("tracking certificate challenge refresh for account %s", accountID)
|
||||
}
|
||||
if started, ok := account.peers[peerID]; ok && streamStart.Before(started) {
|
||||
return
|
||||
}
|
||||
account.peers[peerID] = streamStart
|
||||
}
|
||||
|
||||
// Untrack stops renewing peerID once the sync stream that started at streamStart ends.
|
||||
// A newer stream of the same peer keeps it tracked. The account is dropped with its
|
||||
// last peer.
|
||||
func (r *certChallengeRefresher) Untrack(accountID, peerID string, streamStart time.Time) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
account, ok := r.accounts[accountID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if started, ok := account.peers[peerID]; !ok || !started.Equal(streamStart) {
|
||||
return
|
||||
}
|
||||
delete(account.peers, peerID)
|
||||
if len(account.peers) == 0 {
|
||||
delete(r.accounts, accountID)
|
||||
}
|
||||
}
|
||||
|
||||
// Forget stops refreshing accountID.
|
||||
func (r *certChallengeRefresher) Forget(accountID string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
delete(r.accounts, accountID)
|
||||
}
|
||||
|
||||
func (r *certChallengeRefresher) tracked(accountID string) bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
_, ok := r.accounts[accountID]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (r *certChallengeRefresher) run(ctx context.Context) {
|
||||
ticker := time.NewTicker(r.tick)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
for _, due := range r.takeDue() {
|
||||
r.finish(due.accountID, r.refreshOne(ctx, due))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// refreshOne refreshes a single account under the refresh deadline. A refresh that runs
|
||||
// out of time reports the account as still wanting challenges, since a deadline says
|
||||
// nothing about the account's posture checks; the next sweep tries again.
|
||||
func (r *certChallengeRefresher) refreshOne(ctx context.Context, due certChallengeDue) bool {
|
||||
ctx, cancel := context.WithTimeout(ctx, r.timeout)
|
||||
defer cancel()
|
||||
return r.refresh(ctx, due.accountID, due.peerIDs)
|
||||
}
|
||||
|
||||
// finish records the outcome of an account's refresh. An account none of whose peers
|
||||
// answers a challenge any more is dropped, unless one was tracked again while the
|
||||
// refresh ran: the refresh may have read the store before a certificate check was added.
|
||||
// A dropped peer is tracked again the next time it is stamped a challenge.
|
||||
func (r *certChallengeRefresher) finish(accountID string, wanted bool) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
retracked := r.refreshing[accountID]
|
||||
delete(r.refreshing, accountID)
|
||||
if !wanted && !retracked {
|
||||
delete(r.accounts, accountID)
|
||||
}
|
||||
}
|
||||
|
||||
// takeDue returns the accounts due now and books their next run straight away, so a
|
||||
// slow refresh cannot make an account fall due twice, and so the refresh itself runs
|
||||
// without the lock.
|
||||
func (r *certChallengeRefresher) takeDue() []certChallengeDue {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
now := r.now()
|
||||
var due []certChallengeDue
|
||||
for accountID, account := range r.accounts {
|
||||
if account.due.After(now) {
|
||||
continue
|
||||
}
|
||||
due = append(due, certChallengeDue{accountID: accountID, peerIDs: slices.Collect(maps.Keys(account.peers))})
|
||||
account.due = now.Add(r.period)
|
||||
r.refreshing[accountID] = false
|
||||
}
|
||||
return due
|
||||
}
|
||||
|
||||
// offsetWithin maps a key to a stable duration in [0, period).
|
||||
func offsetWithin(key string, period time.Duration) time.Duration {
|
||||
h := fnv.New64a()
|
||||
//nolint:errcheck // hash.Write never returns an error
|
||||
h.Write([]byte(key))
|
||||
return time.Duration(binary.BigEndian.Uint64(h.Sum(nil)) % uint64(period))
|
||||
}
|
||||
|
||||
// refreshCertificateChallenges pushes an update to those of peerIDs that answer a
|
||||
// certificate challenge, so each is stamped with a nonce for the current window. It
|
||||
// reports whether any of them answers one.
|
||||
func (am *DefaultAccountManager) refreshCertificateChallenges(ctx context.Context, accountID string, peerIDs []string) bool {
|
||||
targets, err := am.certificateChallengeTargets(ctx, accountID)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Debugf("cannot resolve the certificate challenge targets of account %s: %v", accountID, err)
|
||||
// Keep the account tracked: a store error now says nothing about its checks.
|
||||
return true
|
||||
}
|
||||
peerIDs = slices.DeleteFunc(peerIDs, func(id string) bool { return !slices.Contains(targets, id) })
|
||||
if len(peerIDs) == 0 {
|
||||
log.WithContext(ctx).Debugf("no peer of account %s on this instance answers a certificate challenge, stopping challenge refresh", accountID)
|
||||
return false
|
||||
}
|
||||
|
||||
log.WithContext(ctx).Debugf("refreshing certificate challenges for %d peers of account %s", len(peerIDs), accountID)
|
||||
reason := types.UpdateReason{Resource: types.UpdateResourcePostureCheck, Operation: types.UpdateOperationRefresh}
|
||||
if err := am.networkMapController.BufferUpdateAffectedPeers(ctx, accountID, peerIDs, reason); err != nil {
|
||||
log.WithContext(ctx).Warnf("failed refreshing certificate challenges for account %s: %v", accountID, err)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// certificateChallengeTargets returns the peers that are sent a certificate challenge.
|
||||
// Only those peers hold a nonce, so pushing to the whole account would wake every peer
|
||||
// that never uses the feature.
|
||||
//
|
||||
// It is the inverse of processPeerPostureChecks, which decides the same thing one peer
|
||||
// at a time; TestCertificateChallengeTargets_MatchesThePerPeerRule holds them together.
|
||||
func (am *DefaultAccountManager) certificateChallengeTargets(ctx context.Context, accountID string) ([]string, error) {
|
||||
certCheckIDs, err := am.certificatePostureCheckIDs(ctx, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(certCheckIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
policies, err := am.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groups, err := am.Store.GetAccountGroups(ctx, store.LockingStrengthNone, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groupPeers := make(map[string][]string, len(groups))
|
||||
for _, g := range groups {
|
||||
groupPeers[g.ID] = g.Peers
|
||||
}
|
||||
|
||||
return certificateChallengeTargets(policies, groupPeers, certCheckIDs), nil
|
||||
}
|
||||
|
||||
// certificateChallengeTargets collects the source peers of every enabled policy whose
|
||||
// posture checks include a certificate check.
|
||||
func certificateChallengeTargets(policies []*types.Policy, groupPeers map[string][]string, certCheckIDs map[string]struct{}) []string {
|
||||
targets := map[string]struct{}{}
|
||||
for _, policy := range policies {
|
||||
if !policy.Enabled || !slices.ContainsFunc(policy.SourcePostureChecks, func(id string) bool {
|
||||
_, ok := certCheckIDs[id]
|
||||
return ok
|
||||
}) {
|
||||
continue
|
||||
}
|
||||
for _, rule := range policy.Rules {
|
||||
if !rule.Enabled {
|
||||
continue
|
||||
}
|
||||
if rule.SourceResource.Type == types.ResourceTypePeer && rule.SourceResource.ID != "" {
|
||||
targets[rule.SourceResource.ID] = struct{}{}
|
||||
}
|
||||
for _, groupID := range rule.Sources {
|
||||
for _, peerID := range groupPeers[groupID] {
|
||||
targets[peerID] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return slices.Collect(maps.Keys(targets))
|
||||
}
|
||||
|
||||
// certificatePostureCheckIDs returns the IDs of the account's posture checks that ask a
|
||||
// peer to prove a certificate.
|
||||
func (am *DefaultAccountManager) certificatePostureCheckIDs(ctx context.Context, accountID string) (map[string]struct{}, error) {
|
||||
checks, err := am.Store.GetAccountPostureChecks(ctx, store.LockingStrengthNone, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := map[string]struct{}{}
|
||||
for _, check := range checks {
|
||||
if check.Checks.CertificateCheck != nil {
|
||||
ids[check.ID] = struct{}{}
|
||||
}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// TrackCertificateChallenges starts renewing the certificate challenge of a peer that was
|
||||
// stamped one on the sync stream started at streamStart. It is called on every stamped
|
||||
// update, so it has to stay cheap: no store access, just a map the refresher sweeps.
|
||||
func (am *DefaultAccountManager) TrackCertificateChallenges(ctx context.Context, accountID, peerID string, streamStart time.Time) {
|
||||
am.certChallenges.Track(ctx, accountID, peerID, streamStart)
|
||||
}
|
||||
|
||||
// UntrackCertificateChallenges stops renewing the certificate challenge of a peer whose
|
||||
// sync stream started at streamStart has ended.
|
||||
func (am *DefaultAccountManager) UntrackCertificateChallenges(accountID, peerID string, streamStart time.Time) {
|
||||
am.certChallenges.Untrack(accountID, peerID, streamStart)
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/shared/management/certposture"
|
||||
)
|
||||
|
||||
// fakeClock lets a test drive the refresher's schedule without waiting for it.
|
||||
type fakeClock struct {
|
||||
mu sync.Mutex
|
||||
at time.Time
|
||||
}
|
||||
|
||||
func (c *fakeClock) Now() time.Time {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.at
|
||||
}
|
||||
|
||||
func (c *fakeClock) Advance(d time.Duration) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.at = c.at.Add(d)
|
||||
}
|
||||
|
||||
// scheduleRefresher builds a refresher on a clock the test drives and never starts its
|
||||
// loop, so the schedule can be checked by calling takeDue directly.
|
||||
func scheduleRefresher(refresh func(accountID string) bool) (*certChallengeRefresher, *fakeClock) {
|
||||
clock := &fakeClock{at: time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)}
|
||||
r := newCertChallengeRefresher(func(_ context.Context, accountID string, _ []string) bool {
|
||||
return refresh(accountID)
|
||||
})
|
||||
r.now = clock.Now
|
||||
return r, clock
|
||||
}
|
||||
|
||||
// runningRefresher builds a refresher whose period and tick are short enough to observe
|
||||
// in a test, and starts its loop.
|
||||
func runningRefresher(t *testing.T, refresh func(accountID string) bool) *certChallengeRefresher {
|
||||
t.Helper()
|
||||
r := newCertChallengeRefresher(func(_ context.Context, accountID string, _ []string) bool {
|
||||
return refresh(accountID)
|
||||
})
|
||||
r.period = 10 * time.Millisecond
|
||||
r.tick = time.Millisecond
|
||||
r.Start(t.Context())
|
||||
return r
|
||||
}
|
||||
|
||||
// streamStart is the start of the sync stream the tests' peers are stamped on.
|
||||
var streamStart = time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
// track tracks one peer of accountID, as a stamped sync stream does.
|
||||
func track(r *certChallengeRefresher, accountID string) {
|
||||
r.Track(context.Background(), accountID, accountID+"-peer", streamStart)
|
||||
}
|
||||
|
||||
// dueAccounts returns the IDs of the accounts takeDue hands out.
|
||||
func dueAccounts(r *certChallengeRefresher) []string {
|
||||
var ids []string
|
||||
for _, due := range r.takeDue() {
|
||||
ids = append(ids, due.accountID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func (r *certChallengeRefresher) dueAt(accountID string) time.Time {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.accounts[accountID].due
|
||||
}
|
||||
|
||||
func TestCertChallengePeriod_LeavesRoomForAMissedRun(t *testing.T) {
|
||||
// A nonce issued at the very end of a window is accepted for that window and the
|
||||
// next one only, so the shortest life a peer can be handed is one window. Renewing
|
||||
// has to stay clear of that edge even when a run is missed.
|
||||
assert.Less(t, 2*certChallengePeriod(), certposture.EffectiveWindow(),
|
||||
"a missed refresh must still leave the peer's nonce valid, with margin")
|
||||
}
|
||||
|
||||
func TestCertChallengeTick_FollowsTheWindow(t *testing.T) {
|
||||
// An end-to-end test shortens the challenge window to watch a renewal happen. The
|
||||
// tick has to come down with it, or the refresher would still be looking for due
|
||||
// accounts every quarter of an hour and never renew anything in time.
|
||||
short := certChallengeTick(30 * time.Second)
|
||||
assert.Less(t, short, 30*time.Second, "the tick must be shorter than the period it serves")
|
||||
assert.GreaterOrEqual(t, short, minCertChallengeTick, "the tick must not spin")
|
||||
|
||||
assert.Equal(t, maxCertChallengeTick, certChallengeTick(24*time.Hour),
|
||||
"a long period must not stretch the tick without bound")
|
||||
assert.Equal(t, minCertChallengeTick, certChallengeTick(time.Millisecond),
|
||||
"a tiny period must not drive the tick below its floor")
|
||||
}
|
||||
|
||||
func TestCertChallengeRefresher_SpreadsAccountsOverThePeriod(t *testing.T) {
|
||||
// Every account on an instance shares the same global challenge window, so a
|
||||
// restart arms them all at once. The offset is what keeps them from fanning out
|
||||
// to their peers in the same moment.
|
||||
r, clock := scheduleRefresher(func(string) bool { return true })
|
||||
|
||||
ids := []string{"account-a", "account-b", "account-c", "account-d", "account-e", "account-f"}
|
||||
for _, id := range ids {
|
||||
track(r, id)
|
||||
}
|
||||
|
||||
start := clock.Now()
|
||||
offsets := map[time.Duration]bool{}
|
||||
for _, id := range ids {
|
||||
offset := r.dueAt(id).Sub(start)
|
||||
assert.GreaterOrEqual(t, offset, time.Duration(0), "account %s is due in the past", id)
|
||||
assert.Less(t, offset, r.period, "account %s is due beyond one period", id)
|
||||
offsets[offset] = true
|
||||
}
|
||||
assert.Greater(t, len(offsets), 1, "all accounts were given the same offset, which defeats the spreading")
|
||||
}
|
||||
|
||||
func TestCertChallengeOffset_IsStablePerAccount(t *testing.T) {
|
||||
assert.Equal(t, offsetWithin("account-a", certChallengePeriod()), offsetWithin("account-a", certChallengePeriod()),
|
||||
"the same account must keep its slot across restarts")
|
||||
assert.NotEqual(t, offsetWithin("account-a", certChallengePeriod()), offsetWithin("account-b", certChallengePeriod()),
|
||||
"two accounts must not share a slot")
|
||||
}
|
||||
|
||||
func TestCertChallengeRefresher_RenewsOncePerPeriod(t *testing.T) {
|
||||
r, clock := scheduleRefresher(func(string) bool { return true })
|
||||
track(r, "account-a")
|
||||
|
||||
assert.Empty(t, r.takeDue(), "an account just tracked is not due before its offset elapses")
|
||||
|
||||
clock.Advance(r.period)
|
||||
assert.Equal(t, []string{"account-a"}, dueAccounts(r), "the account is due once its offset has elapsed")
|
||||
|
||||
clock.Advance(r.period / 2)
|
||||
assert.Empty(t, r.takeDue(), "the next run is booked a full period out")
|
||||
|
||||
clock.Advance(r.period / 2)
|
||||
assert.Equal(t, []string{"account-a"}, dueAccounts(r), "the account is due again one period later")
|
||||
}
|
||||
|
||||
func TestCertChallengeRefresher_BooksTheNextRunBeforeRefreshing(t *testing.T) {
|
||||
// takeDue reserves the next run while it holds the lock, so a refresh that outlives
|
||||
// a tick cannot have the same account handed out twice.
|
||||
r, clock := scheduleRefresher(func(string) bool { return true })
|
||||
track(r, "account-a")
|
||||
clock.Advance(r.period)
|
||||
|
||||
require.Len(t, r.takeDue(), 1, "the account is due")
|
||||
assert.Empty(t, r.takeDue(), "a second pass at the same instant must not hand out the account again")
|
||||
}
|
||||
|
||||
func TestCertChallengeRefresher_TrackIsIdempotent(t *testing.T) {
|
||||
r, clock := scheduleRefresher(func(string) bool { return true })
|
||||
|
||||
track(r, "account-a")
|
||||
first := r.dueAt("account-a")
|
||||
|
||||
clock.Advance(certChallengePeriod())
|
||||
track(r, "account-a")
|
||||
|
||||
assert.Equal(t, first, r.dueAt("account-a"), "re-tracking must not push the next run further out")
|
||||
}
|
||||
|
||||
func TestCertChallengeRefresher_RefreshesATrackedAccount(t *testing.T) {
|
||||
refreshed := make(chan string, 4)
|
||||
r := runningRefresher(t, func(accountID string) bool {
|
||||
select {
|
||||
case refreshed <- accountID:
|
||||
default:
|
||||
}
|
||||
return true
|
||||
})
|
||||
track(r, "account-a")
|
||||
|
||||
select {
|
||||
case got := <-refreshed:
|
||||
assert.Equal(t, "account-a", got)
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("a tracked account was never refreshed")
|
||||
}
|
||||
assert.True(t, r.tracked("account-a"), "an account that still wants challenges stays tracked")
|
||||
}
|
||||
|
||||
func TestCertChallengeRefresher_DropsAnAccountThatNoLongerWantsChallenges(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var calls int
|
||||
|
||||
r := runningRefresher(t, func(string) bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
calls++
|
||||
return false
|
||||
})
|
||||
track(r, "account-a")
|
||||
|
||||
require.Eventually(t, func() bool { return !r.tracked("account-a") }, 3*time.Second, time.Millisecond,
|
||||
"an account whose refresh reports it no longer wants challenges must be dropped")
|
||||
|
||||
time.Sleep(20 * r.tick)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
assert.Equal(t, 1, calls, "the account must not be refreshed again after being dropped")
|
||||
}
|
||||
|
||||
func TestCertChallengeRefresher_RefreshesWithoutHoldingTheLock(t *testing.T) {
|
||||
// The refresh fans out to every peer of the account, so holding the lock across it
|
||||
// would stall every peer that connects meanwhile. Calling back into the refresher
|
||||
// from inside the refresh deadlocks if the loop still holds it.
|
||||
reentered := make(chan bool, 1)
|
||||
var once sync.Once
|
||||
|
||||
r := newCertChallengeRefresher(nil)
|
||||
r.period = 10 * time.Millisecond
|
||||
r.tick = time.Millisecond
|
||||
r.refresh = func(_ context.Context, accountID string, _ []string) bool {
|
||||
once.Do(func() {
|
||||
answered := make(chan bool, 1)
|
||||
go func() { answered <- r.tracked(accountID) }()
|
||||
select {
|
||||
case got := <-answered:
|
||||
reentered <- got
|
||||
case <-time.After(2 * time.Second):
|
||||
reentered <- false
|
||||
}
|
||||
})
|
||||
return true
|
||||
}
|
||||
r.Start(t.Context())
|
||||
track(r, "account-a")
|
||||
|
||||
select {
|
||||
case ok := <-reentered:
|
||||
assert.True(t, ok, "the refresher was not reachable while a refresh was in flight")
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the refresh never ran")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCertificateChallengeTargets_MatchesThePerPeerRule holds the renewal to the same
|
||||
// rule management uses when it hands out the challenge. The refresher asks "which peers
|
||||
// get one", processPeerPostureChecks asks "does this peer get one", and the two have to
|
||||
// answer identically: a peer the refresher forgets stops being renewed and silently
|
||||
// falls out of its policies, which is the failure the refresher exists to prevent.
|
||||
func TestCertificateChallengeTargets_MatchesThePerPeerRule(t *testing.T) {
|
||||
const certCheck, osCheck = "check-cert", "check-os"
|
||||
certCheckIDs := map[string]struct{}{certCheck: {}}
|
||||
|
||||
groupPeers := map[string][]string{
|
||||
"g-devs": {"p1", "p2"},
|
||||
"g-servers": {"p3"},
|
||||
"g-empty": {},
|
||||
"g-mixed": {"p2", "p4"},
|
||||
}
|
||||
peerGroups := map[string][]string{
|
||||
"p1": {"g-devs"},
|
||||
"p2": {"g-devs", "g-mixed"},
|
||||
"p3": {"g-servers"},
|
||||
"p4": {"g-mixed"},
|
||||
"p5": {},
|
||||
}
|
||||
|
||||
policies := []*types.Policy{
|
||||
{
|
||||
ID: "gated", Enabled: true, SourcePostureChecks: []string{certCheck},
|
||||
Rules: []*types.PolicyRule{{Enabled: true, Sources: []string{"g-devs"}}},
|
||||
},
|
||||
{
|
||||
// A disabled policy hands out nothing.
|
||||
ID: "disabled-policy", Enabled: false, SourcePostureChecks: []string{certCheck},
|
||||
Rules: []*types.PolicyRule{{Enabled: true, Sources: []string{"g-servers"}}},
|
||||
},
|
||||
{
|
||||
// A disabled rule inside an enabled policy likewise.
|
||||
ID: "disabled-rule", Enabled: true, SourcePostureChecks: []string{certCheck},
|
||||
Rules: []*types.PolicyRule{{Enabled: false, Sources: []string{"g-servers"}}},
|
||||
},
|
||||
{
|
||||
// Gated on something other than a certificate: no nonce to renew.
|
||||
ID: "other-check", Enabled: true, SourcePostureChecks: []string{osCheck},
|
||||
Rules: []*types.PolicyRule{{Enabled: true, Sources: []string{"g-mixed"}}},
|
||||
},
|
||||
{
|
||||
// A peer named directly rather than through a group.
|
||||
ID: "by-resource", Enabled: true, SourcePostureChecks: []string{osCheck, certCheck},
|
||||
Rules: []*types.PolicyRule{{
|
||||
Enabled: true,
|
||||
SourceResource: types.Resource{Type: types.ResourceTypePeer, ID: "p4"},
|
||||
}},
|
||||
},
|
||||
{
|
||||
// Destinations are never filtered, so being one earns no challenge.
|
||||
ID: "as-destination", Enabled: true, SourcePostureChecks: []string{certCheck},
|
||||
Rules: []*types.PolicyRule{{Enabled: true, Sources: []string{"g-empty"}, Destinations: []string{"g-servers"}}},
|
||||
},
|
||||
}
|
||||
|
||||
got := certificateChallengeTargets(policies, groupPeers, certCheckIDs)
|
||||
|
||||
// The same question, asked one peer at a time the way the gRPC layer asks it.
|
||||
var want []string
|
||||
for peerID, groupIDs := range peerGroups {
|
||||
for _, policy := range policies {
|
||||
if !policy.Enabled {
|
||||
continue
|
||||
}
|
||||
ids := processPeerPostureChecks(policy, peerID, groupIDs)
|
||||
if slices.ContainsFunc(ids, func(id string) bool { _, ok := certCheckIDs[id]; return ok }) {
|
||||
want = append(want, peerID)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
slices.Sort(got)
|
||||
slices.Sort(want)
|
||||
assert.Equal(t, want, got, "the peers the refresher renews must be exactly the peers that are sent a challenge")
|
||||
assert.Equal(t, []string{"p1", "p2", "p4"}, got, "p3 is only reachable through disabled rules, p5 is in no source group")
|
||||
}
|
||||
|
||||
func TestCertChallengeRefresher_StopsWithItsContext(t *testing.T) {
|
||||
// BuildManager hands the loop the manager's context. A refresher that detached from
|
||||
// it could never be stopped: it would keep sweeping after the server it belongs to
|
||||
// is gone, and leak a goroutine per manager a test builds.
|
||||
var mu sync.Mutex
|
||||
var calls int
|
||||
|
||||
r := newCertChallengeRefresher(func(_ context.Context, _ string, _ []string) bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
calls++
|
||||
return true
|
||||
})
|
||||
r.period = time.Millisecond
|
||||
r.tick = time.Millisecond
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
r.Start(ctx)
|
||||
track(r, "account-a")
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return calls > 0
|
||||
}, 3*time.Second, time.Millisecond, "the refresher never ran")
|
||||
|
||||
cancel()
|
||||
time.Sleep(20 * r.tick)
|
||||
mu.Lock()
|
||||
afterCancel := calls
|
||||
mu.Unlock()
|
||||
|
||||
time.Sleep(20 * r.tick)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
assert.Equal(t, afterCancel, calls, "the refresher kept sweeping after its context was cancelled")
|
||||
}
|
||||
|
||||
func TestCertChallengeRefresh_StaysWithinTheSweep(t *testing.T) {
|
||||
assert.Equal(t, maxCertChallengeRefresh, certChallengeRefresh(time.Hour),
|
||||
"a long tick must not grant a store read minutes")
|
||||
assert.Equal(t, time.Second, certChallengeRefresh(time.Second),
|
||||
"a short tick must bound the refresh to itself, so the sweep keeps its cadence")
|
||||
}
|
||||
|
||||
func TestCertChallengeRefresher_AbandonsAWedgedAccount(t *testing.T) {
|
||||
// Accounts are swept one after another and resolving the targets reads the store, so
|
||||
// an account whose read never returns would hold up every other account on the
|
||||
// instance for as long as the process lives.
|
||||
entered := make(chan struct{}, 1)
|
||||
refreshed := make(chan string, 4)
|
||||
|
||||
r := newCertChallengeRefresher(func(ctx context.Context, accountID string, _ []string) bool {
|
||||
if accountID == "account-wedged" {
|
||||
select {
|
||||
case entered <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
<-ctx.Done()
|
||||
return true
|
||||
}
|
||||
select {
|
||||
case refreshed <- accountID:
|
||||
default:
|
||||
}
|
||||
return true
|
||||
})
|
||||
r.period = 10 * time.Millisecond
|
||||
r.tick = time.Millisecond
|
||||
r.timeout = 20 * time.Millisecond
|
||||
r.Start(t.Context())
|
||||
|
||||
track(r, "account-wedged")
|
||||
select {
|
||||
case <-entered:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("the wedged account was never refreshed")
|
||||
}
|
||||
|
||||
// Tracked only once the sweep is already stuck, so reaching it proves the deadline
|
||||
// released the loop rather than the map happening to hand this one out first.
|
||||
track(r, "account-healthy")
|
||||
select {
|
||||
case got := <-refreshed:
|
||||
assert.Equal(t, "account-healthy", got)
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("a healthy account was never refreshed from behind a wedged one")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCertChallengeRefresher_KeepsAnAccountTrackedDuringItsRefresh: a refresh reads the
|
||||
// store before an admin adds a certificate check; the push that follows the change
|
||||
// tracks the account again. The refresh then reports the account as not wanting
|
||||
// challenges, which is stale, and must not untrack it.
|
||||
func TestCertChallengeRefresher_KeepsAnAccountTrackedDuringItsRefresh(t *testing.T) {
|
||||
r, clock := scheduleRefresher(func(string) bool { return false })
|
||||
track(r, "account-a")
|
||||
clock.Advance(r.period)
|
||||
require.Equal(t, []string{"account-a"}, dueAccounts(r), "the account is due")
|
||||
|
||||
track(r, "account-a")
|
||||
r.finish("account-a", false)
|
||||
assert.True(t, r.tracked("account-a"), "an account tracked again during its refresh stays tracked")
|
||||
|
||||
clock.Advance(r.period)
|
||||
require.Equal(t, []string{"account-a"}, dueAccounts(r), "the account is due again")
|
||||
r.finish("account-a", false)
|
||||
assert.False(t, r.tracked("account-a"), "an account nobody tracked again is dropped once it no longer wants challenges")
|
||||
}
|
||||
|
||||
// TestCertChallengeRefresher_RenewsOnlyThePeersStreamedHere: every instance runs its
|
||||
// own refresher, and a push fans out to all of them, so an instance must hand out only
|
||||
// the peers whose sync stream it holds. Otherwise each peer is pushed once per instance.
|
||||
func TestCertChallengeRefresher_RenewsOnlyThePeersStreamedHere(t *testing.T) {
|
||||
r, clock := scheduleRefresher(func(string) bool { return true })
|
||||
r.Track(context.Background(), "account-a", "peer-1", streamStart)
|
||||
r.Track(context.Background(), "account-a", "peer-2", streamStart)
|
||||
clock.Advance(r.period)
|
||||
|
||||
due := r.takeDue()
|
||||
require.Len(t, due, 1, "the account is due")
|
||||
assert.ElementsMatch(t, []string{"peer-1", "peer-2"}, due[0].peerIDs, "only the peers tracked here are renewed")
|
||||
}
|
||||
|
||||
// TestCertChallengeRefresher_DropsAnAccountWithItsLastPeer: an instance whose peers of an
|
||||
// account have all disconnected has nobody left to renew, and must not keep sweeping it.
|
||||
func TestCertChallengeRefresher_DropsAnAccountWithItsLastPeer(t *testing.T) {
|
||||
r, _ := scheduleRefresher(func(string) bool { return true })
|
||||
r.Track(context.Background(), "account-a", "peer-1", streamStart)
|
||||
r.Track(context.Background(), "account-a", "peer-2", streamStart)
|
||||
|
||||
r.Untrack("account-a", "peer-1", streamStart)
|
||||
assert.True(t, r.tracked("account-a"), "the account stays tracked while a peer is still streamed here")
|
||||
|
||||
r.Untrack("account-a", "peer-2", streamStart)
|
||||
assert.False(t, r.tracked("account-a"), "the account is dropped with its last peer")
|
||||
}
|
||||
|
||||
// TestCertChallengeRefresher_StaleStreamKeepsTheNewerOne: a peer that reconnects to the
|
||||
// same instance opens its new stream before the old one is torn down. The old stream's
|
||||
// teardown must not untrack the peer the new stream is renewing.
|
||||
func TestCertChallengeRefresher_StaleStreamKeepsTheNewerOne(t *testing.T) {
|
||||
r, _ := scheduleRefresher(func(string) bool { return true })
|
||||
newer := streamStart.Add(time.Minute)
|
||||
r.Track(context.Background(), "account-a", "peer-1", streamStart)
|
||||
r.Track(context.Background(), "account-a", "peer-1", newer)
|
||||
|
||||
r.Untrack("account-a", "peer-1", streamStart)
|
||||
assert.True(t, r.tracked("account-a"), "the stale stream's teardown must leave the newer stream tracked")
|
||||
|
||||
r.Untrack("account-a", "peer-1", newer)
|
||||
assert.False(t, r.tracked("account-a"), "the newer stream's teardown untracks the peer")
|
||||
}
|
||||
|
||||
// TestCertChallengeRefresher_LateStampFromAStaleStream: the old stream of a reconnected
|
||||
// peer can still deliver a stamped update after the new stream started. Its Track must
|
||||
// not take the peer over, or the old stream's teardown would untrack the live one.
|
||||
func TestCertChallengeRefresher_LateStampFromAStaleStream(t *testing.T) {
|
||||
r, _ := scheduleRefresher(func(string) bool { return true })
|
||||
newer := streamStart.Add(time.Minute)
|
||||
r.Track(context.Background(), "account-a", "peer-1", newer)
|
||||
|
||||
r.Track(context.Background(), "account-a", "peer-1", streamStart)
|
||||
r.Untrack("account-a", "peer-1", streamStart)
|
||||
assert.True(t, r.tracked("account-a"), "a late stamp from the stale stream must leave the newer stream tracked")
|
||||
}
|
||||
+146
-143
@@ -6,8 +6,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"go.uber.org/mock/gomock"
|
||||
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
ephemeral_manager "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager"
|
||||
"github.com/netbirdio/netbird/management/internals/server/config"
|
||||
"github.com/netbirdio/netbird/management/server/cache"
|
||||
"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
|
||||
"github.com/netbirdio/netbird/management/server/job"
|
||||
"github.com/netbirdio/netbird/management/server/permissions"
|
||||
"github.com/netbirdio/netbird/management/server/settings"
|
||||
@@ -234,9 +233,9 @@ func createDNSManager(t *testing.T) (*DefaultAccountManager, error) {
|
||||
|
||||
updateManager := update_channel.NewPeersUpdateManager(metrics)
|
||||
requestBuffer := NewAccountRequestBuffer(ctx, store)
|
||||
networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.test", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nil)
|
||||
networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.test", ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nil)
|
||||
|
||||
return BuildManager(context.Background(), nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
|
||||
return BuildManager(context.Background(), nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, settingsMockManager, permissionsManager, false, cacheStore)
|
||||
}
|
||||
|
||||
func createDNSStore(t *testing.T) (store.Store, error) {
|
||||
@@ -363,163 +362,167 @@ func initTestDNSAccount(t *testing.T, am *DefaultAccountManager) (*types.Account
|
||||
}
|
||||
|
||||
func TestDNSAccountPeersUpdate(t *testing.T) {
|
||||
manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t)
|
||||
runPeerUpdateTest(t, func(t *testing.T) {
|
||||
manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t)
|
||||
|
||||
err := manager.CreateGroups(context.Background(), account.Id, userID, []*types.Group{
|
||||
{
|
||||
ID: "groupA",
|
||||
Name: "GroupA",
|
||||
Peers: []string{},
|
||||
},
|
||||
{
|
||||
ID: "groupB",
|
||||
Name: "GroupB",
|
||||
Peers: []string{},
|
||||
},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
updMsg := updateManager.CreateChannel(context.Background(), peer1.ID)
|
||||
t.Cleanup(func() {
|
||||
updateManager.CloseChannel(context.Background(), peer1.ID)
|
||||
})
|
||||
|
||||
// Saving DNS settings with groups that have no peers should not trigger updates to account peers or send peer updates
|
||||
t.Run("saving dns setting with unused groups", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldNotReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
err := manager.SaveDNSSettings(context.Background(), account.Id, userID, &types.DNSSettings{
|
||||
DisabledManagementGroups: []string{"groupA"},
|
||||
err := manager.CreateGroups(context.Background(), account.Id, userID, []*types.Group{
|
||||
{
|
||||
ID: "groupA",
|
||||
Name: "GroupA",
|
||||
Peers: []string{},
|
||||
},
|
||||
{
|
||||
ID: "groupB",
|
||||
Name: "GroupB",
|
||||
Peers: []string{},
|
||||
},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("timeout waiting for peerShouldNotReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
// Creating DNS settings with groups that have no peers should not update account peers or send peer update
|
||||
t.Run("creating dns setting with unused groups", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldNotReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
_, err = manager.CreateNameServerGroup(
|
||||
context.Background(), account.Id, "ns-group", "ns-group", []nbdns.NameServer{{
|
||||
IP: netip.MustParseAddr(peer1.IP.String()),
|
||||
NSType: nbdns.UDPNameServerType,
|
||||
Port: nbdns.DefaultDNSPort,
|
||||
}},
|
||||
[]string{"groupB"},
|
||||
true, []string{}, true, userID, false,
|
||||
)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("timeout waiting for peerShouldNotReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
// Creating DNS settings with groups that have peers should update account peers and send peer update
|
||||
t.Run("creating dns setting with used groups", func(t *testing.T) {
|
||||
err = manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{
|
||||
ID: "groupA",
|
||||
Name: "GroupA",
|
||||
Peers: []string{peer1.ID, peer2.ID, peer3.ID},
|
||||
updMsg := updateManager.CreateChannel(context.Background(), peer1.ID)
|
||||
t.Cleanup(func() {
|
||||
updateManager.CloseChannel(context.Background(), peer1.ID)
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
// Saving DNS settings with groups that have no peers should not trigger updates to account peers or send peer updates
|
||||
step(t, "saving dns setting with unused groups", func(t *testing.T) {
|
||||
settleAffectedUpdates(updMsg)
|
||||
|
||||
_, err = manager.CreateNameServerGroup(
|
||||
context.Background(), account.Id, "ns-group-1", "ns-group-1", []nbdns.NameServer{{
|
||||
IP: netip.MustParseAddr(peer1.IP.String()),
|
||||
NSType: nbdns.UDPNameServerType,
|
||||
Port: nbdns.DefaultDNSPort,
|
||||
}},
|
||||
[]string{"groupA"},
|
||||
true, []string{}, true, userID, false,
|
||||
)
|
||||
assert.NoError(t, err)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldNotReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
err := manager.SaveDNSSettings(context.Background(), account.Id, userID, &types.DNSSettings{
|
||||
DisabledManagementGroups: []string{"groupA"},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Saving DNS settings with groups that have peers should update account peers and send peer update
|
||||
t.Run("saving dns setting with used groups", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
err := manager.SaveDNSSettings(context.Background(), account.Id, userID, &types.DNSSettings{
|
||||
DisabledManagementGroups: []string{"groupA", "groupB"},
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("timeout waiting for peerShouldNotReceiveUpdate")
|
||||
}
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
// Creating DNS settings with groups that have no peers should not update account peers or send peer update
|
||||
step(t, "creating dns setting with unused groups", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldNotReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
// Removing group with no peers from DNS settings should not trigger updates to account peers or send peer updates
|
||||
t.Run("removing group with no peers from dns settings", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldNotReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
_, err = manager.CreateNameServerGroup(
|
||||
context.Background(), account.Id, "ns-group", "ns-group", []nbdns.NameServer{{
|
||||
IP: netip.MustParseAddr(peer1.IP.String()),
|
||||
NSType: nbdns.UDPNameServerType,
|
||||
Port: nbdns.DefaultDNSPort,
|
||||
}},
|
||||
[]string{"groupB"},
|
||||
true, []string{}, true, userID, false,
|
||||
)
|
||||
assert.NoError(t, err)
|
||||
|
||||
err := manager.SaveDNSSettings(context.Background(), account.Id, userID, &types.DNSSettings{
|
||||
DisabledManagementGroups: []string{"groupA"},
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("timeout waiting for peerShouldNotReceiveUpdate")
|
||||
}
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("timeout waiting for peerShouldNotReceiveUpdate")
|
||||
}
|
||||
})
|
||||
// Creating DNS settings with groups that have peers should update account peers and send peer update
|
||||
step(t, "creating dns setting with used groups", func(t *testing.T) {
|
||||
err = manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{
|
||||
ID: "groupA",
|
||||
Name: "GroupA",
|
||||
Peers: []string{peer1.ID, peer2.ID, peer3.ID},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Removing group with peers from DNS settings should trigger updates to account peers and send peer updates
|
||||
t.Run("removing group with peers from dns settings", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
err := manager.SaveDNSSettings(context.Background(), account.Id, userID, &types.DNSSettings{
|
||||
DisabledManagementGroups: []string{},
|
||||
_, err = manager.CreateNameServerGroup(
|
||||
context.Background(), account.Id, "ns-group-1", "ns-group-1", []nbdns.NameServer{{
|
||||
IP: netip.MustParseAddr(peer1.IP.String()),
|
||||
NSType: nbdns.UDPNameServerType,
|
||||
Port: nbdns.DefaultDNSPort,
|
||||
}},
|
||||
[]string{"groupA"},
|
||||
true, []string{}, true, userID, false,
|
||||
)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
// Saving DNS settings with groups that have peers should update account peers and send peer update
|
||||
step(t, "saving dns setting with used groups", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
err := manager.SaveDNSSettings(context.Background(), account.Id, userID, &types.DNSSettings{
|
||||
DisabledManagementGroups: []string{"groupA", "groupB"},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
// Removing group with no peers from DNS settings should not trigger updates to account peers or send peer updates
|
||||
step(t, "removing group with no peers from dns settings", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldNotReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
err := manager.SaveDNSSettings(context.Background(), account.Id, userID, &types.DNSSettings{
|
||||
DisabledManagementGroups: []string{"groupA"},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("timeout waiting for peerShouldNotReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
// Removing group with peers from DNS settings should trigger updates to account peers and send peer updates
|
||||
step(t, "removing group with peers from dns settings", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
err := manager.SaveDNSSettings(context.Background(), account.Id, userID, &types.DNSSettings{
|
||||
DisabledManagementGroups: []string{},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
+50
-31
@@ -6,12 +6,12 @@ import (
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||
"github.com/rs/xid"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||
"github.com/netbirdio/netbird/management/server/activity"
|
||||
"github.com/netbirdio/netbird/management/server/affectedpeers"
|
||||
routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
|
||||
@@ -166,9 +166,11 @@ func (am *DefaultAccountManager) UpdateGroup(ctx context.Context, accountID, use
|
||||
return err
|
||||
}
|
||||
|
||||
if err = am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, []string{newGroup.ID}); err != nil {
|
||||
ipv6Changed, err := am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, []string{newGroup.ID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
change.ChangedPeerIDs = ipv6Changed
|
||||
|
||||
// A membership change does not alter which entities reference the group, so
|
||||
// the dependency walk runs once against the post-change snapshot. The new
|
||||
@@ -247,14 +249,14 @@ func (am *DefaultAccountManager) CreateGroups(ctx context.Context, accountID, us
|
||||
}
|
||||
|
||||
var eventsToStore []func()
|
||||
var snaps []*affectedpeers.Snapshot
|
||||
var changes []affectedpeers.Change
|
||||
affected := make(affectedPeerSet)
|
||||
|
||||
var globalErr error
|
||||
createdCount := 0
|
||||
for _, newGroup := range groups {
|
||||
change := affectedpeers.Change{ChangedGroupIDs: []string{newGroup.ID}}
|
||||
var snap *affectedpeers.Snapshot
|
||||
var events []func()
|
||||
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
|
||||
if err = validateNewGroup(ctx, transaction, accountID, newGroup); err != nil {
|
||||
return err
|
||||
@@ -262,6 +264,7 @@ func (am *DefaultAccountManager) CreateGroups(ctx context.Context, accountID, us
|
||||
|
||||
newGroup.AccountID = accountID
|
||||
newGroup.PublicID = xid.New().String()
|
||||
events = am.prepareGroupEvents(ctx, transaction, accountID, userID, newGroup)
|
||||
|
||||
if err = transaction.CreateGroup(ctx, newGroup); err != nil {
|
||||
return err
|
||||
@@ -272,14 +275,11 @@ func (am *DefaultAccountManager) CreateGroups(ctx context.Context, accountID, us
|
||||
return err
|
||||
}
|
||||
|
||||
events := am.prepareGroupEvents(ctx, transaction, accountID, userID, newGroup)
|
||||
eventsToStore = append(eventsToStore, events...)
|
||||
|
||||
snap, err = affectedpeers.Load(ctx, transaction, accountID, change)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to update group %s: %v", newGroup.ID, err)
|
||||
log.WithContext(ctx).Errorf("failed to create group %s: %v", newGroup.ID, err)
|
||||
if createdCount == 0 {
|
||||
return err
|
||||
}
|
||||
@@ -288,15 +288,15 @@ func (am *DefaultAccountManager) CreateGroups(ctx context.Context, accountID, us
|
||||
continue
|
||||
}
|
||||
createdCount++
|
||||
snaps = append(snaps, snap)
|
||||
changes = append(changes, change)
|
||||
affected.add(snap.Expand(ctx, accountID, change))
|
||||
eventsToStore = append(eventsToStore, events...)
|
||||
}
|
||||
|
||||
for _, storeEvent := range eventsToStore {
|
||||
storeEvent()
|
||||
}
|
||||
|
||||
go am.dispatchAffected(ctx, accountID, snaps, changes)
|
||||
go am.dispatchAffectedPeerIDs(ctx, accountID, affected.ids())
|
||||
|
||||
return globalErr
|
||||
}
|
||||
@@ -315,13 +315,12 @@ func (am *DefaultAccountManager) UpdateGroups(ctx context.Context, accountID, us
|
||||
}
|
||||
|
||||
var eventsToStore []func()
|
||||
var snaps []*affectedpeers.Snapshot
|
||||
var changes []affectedpeers.Change
|
||||
affected := make(affectedPeerSet)
|
||||
|
||||
var globalErr error
|
||||
for _, newGroup := range groups {
|
||||
change := affectedpeers.Change{ChangedGroupIDs: []string{newGroup.ID}}
|
||||
events, snap, err := am.updateSingleGroup(ctx, accountID, userID, newGroup, change)
|
||||
events, snap, change, err := am.updateSingleGroup(ctx, accountID, userID, newGroup, change)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to update group %s: %v", newGroup.ID, err)
|
||||
if len(groups) == 1 {
|
||||
@@ -331,20 +330,19 @@ func (am *DefaultAccountManager) UpdateGroups(ctx context.Context, accountID, us
|
||||
continue
|
||||
}
|
||||
eventsToStore = append(eventsToStore, events...)
|
||||
snaps = append(snaps, snap)
|
||||
changes = append(changes, change)
|
||||
affected.add(snap.Expand(ctx, accountID, change))
|
||||
}
|
||||
|
||||
for _, storeEvent := range eventsToStore {
|
||||
storeEvent()
|
||||
}
|
||||
|
||||
go am.dispatchAffected(ctx, accountID, snaps, changes)
|
||||
go am.dispatchAffectedPeerIDs(ctx, accountID, affected.ids())
|
||||
|
||||
return globalErr
|
||||
}
|
||||
|
||||
func (am *DefaultAccountManager) updateSingleGroup(ctx context.Context, accountID, userID string, newGroup *types.Group, change affectedpeers.Change) ([]func(), *affectedpeers.Snapshot, error) {
|
||||
func (am *DefaultAccountManager) updateSingleGroup(ctx context.Context, accountID, userID string, newGroup *types.Group, change affectedpeers.Change) ([]func(), *affectedpeers.Snapshot, affectedpeers.Change, error) {
|
||||
var events []func()
|
||||
var snap *affectedpeers.Snapshot
|
||||
err := am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
|
||||
@@ -360,24 +358,26 @@ func (am *DefaultAccountManager) updateSingleGroup(ctx context.Context, accountI
|
||||
}
|
||||
newGroup.PublicID = oldGroup.PublicID
|
||||
|
||||
events = am.prepareGroupEvents(ctx, transaction, accountID, userID, newGroup)
|
||||
|
||||
if err := transaction.UpdateGroup(ctx, newGroup); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, []string{newGroup.ID}); err != nil {
|
||||
ipv6Changed, err := am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, []string{newGroup.ID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
change.ChangedPeerIDs = ipv6Changed
|
||||
|
||||
if err := transaction.IncrementNetworkSerial(ctx, accountID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
events = am.prepareGroupEvents(ctx, transaction, accountID, userID, newGroup)
|
||||
|
||||
snap, err = affectedpeers.Load(ctx, transaction, accountID, change)
|
||||
return err
|
||||
})
|
||||
return events, snap, err
|
||||
return events, snap, change, err
|
||||
}
|
||||
|
||||
// prepareGroupEvents prepares a list of event functions to be stored.
|
||||
@@ -480,8 +480,8 @@ func (am *DefaultAccountManager) DeleteGroups(ctx context.Context, accountID, us
|
||||
var allErrors error
|
||||
var groupIDsToDelete []string
|
||||
var deletedGroups []*types.Group
|
||||
var snap *affectedpeers.Snapshot
|
||||
var change affectedpeers.Change
|
||||
var snap, ipv6Snap *affectedpeers.Snapshot
|
||||
var change, ipv6Change affectedpeers.Change
|
||||
|
||||
extraSettings, err := am.settingsManager.GetExtraSettings(ctx, accountID)
|
||||
if err != nil {
|
||||
@@ -510,10 +510,20 @@ func (am *DefaultAccountManager) DeleteGroups(ctx context.Context, accountID, us
|
||||
return err
|
||||
}
|
||||
|
||||
if err = am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, groupIDsToDelete); err != nil {
|
||||
ipv6Changed, err := am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, groupIDsToDelete)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Members of a deleted IPv6-enabled group lose their address, which the
|
||||
// pre-delete snapshot cannot see, so they are resolved post-delete.
|
||||
if len(ipv6Changed) > 0 {
|
||||
ipv6Change = affectedpeers.Change{ChangedPeerIDs: ipv6Changed}
|
||||
if ipv6Snap, err = affectedpeers.Load(ctx, transaction, accountID, ipv6Change); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return transaction.IncrementNetworkSerial(ctx, accountID)
|
||||
})
|
||||
if err != nil {
|
||||
@@ -524,7 +534,10 @@ func (am *DefaultAccountManager) DeleteGroups(ctx context.Context, accountID, us
|
||||
am.StoreEvent(ctx, userID, group.ID, accountID, activity.GroupDeleted, group.EventMeta())
|
||||
}
|
||||
|
||||
am.ExpandAndUpdateAffected(ctx, accountID, snap, change)
|
||||
affected := make(affectedPeerSet)
|
||||
affected.add(snap.Expand(ctx, accountID, change))
|
||||
affected.add(ipv6Snap.Expand(ctx, accountID, ipv6Change))
|
||||
go am.dispatchAffectedPeerIDs(ctx, accountID, affected.ids())
|
||||
|
||||
return allErrors
|
||||
}
|
||||
@@ -564,11 +577,14 @@ func (am *DefaultAccountManager) GroupAddPeer(ctx context.Context, accountID, gr
|
||||
return err
|
||||
}
|
||||
|
||||
if err := am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, []string{groupID}); err != nil {
|
||||
ipv6Changed, err := am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, []string{groupID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// A peer whose IPv6 address changed is visible to every peer that reaches it
|
||||
// through any of its groups, not only through this one.
|
||||
change.ChangedPeerIDs = ipv6Changed
|
||||
|
||||
var err error
|
||||
if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -634,11 +650,14 @@ func (am *DefaultAccountManager) GroupDeletePeer(ctx context.Context, accountID,
|
||||
return err
|
||||
}
|
||||
|
||||
if err := am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, []string{groupID}); err != nil {
|
||||
ipv6Changed, err := am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, []string{groupID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// A peer whose IPv6 address changed is visible to every peer that reaches it
|
||||
// through any of its groups, not only through this one.
|
||||
change.ChangedPeerIDs = ipv6Changed
|
||||
|
||||
var err error
|
||||
if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+466
-306
@@ -20,9 +20,11 @@ import (
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||
"github.com/netbirdio/netbird/management/server/activity"
|
||||
"github.com/netbirdio/netbird/management/server/groups"
|
||||
"github.com/netbirdio/netbird/management/server/networks"
|
||||
"github.com/netbirdio/netbird/management/server/networks/resources"
|
||||
resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
|
||||
"github.com/netbirdio/netbird/management/server/networks/routers"
|
||||
routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
|
||||
networkTypes "github.com/netbirdio/netbird/management/server/networks/types"
|
||||
@@ -76,6 +78,42 @@ func TestDefaultAccountManager_CreateGroup(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultAccountManager_BatchGroupChangesStoreEvents(t *testing.T) {
|
||||
t.Setenv("NB_EVENT_ACTIVITY_LOG_ENABLED", "true")
|
||||
|
||||
am, _, err := createManager(t)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, account, err := initTestGroupAccount(am)
|
||||
require.NoError(t, err)
|
||||
|
||||
group := &types.Group{ID: "grp-batch", Name: "Batch group", Issued: types.GroupIssuedAPI}
|
||||
err = am.CreateGroups(context.Background(), account.Id, groupAdminUserID, []*types.Group{group})
|
||||
require.NoError(t, err)
|
||||
|
||||
renamed := &types.Group{ID: group.ID, Name: "Batch group renamed", Issued: types.GroupIssuedAPI}
|
||||
err = am.UpdateGroups(context.Background(), account.Id, groupAdminUserID, []*types.Group{renamed})
|
||||
require.NoError(t, err)
|
||||
|
||||
hasEvent := func(code activity.Activity) bool {
|
||||
events, err := am.eventStore.Get(context.Background(), account.Id, 0, 100, true)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, event := range events {
|
||||
if event.Activity == code && event.TargetID == group.ID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
assert.Eventually(t, func() bool { return hasEvent(activity.GroupCreated) }, time.Second, 10*time.Millisecond,
|
||||
"CreateGroups should store a group created event")
|
||||
assert.Eventually(t, func() bool { return hasEvent(activity.GroupUpdated) }, time.Second, 10*time.Millisecond,
|
||||
"UpdateGroups should store a group updated event on rename")
|
||||
}
|
||||
|
||||
func TestDefaultAccountManager_DeleteGroup(t *testing.T) {
|
||||
am, _, err := createManager(t)
|
||||
if err != nil {
|
||||
@@ -685,342 +723,368 @@ func initTestGroupAccount(am *DefaultAccountManager) (*DefaultAccountManager, *t
|
||||
}
|
||||
|
||||
func TestGroupAccountPeersUpdate(t *testing.T) {
|
||||
manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t)
|
||||
runPeerUpdateTest(t, func(t *testing.T) {
|
||||
manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t)
|
||||
|
||||
g := []*types.Group{
|
||||
{
|
||||
ID: "groupA",
|
||||
Name: "GroupA",
|
||||
Peers: []string{peer1.ID, peer2.ID},
|
||||
},
|
||||
{
|
||||
ID: "groupB",
|
||||
Name: "GroupB",
|
||||
Peers: []string{},
|
||||
},
|
||||
{
|
||||
ID: "groupC",
|
||||
Name: "GroupC",
|
||||
Peers: []string{peer1.ID, peer3.ID},
|
||||
},
|
||||
{
|
||||
ID: "groupD",
|
||||
Name: "GroupD",
|
||||
Peers: []string{},
|
||||
},
|
||||
{
|
||||
ID: "groupE",
|
||||
Name: "GroupE",
|
||||
Peers: []string{peer2.ID},
|
||||
},
|
||||
}
|
||||
for _, group := range g {
|
||||
err := manager.CreateGroup(context.Background(), account.Id, userID, group)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
updMsg := updateManager.CreateChannel(context.Background(), peer1.ID)
|
||||
t.Cleanup(func() {
|
||||
updateManager.CloseChannel(context.Background(), peer1.ID)
|
||||
})
|
||||
|
||||
// Saving a group that is not linked to any resource should not update account peers
|
||||
t.Run("saving unlinked group", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldNotReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
err := manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{
|
||||
ID: "groupB",
|
||||
Name: "GroupB",
|
||||
Peers: []string{peer1.ID, peer2.ID},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("timeout waiting for peerShouldNotReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
// Adding a peer to a group that is not linked to any resource should not update account peers
|
||||
// and not send peer update
|
||||
t.Run("adding peer to unlinked group", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldNotReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
err := manager.GroupAddPeer(context.Background(), account.Id, "groupB", peer3.ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("timeout waiting for peerShouldNotReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
// Removing a peer from a group that is not linked to any resource should not update account peers
|
||||
// and not send peer update
|
||||
t.Run("removing peer from unliked group", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldNotReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
err := manager.GroupDeletePeer(context.Background(), account.Id, "groupB", peer3.ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("timeout waiting for peerShouldNotReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
// Deleting group should not update account peers and not send peer update
|
||||
t.Run("deleting group", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldNotReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
err := manager.DeleteGroup(context.Background(), account.Id, userID, "groupB")
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("timeout waiting for peerShouldNotReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
// adding a group to policy
|
||||
_, err := manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{
|
||||
Enabled: true,
|
||||
Rules: []*types.PolicyRule{
|
||||
g := []*types.Group{
|
||||
{
|
||||
Enabled: true,
|
||||
Sources: []string{"groupA"},
|
||||
Destinations: []string{"groupA"},
|
||||
Bidirectional: true,
|
||||
Action: types.PolicyTrafficActionAccept,
|
||||
ID: "groupA",
|
||||
Name: "GroupA",
|
||||
Peers: []string{peer1.ID, peer2.ID},
|
||||
},
|
||||
{
|
||||
ID: "groupB",
|
||||
Name: "GroupB",
|
||||
Peers: []string{},
|
||||
},
|
||||
{
|
||||
ID: "groupC",
|
||||
Name: "GroupC",
|
||||
Peers: []string{peer1.ID, peer3.ID},
|
||||
},
|
||||
{
|
||||
ID: "groupD",
|
||||
Name: "GroupD",
|
||||
Peers: []string{},
|
||||
},
|
||||
{
|
||||
ID: "groupE",
|
||||
Name: "GroupE",
|
||||
Peers: []string{peer2.ID},
|
||||
},
|
||||
},
|
||||
}, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Saving a group linked to policy should update account peers and send peer update
|
||||
t.Run("saving linked group to policy", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
err := manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{
|
||||
ID: "groupA",
|
||||
Name: "GroupA",
|
||||
Peers: []string{peer1.ID, peer2.ID},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
// adding peer to a used group should update account peers and send peer update
|
||||
t.Run("adding peer to linked group", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
err := manager.GroupAddPeer(context.Background(), account.Id, "groupA", peer3.ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
for _, group := range g {
|
||||
err := manager.CreateGroup(context.Background(), account.Id, userID, group)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
|
||||
// removing peer from a linked group should update account peers and send peer update
|
||||
t.Run("removing peer from linked group", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
updMsg := updateManager.CreateChannel(context.Background(), peer1.ID)
|
||||
t.Cleanup(func() {
|
||||
updateManager.CloseChannel(context.Background(), peer1.ID)
|
||||
})
|
||||
|
||||
err := manager.GroupDeletePeer(context.Background(), account.Id, "groupA", peer3.ID)
|
||||
// Saving a group that is not linked to any resource should not update account peers
|
||||
step(t, "saving unlinked group", func(t *testing.T) {
|
||||
settleAffectedUpdates(updMsg)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldNotReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
err := manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{
|
||||
ID: "groupB",
|
||||
Name: "GroupB",
|
||||
Peers: []string{peer1.ID, peer2.ID},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("timeout waiting for peerShouldNotReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
// Adding a peer to a group that is not linked to any resource should not update account peers
|
||||
// and not send peer update
|
||||
step(t, "adding peer to unlinked group", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldNotReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
err := manager.GroupAddPeer(context.Background(), account.Id, "groupB", peer3.ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("timeout waiting for peerShouldNotReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
// Removing a peer from a group that is not linked to any resource should not update account peers
|
||||
// and not send peer update
|
||||
step(t, "removing peer from unliked group", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldNotReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
err := manager.GroupDeletePeer(context.Background(), account.Id, "groupB", peer3.ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("timeout waiting for peerShouldNotReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
// Deleting group should not update account peers and not send peer update
|
||||
step(t, "deleting group", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldNotReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
err := manager.DeleteGroup(context.Background(), account.Id, userID, "groupB")
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("timeout waiting for peerShouldNotReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
// adding a group to policy
|
||||
_, err := manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{
|
||||
Enabled: true,
|
||||
Rules: []*types.PolicyRule{
|
||||
{
|
||||
Enabled: true,
|
||||
Sources: []string{"groupA"},
|
||||
Destinations: []string{"groupA"},
|
||||
Bidirectional: true,
|
||||
Action: types.PolicyTrafficActionAccept,
|
||||
},
|
||||
},
|
||||
}, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
// Saving a group linked to policy should update account peers and send peer update
|
||||
step(t, "saving linked group to policy", func(t *testing.T) {
|
||||
settleAffectedUpdates(updMsg)
|
||||
|
||||
// Saving a group linked to name server group should update account peers and send peer update
|
||||
t.Run("saving group linked to name server group", func(t *testing.T) {
|
||||
_, err = manager.CreateNameServerGroup(
|
||||
context.Background(), account.Id, "nsGroup", "nsGroup", []nbdns.NameServer{{
|
||||
IP: netip.MustParseAddr("1.1.1.1"),
|
||||
NSType: nbdns.UDPNameServerType,
|
||||
Port: nbdns.DefaultDNSPort,
|
||||
}},
|
||||
[]string{"groupC"},
|
||||
true, nil, true, userID, false,
|
||||
)
|
||||
assert.NoError(t, err)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
err := manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{
|
||||
ID: "groupA",
|
||||
Name: "GroupA",
|
||||
Peers: []string{peer1.ID, peer2.ID},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
err := manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{
|
||||
ID: "groupC",
|
||||
Name: "GroupC",
|
||||
Peers: []string{peer1.ID, peer3.ID},
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
// adding peer to a used group should update account peers and send peer update
|
||||
step(t, "adding peer to linked group", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
// Saving a group linked to route should update account peers and send peer update
|
||||
t.Run("saving group linked to route", func(t *testing.T) {
|
||||
newRoute := route.Route{
|
||||
ID: "route",
|
||||
Network: netip.MustParsePrefix("192.168.0.0/16"),
|
||||
NetID: "superNet",
|
||||
NetworkType: route.IPv4Network,
|
||||
PeerGroups: []string{"groupA"},
|
||||
Description: "super",
|
||||
Masquerade: false,
|
||||
Metric: 9999,
|
||||
Enabled: true,
|
||||
Groups: []string{"groupC"},
|
||||
}
|
||||
_, err := manager.CreateRoute(
|
||||
context.Background(), account.Id, newRoute.Network, newRoute.NetworkType, newRoute.Domains, newRoute.Peer,
|
||||
newRoute.PeerGroups, newRoute.Description, newRoute.NetID, newRoute.Masquerade, newRoute.Metric,
|
||||
newRoute.Groups, []string{}, true, userID, newRoute.KeepRoute, newRoute.SkipAutoApply,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
err := manager.GroupAddPeer(context.Background(), account.Id, "groupA", peer3.ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
err = manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{
|
||||
ID: "groupA",
|
||||
Name: "GroupA",
|
||||
Peers: []string{peer1.ID, peer2.ID, peer3.ID},
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
// removing peer from a linked group should update account peers and send peer update
|
||||
step(t, "removing peer from linked group", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
// Saving a group linked to dns settings should update account peers and send peer update
|
||||
t.Run("saving group linked to dns settings", func(t *testing.T) {
|
||||
err := manager.SaveDNSSettings(context.Background(), account.Id, userID, &types.DNSSettings{
|
||||
DisabledManagementGroups: []string{"groupD"},
|
||||
err := manager.GroupDeletePeer(context.Background(), account.Id, "groupA", peer3.ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
// Saving a group linked to name server group should update account peers and send peer update
|
||||
step(t, "saving group linked to name server group", func(t *testing.T) {
|
||||
_, err = manager.CreateNameServerGroup(
|
||||
context.Background(), account.Id, "nsGroup", "nsGroup", []nbdns.NameServer{{
|
||||
IP: netip.MustParseAddr("1.1.1.1"),
|
||||
NSType: nbdns.UDPNameServerType,
|
||||
Port: nbdns.DefaultDNSPort,
|
||||
}},
|
||||
[]string{"groupC"},
|
||||
true, nil, true, userID, false,
|
||||
)
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{
|
||||
ID: "groupD",
|
||||
Name: "GroupD",
|
||||
Peers: []string{peer1.ID},
|
||||
settleAffectedUpdates(updMsg)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
err := manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{
|
||||
ID: "groupC",
|
||||
Name: "GroupC",
|
||||
Peers: []string{peer1.ID, peer3.ID},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
// Saving a group linked to route should update account peers and send peer update
|
||||
step(t, "saving group linked to route", func(t *testing.T) {
|
||||
newRoute := route.Route{
|
||||
ID: "route",
|
||||
Network: netip.MustParsePrefix("192.168.0.0/16"),
|
||||
NetID: "superNet",
|
||||
NetworkType: route.IPv4Network,
|
||||
PeerGroups: []string{"groupA"},
|
||||
Description: "super",
|
||||
Masquerade: false,
|
||||
Metric: 9999,
|
||||
Enabled: true,
|
||||
Groups: []string{"groupC"},
|
||||
}
|
||||
_, err := manager.CreateRoute(
|
||||
context.Background(), account.Id, newRoute.Network, newRoute.NetworkType, newRoute.Domains, newRoute.Peer,
|
||||
newRoute.PeerGroups, newRoute.Description, newRoute.NetID, newRoute.Masquerade, newRoute.Metric,
|
||||
newRoute.Groups, []string{}, true, userID, newRoute.KeepRoute, newRoute.SkipAutoApply,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Saving a group linked to network router should update account peers and send peer update
|
||||
t.Run("saving group linked to network router", func(t *testing.T) {
|
||||
permissionsManager := permissions.NewManager(manager.Store)
|
||||
groupsManager := groups.NewManager(manager.Store, permissionsManager, manager)
|
||||
resourcesManager := resources.NewManager(manager.Store, permissionsManager, groupsManager, manager, manager.serviceManager)
|
||||
routersManager := routers.NewManager(manager.Store, permissionsManager, manager)
|
||||
networksManager := networks.NewManager(manager.Store, permissionsManager, resourcesManager, routersManager, manager)
|
||||
settleAffectedUpdates(updMsg)
|
||||
|
||||
network, err := networksManager.CreateNetwork(context.Background(), userID, &networkTypes.Network{
|
||||
ID: "network_test",
|
||||
AccountID: account.Id,
|
||||
Name: "network_test",
|
||||
Description: "",
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
err = manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{
|
||||
ID: "groupA",
|
||||
Name: "GroupA",
|
||||
Peers: []string{peer1.ID, peer2.ID, peer3.ID},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = routersManager.CreateRouter(context.Background(), userID, &routerTypes.NetworkRouter{
|
||||
ID: "router_test",
|
||||
NetworkID: network.ID,
|
||||
AccountID: account.Id,
|
||||
PeerGroups: []string{"groupE"},
|
||||
Masquerade: true,
|
||||
Metric: 9999,
|
||||
Enabled: true,
|
||||
// Saving a group linked to dns settings should update account peers and send peer update
|
||||
step(t, "saving group linked to dns settings", func(t *testing.T) {
|
||||
err := manager.SaveDNSSettings(context.Background(), account.Id, userID, &types.DNSSettings{
|
||||
DisabledManagementGroups: []string{"groupD"},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
settleAffectedUpdates(updMsg)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
err = manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{
|
||||
ID: "groupD",
|
||||
Name: "GroupD",
|
||||
Peers: []string{peer1.ID},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
// Saving a group linked to network router should update account peers and send peer update
|
||||
step(t, "saving group linked to network router", func(t *testing.T) {
|
||||
permissionsManager := permissions.NewManager(manager.Store)
|
||||
groupsManager := groups.NewManager(manager.Store, permissionsManager, manager)
|
||||
resourcesManager := resources.NewManager(manager.Store, permissionsManager, groupsManager, manager, manager.serviceManager)
|
||||
routersManager := routers.NewManager(manager.Store, permissionsManager, manager)
|
||||
networksManager := networks.NewManager(manager.Store, permissionsManager, resourcesManager, routersManager, manager)
|
||||
|
||||
err = manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{
|
||||
ID: "groupE",
|
||||
Name: "GroupE",
|
||||
Peers: []string{peer2.ID, peer3.ID},
|
||||
network, err := networksManager.CreateNetwork(context.Background(), userID, &networkTypes.Network{
|
||||
ID: "network_test",
|
||||
AccountID: account.Id,
|
||||
Name: "network_test",
|
||||
Description: "",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = routersManager.CreateRouter(context.Background(), userID, &routerTypes.NetworkRouter{
|
||||
ID: "router_test",
|
||||
NetworkID: network.ID,
|
||||
AccountID: account.Id,
|
||||
PeerGroups: []string{"groupE"},
|
||||
Masquerade: true,
|
||||
Metric: 9999,
|
||||
Enabled: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
resource, err := resourcesManager.CreateResource(context.Background(), userID, &resourceTypes.NetworkResource{
|
||||
AccountID: account.Id,
|
||||
NetworkID: network.ID,
|
||||
Name: "resource_test",
|
||||
Address: "10.20.30.0/24",
|
||||
Enabled: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = manager.SavePolicy(context.Background(), account.Id, userID, peerToResourcePolicyByResource("groupA", resource.ID), true)
|
||||
require.NoError(t, err)
|
||||
|
||||
settleAffectedUpdates(updMsg)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
err = manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{
|
||||
ID: "groupE",
|
||||
Name: "GroupE",
|
||||
Peers: []string{peer2.ID, peer3.ID},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1058,7 +1122,6 @@ func Test_AddPeerToGroup(t *testing.T) {
|
||||
errs <- fmt.Errorf("AddPeer failed for peer %d: %w", i, err)
|
||||
return
|
||||
}
|
||||
|
||||
}(i)
|
||||
}
|
||||
startTime := time.Now()
|
||||
@@ -1115,7 +1178,6 @@ func Test_AddPeerToAll(t *testing.T) {
|
||||
errs <- fmt.Errorf("AddPeer failed for peer %d: %w", i, err)
|
||||
return
|
||||
}
|
||||
|
||||
}(i)
|
||||
}
|
||||
startTime := time.Now()
|
||||
@@ -1359,3 +1421,101 @@ func TestDefaultAccountManager_GroupPeersMustBelongToAccount(t *testing.T) {
|
||||
assert.Equal(t, []string{peer1.ID}, stored.Peers, "rejected direct adds must not change membership")
|
||||
})
|
||||
}
|
||||
|
||||
func TestGroupsBatchAccountPeersUpdate(t *testing.T) {
|
||||
runPeerUpdateTest(t, func(t *testing.T) {
|
||||
manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t)
|
||||
|
||||
for _, group := range []*types.Group{
|
||||
{ID: "groupA", Name: "GroupA", Peers: []string{peer1.ID, peer2.ID}},
|
||||
{ID: "groupB", Name: "GroupB", Peers: []string{peer3.ID}},
|
||||
} {
|
||||
require.NoError(t, manager.CreateGroup(context.Background(), account.Id, userID, group))
|
||||
}
|
||||
|
||||
updMsg := updateManager.CreateChannel(context.Background(), peer1.ID)
|
||||
t.Cleanup(func() {
|
||||
updateManager.CloseChannel(context.Background(), peer1.ID)
|
||||
})
|
||||
|
||||
step(t, "creating unlinked groups", func(t *testing.T) {
|
||||
settleAffectedUpdates(updMsg)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldNotReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
err := manager.CreateGroups(context.Background(), account.Id, userID, []*types.Group{
|
||||
{ID: "groupC", Name: "GroupC", Peers: []string{peer1.ID}},
|
||||
{ID: "groupD", Name: "GroupD"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("timeout waiting for peerShouldNotReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
_, err := manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{
|
||||
Enabled: true,
|
||||
Rules: []*types.PolicyRule{
|
||||
{
|
||||
Enabled: true,
|
||||
Sources: []string{"groupA"},
|
||||
Destinations: []string{"groupB"},
|
||||
Bidirectional: true,
|
||||
Action: types.PolicyTrafficActionAccept,
|
||||
},
|
||||
},
|
||||
}, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
step(t, "updating a batch with a linked group", func(t *testing.T) {
|
||||
settleAffectedUpdates(updMsg)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
err := manager.UpdateGroups(context.Background(), account.Id, userID, []*types.Group{
|
||||
{ID: "groupD", Name: "GroupD renamed"},
|
||||
{ID: "groupB", Name: "GroupB renamed"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
step(t, "updating a batch where one item fails", func(t *testing.T) {
|
||||
settleAffectedUpdates(updMsg)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
err := manager.UpdateGroups(context.Background(), account.Id, userID, []*types.Group{
|
||||
{ID: "missing", Name: "Missing"},
|
||||
{ID: "groupB", Name: "GroupB"},
|
||||
})
|
||||
require.Error(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,8 +7,6 @@ import (
|
||||
"net/netip"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/rs/cors"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain/manager"
|
||||
|
||||
@@ -36,7 +34,6 @@ import (
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/http/handlers/proxy"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/auth"
|
||||
"github.com/netbirdio/netbird/management/server/geolocation"
|
||||
nbgroups "github.com/netbirdio/netbird/management/server/groups"
|
||||
"github.com/netbirdio/netbird/management/server/http/handlers/accounts"
|
||||
@@ -51,17 +48,20 @@ import (
|
||||
"github.com/netbirdio/netbird/management/server/http/handlers/routes"
|
||||
"github.com/netbirdio/netbird/management/server/http/handlers/setup_keys"
|
||||
"github.com/netbirdio/netbird/management/server/http/handlers/users"
|
||||
"github.com/netbirdio/netbird/management/server/http/middleware"
|
||||
"github.com/netbirdio/netbird/management/server/http/middleware/bypass"
|
||||
nbinstance "github.com/netbirdio/netbird/management/server/instance"
|
||||
nbnetworks "github.com/netbirdio/netbird/management/server/networks"
|
||||
"github.com/netbirdio/netbird/management/server/networks/resources"
|
||||
"github.com/netbirdio/netbird/management/server/networks/routers"
|
||||
"github.com/netbirdio/netbird/management/server/telemetry"
|
||||
)
|
||||
|
||||
// NewAPIHandler creates the Management service HTTP API handler registering all the available endpoints.
|
||||
func NewAPIHandler(ctx context.Context, router *mux.Router, accountManager account.Manager, networksManager nbnetworks.Manager, resourceManager resources.Manager, routerManager routers.Manager, groupsManager nbgroups.Manager, LocationManager geolocation.Geolocation, authManager auth.Manager, appMetrics telemetry.AppMetrics, permissionsManager permissions.Manager, settingsManager settings.Manager, zManager zones.Manager, rManager records.Manager, networkMapController network_map.Controller, idpManager idpmanager.Manager, serviceManager service.Manager, reverseProxyDomainManager *manager.Manager, reverseProxyAccessLogsManager accesslogs.Manager, proxyGRPCServer *nbgrpc.ProxyServiceServer, trustedHTTPProxies []netip.Prefix, rateLimiter *middleware.APIRateLimiter, isValidChildAccount middleware.IsValidChildAccountFunc, agentNetworkManager agentnetwork.Manager) (http.Handler, error) {
|
||||
func NewAPIHandler(ctx context.Context, router *mux.Router, accountManager account.Manager, networksManager nbnetworks.Manager,
|
||||
resourceManager resources.Manager, routerManager routers.Manager, groupsManager nbgroups.Manager, LocationManager geolocation.Geolocation,
|
||||
permissionsManager permissions.Manager, settingsManager settings.Manager, zManager zones.Manager, rManager records.Manager,
|
||||
networkMapController network_map.Controller, idpManager idpmanager.Manager, serviceManager service.Manager,
|
||||
reverseProxyDomainManager *manager.Manager, reverseProxyAccessLogsManager accesslogs.Manager, proxyGRPCServer *nbgrpc.ProxyServiceServer,
|
||||
trustedHTTPProxies []netip.Prefix, agentNetworkManager agentnetwork.Manager, proxyTokenRevocationGuard proxytoken.RevocationGuard) (http.Handler, error) {
|
||||
|
||||
// Register bypass paths for unauthenticated endpoints
|
||||
if err := bypass.AddBypassPath("/api/instance"); err != nil {
|
||||
@@ -82,28 +82,6 @@ func NewAPIHandler(ctx context.Context, router *mux.Router, accountManager accou
|
||||
return nil, fmt.Errorf("failed to add bypass path: %w", err)
|
||||
}
|
||||
|
||||
if rateLimiter == nil {
|
||||
log.Warn("NewAPIHandler: nil rate limiter, rate limiting disabled")
|
||||
rateLimiter = middleware.NewAPIRateLimiter(nil)
|
||||
rateLimiter.SetEnabled(false)
|
||||
}
|
||||
|
||||
authMiddleware := middleware.NewAuthMiddleware(
|
||||
authManager,
|
||||
accountManager.GetAccountIDFromUserAuth,
|
||||
accountManager.SyncUserJWTGroups,
|
||||
accountManager.GetUserFromUserAuth,
|
||||
rateLimiter,
|
||||
appMetrics.GetMeter(),
|
||||
isValidChildAccount,
|
||||
)
|
||||
|
||||
corsMiddleware := cors.AllowAll()
|
||||
|
||||
metricsMiddleware := appMetrics.HTTPMiddleware()
|
||||
|
||||
router.Use(metricsMiddleware.Handler, corsMiddleware.Handler, authMiddleware.Handler)
|
||||
|
||||
instanceManager, err := nbinstance.NewManager(ctx, accountManager.GetStore(), idpManager)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create instance manager: %w", err)
|
||||
@@ -135,7 +113,7 @@ func NewAPIHandler(ctx context.Context, router *mux.Router, accountManager accou
|
||||
reverseproxymanager.RegisterEndpoints(serviceManager, *reverseProxyDomainManager, reverseProxyAccessLogsManager, permissionsManager, router)
|
||||
}
|
||||
|
||||
proxytoken.RegisterEndpoints(accountManager.GetStore(), permissionsManager, router)
|
||||
proxytoken.RegisterEndpoints(accountManager.GetStore(), permissionsManager, proxyTokenRevocationGuard, router)
|
||||
|
||||
// Register OAuth callback handler for proxy authentication
|
||||
if proxyGRPCServer != nil {
|
||||
|
||||
@@ -127,7 +127,7 @@ func (h *handler) validateNetworkRange(ctx context.Context, accountID, userID st
|
||||
}
|
||||
|
||||
func (h *handler) validateCapacity(ctx context.Context, accountID, userID string, prefix netip.Prefix) error {
|
||||
peers, err := h.accountManager.GetPeers(ctx, accountID, userID, "", "")
|
||||
peers, err := h.accountManager.GetPeers(ctx, accountID, userID, "", "", "")
|
||||
if err != nil {
|
||||
return status.Errorf(status.Internal, "get peer count: %v", err)
|
||||
}
|
||||
@@ -152,7 +152,7 @@ func calculateMaxHosts(prefix netip.Prefix) int64 {
|
||||
|
||||
maxHosts := int64(1) << hostBits
|
||||
if prefix.Addr().Is4() {
|
||||
maxHosts -= 2 // network and broadcast addresses
|
||||
maxHosts -= 3 // network, client DNS resolver and broadcast addresses
|
||||
}
|
||||
|
||||
return maxHosts
|
||||
@@ -286,6 +286,9 @@ func (h *handler) updateAccountRequestSettings(req api.PutApiAccountsAccountIdJS
|
||||
if req.Settings.MetricsPushEnabled != nil {
|
||||
returnSettings.MetricsPushEnabled = *req.Settings.MetricsPushEnabled
|
||||
}
|
||||
if req.Settings.PeerHostnameCollisionRejected != nil {
|
||||
returnSettings.PeerHostnameCollisionRejected = *req.Settings.PeerHostnameCollisionRejected
|
||||
}
|
||||
if req.Settings.AgentNetworkOnly != nil {
|
||||
returnSettings.AgentNetworkOnly = *req.Settings.AgentNetworkOnly
|
||||
}
|
||||
@@ -432,6 +435,7 @@ func toAccountResponse(accountID string, settings *types.Settings, meta *types.A
|
||||
AutoUpdateAlways: &settings.AutoUpdateAlways,
|
||||
Ipv6EnabledGroups: &settings.IPv6EnabledGroups,
|
||||
MetricsPushEnabled: &settings.MetricsPushEnabled,
|
||||
PeerHostnameCollisionRejected: &settings.PeerHostnameCollisionRejected,
|
||||
AgentNetworkOnly: &settings.AgentNetworkOnly,
|
||||
EmbeddedIdpEnabled: &settings.EmbeddedIdpEnabled,
|
||||
LocalAuthDisabled: &settings.LocalAuthDisabled,
|
||||
|
||||
@@ -12,9 +12,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"go.uber.org/mock/gomock"
|
||||
|
||||
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
||||
"github.com/netbirdio/netbird/management/server/mock_server"
|
||||
@@ -130,6 +130,7 @@ func TestAccounts_AccountsHandler(t *testing.T) {
|
||||
AutoUpdateAlways: br(false),
|
||||
AutoUpdateVersion: sr(""),
|
||||
MetricsPushEnabled: br(false),
|
||||
PeerHostnameCollisionRejected: br(false),
|
||||
AgentNetworkOnly: br(false),
|
||||
EmbeddedIdpEnabled: br(false),
|
||||
LocalAuthDisabled: br(false),
|
||||
@@ -159,6 +160,7 @@ func TestAccounts_AccountsHandler(t *testing.T) {
|
||||
AutoUpdateAlways: br(false),
|
||||
AutoUpdateVersion: sr(""),
|
||||
MetricsPushEnabled: br(false),
|
||||
PeerHostnameCollisionRejected: br(false),
|
||||
AgentNetworkOnly: br(false),
|
||||
EmbeddedIdpEnabled: br(false),
|
||||
LocalAuthDisabled: br(false),
|
||||
@@ -188,6 +190,7 @@ func TestAccounts_AccountsHandler(t *testing.T) {
|
||||
AutoUpdateAlways: br(false),
|
||||
AutoUpdateVersion: sr("latest"),
|
||||
MetricsPushEnabled: br(false),
|
||||
PeerHostnameCollisionRejected: br(false),
|
||||
AgentNetworkOnly: br(false),
|
||||
EmbeddedIdpEnabled: br(false),
|
||||
LocalAuthDisabled: br(false),
|
||||
@@ -217,6 +220,7 @@ func TestAccounts_AccountsHandler(t *testing.T) {
|
||||
AutoUpdateAlways: br(false),
|
||||
AutoUpdateVersion: sr(""),
|
||||
MetricsPushEnabled: br(false),
|
||||
PeerHostnameCollisionRejected: br(false),
|
||||
AgentNetworkOnly: br(false),
|
||||
EmbeddedIdpEnabled: br(false),
|
||||
LocalAuthDisabled: br(false),
|
||||
@@ -246,6 +250,7 @@ func TestAccounts_AccountsHandler(t *testing.T) {
|
||||
AutoUpdateAlways: br(false),
|
||||
AutoUpdateVersion: sr(""),
|
||||
MetricsPushEnabled: br(false),
|
||||
PeerHostnameCollisionRejected: br(false),
|
||||
AgentNetworkOnly: br(false),
|
||||
EmbeddedIdpEnabled: br(false),
|
||||
LocalAuthDisabled: br(false),
|
||||
@@ -275,6 +280,7 @@ func TestAccounts_AccountsHandler(t *testing.T) {
|
||||
AutoUpdateAlways: br(false),
|
||||
AutoUpdateVersion: sr(""),
|
||||
MetricsPushEnabled: br(false),
|
||||
PeerHostnameCollisionRejected: br(false),
|
||||
AgentNetworkOnly: br(false),
|
||||
EmbeddedIdpEnabled: br(false),
|
||||
LocalAuthDisabled: br(false),
|
||||
@@ -304,6 +310,7 @@ func TestAccounts_AccountsHandler(t *testing.T) {
|
||||
AutoUpdateAlways: br(false),
|
||||
AutoUpdateVersion: sr(""),
|
||||
MetricsPushEnabled: br(false),
|
||||
PeerHostnameCollisionRejected: br(false),
|
||||
AgentNetworkOnly: br(true),
|
||||
DashboardFeatures: &api.AccountDashboardFeatures{
|
||||
AgentNetwork: br(true),
|
||||
@@ -345,6 +352,7 @@ func TestAccounts_AccountsHandler(t *testing.T) {
|
||||
AutoUpdateAlways: br(false),
|
||||
AutoUpdateVersion: sr(""),
|
||||
MetricsPushEnabled: br(false),
|
||||
PeerHostnameCollisionRejected: br(false),
|
||||
AgentNetworkOnly: br(false),
|
||||
DashboardFeatures: &api.AccountDashboardFeatures{
|
||||
AgentNetwork: br(true),
|
||||
@@ -377,6 +385,7 @@ func TestAccounts_AccountsHandler(t *testing.T) {
|
||||
AutoUpdateAlways: br(false),
|
||||
AutoUpdateVersion: sr(""),
|
||||
MetricsPushEnabled: br(false),
|
||||
PeerHostnameCollisionRejected: br(false),
|
||||
AgentNetworkOnly: br(false),
|
||||
EmbeddedIdpEnabled: br(false),
|
||||
LocalAuthDisabled: br(false),
|
||||
@@ -469,9 +478,9 @@ func TestCalculateMaxHosts(t *testing.T) {
|
||||
prefix string
|
||||
min int64
|
||||
}{
|
||||
{"v4 /24", "100.64.0.0/24", 254},
|
||||
{"v4 /16", "100.64.0.0/16", 65534},
|
||||
{"v4 /28", "100.64.0.0/28", 14},
|
||||
{"v4 /24", "100.64.0.0/24", 253},
|
||||
{"v4 /16", "100.64.0.0/16", 65533},
|
||||
{"v4 /28", "100.64.0.0/28", 13},
|
||||
{"v6 /64", "fd00::/64", math.MaxInt64},
|
||||
{"v6 /120", "fd00::/120", 256},
|
||||
{"v6 /112", "fd00::/112", 65536},
|
||||
|
||||
@@ -58,7 +58,7 @@ func (h *handler) getAllGroups(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
accountPeers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, "", "")
|
||||
accountPeers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, "", "", "")
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
@@ -77,7 +77,7 @@ func (h *handler) getAllGroups(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
accountPeers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, "", "")
|
||||
accountPeers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, "", "", "")
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
@@ -169,7 +169,7 @@ func (h *handler) updateGroup(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
accountPeers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, "", "")
|
||||
accountPeers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, "", "", "")
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
@@ -226,7 +226,7 @@ func (h *handler) createGroup(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
accountPeers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, "", "")
|
||||
accountPeers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, "", "", "")
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
@@ -287,7 +287,7 @@ func (h *handler) getGroup(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
accountPeers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, "", "")
|
||||
accountPeers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, "", "", "")
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
|
||||
@@ -78,7 +78,7 @@ func initGroupTestData(initGroups ...*types.Group) *handler {
|
||||
|
||||
return nil, status.Errorf(status.NotFound, "unknown group name")
|
||||
},
|
||||
GetPeersFunc: func(ctx context.Context, accountID, userID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error) {
|
||||
GetPeersFunc: func(ctx context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) {
|
||||
return maps.Values(TestPeers), nil
|
||||
},
|
||||
DeleteGroupFunc: func(_ context.Context, accountID, userId, groupID string) error {
|
||||
|
||||
@@ -319,10 +319,11 @@ func (h *Handler) GetAllPeers(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
nameFilter := r.URL.Query().Get("name")
|
||||
ipFilter := r.URL.Query().Get("ip")
|
||||
macFilter := r.URL.Query().Get("mac")
|
||||
|
||||
accountID, userID := userAuth.AccountId, userAuth.UserId
|
||||
|
||||
peers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, nameFilter, ipFilter)
|
||||
peers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, nameFilter, ipFilter, macFilter)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
@@ -665,6 +666,17 @@ func peerToAccessiblePeer(peer *nbpeer.Peer, dnsDomain string) api.AccessiblePee
|
||||
}
|
||||
}
|
||||
|
||||
func toNetworkAddresses(addrs []nbpeer.NetworkAddress) *[]api.NetworkAddress {
|
||||
if len(addrs) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]api.NetworkAddress, 0, len(addrs))
|
||||
for _, a := range addrs {
|
||||
out = append(out, api.NetworkAddress{NetIp: a.NetIP.String(), Mac: a.Mac})
|
||||
}
|
||||
return &out
|
||||
}
|
||||
|
||||
func toSinglePeerResponse(peer *nbpeer.Peer, groupsInfo []api.GroupMinimum, dnsDomain string, approved bool, reason string) *api.Peer {
|
||||
osVersion := peer.Meta.OSVersion
|
||||
if osVersion == "" {
|
||||
@@ -677,6 +689,7 @@ func toSinglePeerResponse(peer *nbpeer.Peer, groupsInfo []api.GroupMinimum, dnsD
|
||||
Name: peer.Name,
|
||||
Ip: peer.IP.String(),
|
||||
Ipv6: peerIPv6String(peer),
|
||||
NetworkAddresses: toNetworkAddresses(peer.Meta.NetworkAddresses),
|
||||
ConnectionIp: peer.Location.ConnectionIP.String(),
|
||||
Connected: peer.Status.Connected,
|
||||
LastSeen: peer.Status.LastSeen,
|
||||
@@ -734,6 +747,7 @@ func toPeerListItemResponse(peer *nbpeer.Peer, groupsInfo []api.GroupMinimum, dn
|
||||
Name: peer.Name,
|
||||
Ip: peer.IP.String(),
|
||||
Ipv6: peerIPv6String(peer),
|
||||
NetworkAddresses: toNetworkAddresses(peer.Meta.NetworkAddresses),
|
||||
ConnectionIp: peer.Location.ConnectionIP.String(),
|
||||
Connected: peer.Status.Connected,
|
||||
LastSeen: peer.Status.LastSeen,
|
||||
|
||||
@@ -173,7 +173,7 @@ func initTestMetaData(t *testing.T, peers ...*nbpeer.Peer) *Handler {
|
||||
return nil, fmt.Errorf("user not found")
|
||||
}
|
||||
},
|
||||
GetPeersFunc: func(_ context.Context, accountID, userID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error) {
|
||||
GetPeersFunc: func(_ context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) {
|
||||
return peers, nil
|
||||
},
|
||||
GetPeerGroupsFunc: func(ctx context.Context, accountID, peerID string) ([]*types.Group, error) {
|
||||
@@ -364,6 +364,50 @@ func TestGetPeers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerResponseNetworkAddresses(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
addresses []nbpeer.NetworkAddress
|
||||
wantJSON string
|
||||
}{
|
||||
{name: "not reported"},
|
||||
{name: "empty", addresses: []nbpeer.NetworkAddress{}},
|
||||
{
|
||||
name: "multiple interfaces",
|
||||
addresses: []nbpeer.NetworkAddress{
|
||||
{NetIP: netip.MustParsePrefix("192.168.0.11/24"), Mac: "00:93:37:bd:83:0f"},
|
||||
{NetIP: netip.MustParsePrefix("2001:db8::123/64"), Mac: "00:93:37:bd:83:10"},
|
||||
},
|
||||
wantJSON: `[{"net_ip":"192.168.0.11/24","mac":"00:93:37:bd:83:0f"},{"net_ip":"2001:db8::123/64","mac":"00:93:37:bd:83:10"}]`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
peer := &nbpeer.Peer{
|
||||
Status: &nbpeer.PeerStatus{},
|
||||
Meta: nbpeer.PeerSystemMeta{NetworkAddresses: tt.addresses},
|
||||
}
|
||||
responses := map[string]any{
|
||||
"single peer": toSinglePeerResponse(peer, nil, "example.com", true, ""),
|
||||
"peer list": toPeerListItemResponse(peer, nil, "example.com", 0),
|
||||
}
|
||||
for name, response := range responses {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
body, err := json.Marshal(response)
|
||||
require.NoError(t, err)
|
||||
var fields map[string]json.RawMessage
|
||||
require.NoError(t, json.Unmarshal(body, &fields))
|
||||
if tt.wantJSON == "" {
|
||||
assert.NotContains(t, fields, "network_addresses", "unreported interfaces should be omitted")
|
||||
return
|
||||
}
|
||||
assert.JSONEq(t, tt.wantJSON, string(fields["network_addresses"]), "response should preserve interface addresses and MACs")
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAccessiblePeers(t *testing.T) {
|
||||
peer1 := &nbpeer.Peer{
|
||||
ID: "peer1",
|
||||
|
||||
@@ -16,21 +16,21 @@ import (
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
|
||||
"github.com/netbirdio/netbird/management/server/http/middleware"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/proxy/auth"
|
||||
"github.com/netbirdio/netbird/shared/ratelimit"
|
||||
)
|
||||
|
||||
// AuthCallbackHandler handles OAuth callbacks for proxy authentication.
|
||||
type AuthCallbackHandler struct {
|
||||
proxyService *nbgrpc.ProxyServiceServer
|
||||
rateLimiter *middleware.APIRateLimiter
|
||||
rateLimiter *ratelimit.APIRateLimiter
|
||||
trustedProxies []netip.Prefix
|
||||
}
|
||||
|
||||
// NewAuthCallbackHandler creates a new OAuth callback handler.
|
||||
func NewAuthCallbackHandler(proxyService *nbgrpc.ProxyServiceServer, trustedProxies []netip.Prefix) *AuthCallbackHandler {
|
||||
rateLimiterConfig := &middleware.RateLimiterConfig{
|
||||
rateLimiterConfig := &ratelimit.RateLimiterConfig{
|
||||
RequestsPerMinute: 10,
|
||||
Burst: 15,
|
||||
CleanupInterval: 5 * time.Minute,
|
||||
@@ -39,7 +39,7 @@ func NewAuthCallbackHandler(proxyService *nbgrpc.ProxyServiceServer, trustedProx
|
||||
|
||||
return &AuthCallbackHandler{
|
||||
proxyService: proxyService,
|
||||
rateLimiter: middleware.NewAPIRateLimiter(rateLimiterConfig),
|
||||
rateLimiter: ratelimit.NewAPIRateLimiter(rateLimiterConfig),
|
||||
trustedProxies: trustedProxies,
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,7 @@ func (h *AuthCallbackHandler) handleCallback(w http.ResponseWriter, r *http.Requ
|
||||
|
||||
state := r.URL.Query().Get("state")
|
||||
|
||||
codeVerifier, originalURL, err := h.proxyService.ValidateState(state)
|
||||
codeVerifier, originalURL, useSessionCode, err := h.proxyService.ValidateState(state)
|
||||
if err != nil {
|
||||
log.WithError(err).Error("OAuth callback state validation failed")
|
||||
http.Error(w, "Invalid state parameter", http.StatusBadRequest)
|
||||
@@ -119,10 +119,19 @@ func (h *AuthCallbackHandler) handleCallback(w http.ResponseWriter, r *http.Requ
|
||||
redirectURL.Scheme = "https"
|
||||
|
||||
query := redirectURL.Query()
|
||||
query.Set("session_token", sessionToken)
|
||||
if useSessionCode {
|
||||
code, ok := h.proxyService.GenerateSessionCode(sessionToken)
|
||||
if !ok {
|
||||
http.Error(w, "Failed to create session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
query.Set(auth.SessionCodeQueryParam, code)
|
||||
} else {
|
||||
query.Set(auth.SessionTokenQueryParam, sessionToken)
|
||||
}
|
||||
redirectURL.RawQuery = query.Encode()
|
||||
|
||||
log.WithField("redirect", redirectURL.Host).Debug("OAuth callback: redirecting user with session token")
|
||||
log.WithField("redirect", redirectURL.Host).Debug("OAuth callback: redirecting user to proxy")
|
||||
http.Redirect(w, r, redirectURL.String(), http.StatusFound)
|
||||
}
|
||||
|
||||
|
||||
@@ -181,6 +181,10 @@ func (m *testAccessLogManager) GetAllAccessLogs(_ context.Context, _, _ string,
|
||||
}
|
||||
|
||||
func setupAuthCallbackTest(t *testing.T) *testSetup {
|
||||
return setupAuthCallbackTestWithProxyManager(t, testSessionCodeManager{})
|
||||
}
|
||||
|
||||
func setupAuthCallbackTestWithProxyManager(t *testing.T, proxyManager nbproxy.Manager) *testSetup {
|
||||
t.Helper()
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -197,7 +201,7 @@ func setupAuthCallbackTest(t *testing.T) *testSetup {
|
||||
require.NoError(t, err)
|
||||
|
||||
tokenStore := nbgrpc.NewOneTimeTokenStore(ctx, cacheStore)
|
||||
pkceStore := nbgrpc.NewPKCEVerifierStore(ctx, cacheStore)
|
||||
singleUseStore := nbgrpc.NewSingleUseStore(ctx, cacheStore)
|
||||
|
||||
usersManager := users.NewManager(testStore)
|
||||
|
||||
@@ -212,12 +216,12 @@ func setupAuthCallbackTest(t *testing.T) *testSetup {
|
||||
proxyService := nbgrpc.NewProxyServiceServer(
|
||||
&testAccessLogManager{},
|
||||
tokenStore,
|
||||
pkceStore,
|
||||
singleUseStore,
|
||||
oidcConfig,
|
||||
nil,
|
||||
usersManager,
|
||||
nil,
|
||||
nil,
|
||||
proxyManager,
|
||||
nil,
|
||||
)
|
||||
|
||||
@@ -242,6 +246,15 @@ func setupAuthCallbackTest(t *testing.T) *testSetup {
|
||||
}
|
||||
}
|
||||
|
||||
type testSessionCodeManager struct {
|
||||
nbproxy.Manager
|
||||
supported bool
|
||||
}
|
||||
|
||||
func (m testSessionCodeManager) ClusterSupportsSessionCode(_ context.Context, _ string) bool {
|
||||
return m.supported
|
||||
}
|
||||
|
||||
func createTestReverseProxies(t *testing.T, ctx context.Context, testStore store.Store) {
|
||||
t.Helper()
|
||||
|
||||
@@ -252,10 +265,11 @@ func createTestReverseProxies(t *testing.T, ctx context.Context, testStore store
|
||||
privKey := base64.StdEncoding.EncodeToString(priv)
|
||||
|
||||
testProxy := &service.Service{
|
||||
ID: "testProxyId",
|
||||
AccountID: "testAccountId",
|
||||
Name: "Test Proxy",
|
||||
Domain: "test-proxy.example.com",
|
||||
ID: "testProxyId",
|
||||
AccountID: "testAccountId",
|
||||
Name: "Test Proxy",
|
||||
Domain: "test-proxy.example.com",
|
||||
ProxyCluster: "cluster.example.com",
|
||||
Targets: []*service.Target{{
|
||||
Path: strPtr("/"),
|
||||
Host: "localhost",
|
||||
@@ -512,29 +526,56 @@ func createTestState(t *testing.T, ps *nbgrpc.ProxyServiceServer, redirectURL st
|
||||
}
|
||||
|
||||
func TestAuthCallback_UserAllowedToLogin(t *testing.T) {
|
||||
setup := setupAuthCallbackTest(t)
|
||||
defer setup.cleanup()
|
||||
tests := []struct {
|
||||
name string
|
||||
manager nbproxy.Manager
|
||||
wantParam string
|
||||
absentParam string
|
||||
}{
|
||||
{name: "legacy proxy", manager: testSessionCodeManager{}, wantParam: "session_token", absentParam: "nb_session_code"},
|
||||
{name: "compatible proxy", manager: testSessionCodeManager{supported: true}, wantParam: "nb_session_code", absentParam: "session_token"},
|
||||
}
|
||||
|
||||
setup.oidcServer.tokenSubject = "allowedUserId"
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
setup := setupAuthCallbackTestWithProxyManager(t, tt.manager)
|
||||
defer setup.cleanup()
|
||||
|
||||
state := createTestState(t, setup.proxyService, "https://test-proxy.example.com/dashboard")
|
||||
setup.oidcServer.tokenSubject = "allowedUserId"
|
||||
state := createTestState(t, setup.proxyService, "https://test-proxy.example.com/dashboard")
|
||||
req := httptest.NewRequest(http.MethodGet, "/reverse-proxy/callback?code=test-auth-code&state="+url.QueryEscape(state), nil)
|
||||
rec := httptest.NewRecorder()
|
||||
setup.router.ServeHTTP(rec, req)
|
||||
require.Equal(t, http.StatusFound, rec.Code)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/reverse-proxy/callback?code=test-auth-code&state="+url.QueryEscape(state), nil)
|
||||
rec := httptest.NewRecorder()
|
||||
location, err := url.Parse(rec.Header().Get("Location"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "test-proxy.example.com", location.Host)
|
||||
require.NotEmpty(t, location.Query().Get(tt.wantParam))
|
||||
require.Empty(t, location.Query().Get(tt.absentParam))
|
||||
require.Empty(t, location.Query().Get("error"))
|
||||
|
||||
setup.router.ServeHTTP(rec, req)
|
||||
if tt.wantParam == "nb_session_code" {
|
||||
code := location.Query().Get("nb_session_code")
|
||||
response, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{
|
||||
Domain: location.Hostname(),
|
||||
SessionCode: code,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, response.GetValid())
|
||||
require.NotEmpty(t, response.GetSessionToken())
|
||||
require.NotEqual(t, code, response.GetSessionToken())
|
||||
|
||||
require.Equal(t, http.StatusFound, rec.Code)
|
||||
|
||||
location := rec.Header().Get("Location")
|
||||
require.NotEmpty(t, location)
|
||||
|
||||
parsedLocation, err := url.Parse(location)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, "test-proxy.example.com", parsedLocation.Host)
|
||||
require.NotEmpty(t, parsedLocation.Query().Get("session_token"), "Should include session token")
|
||||
require.Empty(t, parsedLocation.Query().Get("error"), "Should not have error parameter")
|
||||
replayed, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{
|
||||
Domain: location.Hostname(),
|
||||
SessionCode: code,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.False(t, replayed.GetValid())
|
||||
require.Empty(t, replayed.GetSessionToken())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthCallback_UserDeniedByAccountStatus asserts that a user whose account
|
||||
|
||||
@@ -11,15 +11,15 @@ import (
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/account"
|
||||
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
||||
"github.com/netbirdio/netbird/management/server/http/middleware"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
"github.com/netbirdio/netbird/shared/management/http/util"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
"github.com/netbirdio/netbird/shared/ratelimit"
|
||||
)
|
||||
|
||||
// publicInviteRateLimiter limits public invite requests by IP address to prevent brute-force attacks
|
||||
var publicInviteRateLimiter = middleware.NewAPIRateLimiter(&middleware.RateLimiterConfig{
|
||||
var publicInviteRateLimiter = ratelimit.NewAPIRateLimiter(&ratelimit.RateLimiterConfig{
|
||||
RequestsPerMinute: 10, // 10 attempts per minute per IP
|
||||
Burst: 5, // Allow burst of 5 requests
|
||||
CleanupInterval: 10 * time.Minute,
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/netbirdio/netbird/shared/auth"
|
||||
"github.com/netbirdio/netbird/shared/management/http/util"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
"github.com/netbirdio/netbird/shared/ratelimit"
|
||||
)
|
||||
|
||||
type EnsureAccountFunc func(ctx context.Context, userAuth auth.UserAuth) (string, string, error)
|
||||
@@ -25,17 +26,14 @@ type SyncUserJWTGroupsFunc func(ctx context.Context, userAuth auth.UserAuth) err
|
||||
|
||||
type GetUserFromUserAuthFunc func(ctx context.Context, userAuth auth.UserAuth) (*types.User, error)
|
||||
|
||||
type IsValidChildAccountFunc func(ctx context.Context, userID, accountID, childAccountID string) bool
|
||||
|
||||
// AuthMiddleware middleware to verify personal access tokens (PAT) and JWT tokens
|
||||
type AuthMiddleware struct {
|
||||
authManager serverauth.Manager
|
||||
ensureAccount EnsureAccountFunc
|
||||
getUserFromUserAuth GetUserFromUserAuthFunc
|
||||
syncUserJWTGroups SyncUserJWTGroupsFunc
|
||||
rateLimiter *APIRateLimiter
|
||||
rateLimiter *ratelimit.APIRateLimiter
|
||||
patUsageTracker *PATUsageTracker
|
||||
isValidChildAccount IsValidChildAccountFunc
|
||||
}
|
||||
|
||||
// NewAuthMiddleware instance constructor
|
||||
@@ -44,9 +42,8 @@ func NewAuthMiddleware(
|
||||
ensureAccount EnsureAccountFunc,
|
||||
syncUserJWTGroups SyncUserJWTGroupsFunc,
|
||||
getUserFromUserAuth GetUserFromUserAuthFunc,
|
||||
rateLimiter *APIRateLimiter,
|
||||
rateLimiter *ratelimit.APIRateLimiter,
|
||||
meter metric.Meter,
|
||||
isValidChildAccount IsValidChildAccountFunc,
|
||||
) *AuthMiddleware {
|
||||
var patUsageTracker *PATUsageTracker
|
||||
if meter != nil {
|
||||
@@ -64,7 +61,6 @@ func NewAuthMiddleware(
|
||||
getUserFromUserAuth: getUserFromUserAuth,
|
||||
rateLimiter: rateLimiter,
|
||||
patUsageTracker: patUsageTracker,
|
||||
isValidChildAccount: isValidChildAccount,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,13 +122,6 @@ func (m *AuthMiddleware) checkJWTFromRequest(r *http.Request, authHeaderParts []
|
||||
return err
|
||||
}
|
||||
|
||||
if impersonate, ok := r.URL.Query()["account"]; ok && len(impersonate) == 1 {
|
||||
if m.isValidChildAccount(ctx, userAuth.UserId, userAuth.AccountId, impersonate[0]) {
|
||||
userAuth.AccountId = impersonate[0]
|
||||
userAuth.IsChild = true
|
||||
}
|
||||
}
|
||||
|
||||
// Email is now extracted in ToUserAuth (from claims or userinfo endpoint)
|
||||
// Available as userAuth.Email
|
||||
|
||||
@@ -209,13 +198,6 @@ func (m *AuthMiddleware) checkPATFromRequest(r *http.Request, authHeaderParts []
|
||||
IsPAT: true,
|
||||
}
|
||||
|
||||
if impersonate, ok := r.URL.Query()["account"]; ok && len(impersonate) == 1 {
|
||||
if m.isValidChildAccount(r.Context(), userAuth.UserId, userAuth.AccountId, impersonate[0]) {
|
||||
userAuth.AccountId = impersonate[0]
|
||||
userAuth.IsChild = true
|
||||
}
|
||||
}
|
||||
|
||||
// propagates ctx change to upstream middleware
|
||||
*r = *nbcontext.SetUserAuthInRequest(r, userAuth)
|
||||
return nil
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/netbirdio/netbird/management/server/util"
|
||||
nbauth "github.com/netbirdio/netbird/shared/auth"
|
||||
nbjwt "github.com/netbirdio/netbird/shared/auth/jwt"
|
||||
"github.com/netbirdio/netbird/shared/ratelimit"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -103,7 +104,7 @@ func mockMarkPATUsed(_ context.Context, token string) error {
|
||||
}
|
||||
|
||||
func mockEnsureUserAccessByJWTGroups(_ context.Context, userAuth nbauth.UserAuth, token *jwt.Token) (nbauth.UserAuth, error) {
|
||||
if userAuth.IsChild || userAuth.IsPAT {
|
||||
if userAuth.IsPAT {
|
||||
return userAuth, nil
|
||||
}
|
||||
|
||||
@@ -196,7 +197,7 @@ func TestAuthMiddleware_Handler(t *testing.T) {
|
||||
GetPATInfoFunc: mockGetAccountInfoFromPAT,
|
||||
}
|
||||
|
||||
disabledLimiter := NewAPIRateLimiter(nil)
|
||||
disabledLimiter := ratelimit.NewAPIRateLimiter(nil)
|
||||
disabledLimiter.SetEnabled(false)
|
||||
authMiddleware := NewAuthMiddleware(
|
||||
mockAuth,
|
||||
@@ -211,7 +212,6 @@ func TestAuthMiddleware_Handler(t *testing.T) {
|
||||
},
|
||||
disabledLimiter,
|
||||
nil,
|
||||
func(_ context.Context, _, _, _ string) bool { return false },
|
||||
)
|
||||
|
||||
handlerToTest := authMiddleware.Handler(nextHandler)
|
||||
@@ -260,7 +260,7 @@ func TestAuthMiddleware_SyncUserJWTGroupsDetachedFromRequestCancellation(t *test
|
||||
GetPATInfoFunc: mockGetAccountInfoFromPAT,
|
||||
}
|
||||
|
||||
disabledLimiter := NewAPIRateLimiter(nil)
|
||||
disabledLimiter := ratelimit.NewAPIRateLimiter(nil)
|
||||
disabledLimiter.SetEnabled(false)
|
||||
|
||||
authMiddleware := NewAuthMiddleware(
|
||||
@@ -278,7 +278,6 @@ func TestAuthMiddleware_SyncUserJWTGroupsDetachedFromRequestCancellation(t *test
|
||||
},
|
||||
disabledLimiter,
|
||||
nil,
|
||||
func(_ context.Context, _, _, _ string) bool { return false },
|
||||
)
|
||||
|
||||
handlerToTest := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
@@ -311,7 +310,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) {
|
||||
|
||||
t.Run("PAT Token Rate Limiting - Burst Works", func(t *testing.T) {
|
||||
// Configure rate limiter: 10 requests per minute with burst of 5
|
||||
rateLimitConfig := &RateLimiterConfig{
|
||||
rateLimitConfig := &ratelimit.RateLimiterConfig{
|
||||
RequestsPerMinute: 10,
|
||||
Burst: 5,
|
||||
CleanupInterval: 5 * time.Minute,
|
||||
@@ -329,9 +328,8 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) {
|
||||
func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) {
|
||||
return &types.User{}, nil
|
||||
},
|
||||
NewAPIRateLimiter(rateLimitConfig),
|
||||
ratelimit.NewAPIRateLimiter(rateLimitConfig),
|
||||
nil,
|
||||
func(_ context.Context, _, _, _ string) bool { return false },
|
||||
)
|
||||
|
||||
handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -364,7 +362,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) {
|
||||
|
||||
t.Run("PAT Token Rate Limiting - Rate Limit Enforced", func(t *testing.T) {
|
||||
// Configure very low rate limit: 1 request per minute
|
||||
rateLimitConfig := &RateLimiterConfig{
|
||||
rateLimitConfig := &ratelimit.RateLimiterConfig{
|
||||
RequestsPerMinute: 1,
|
||||
Burst: 1,
|
||||
CleanupInterval: 5 * time.Minute,
|
||||
@@ -382,9 +380,8 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) {
|
||||
func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) {
|
||||
return &types.User{}, nil
|
||||
},
|
||||
NewAPIRateLimiter(rateLimitConfig),
|
||||
ratelimit.NewAPIRateLimiter(rateLimitConfig),
|
||||
nil,
|
||||
func(_ context.Context, _, _, _ string) bool { return false },
|
||||
)
|
||||
|
||||
handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -408,7 +405,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) {
|
||||
|
||||
t.Run("Bearer Token Not Rate Limited", func(t *testing.T) {
|
||||
// Configure strict rate limit
|
||||
rateLimitConfig := &RateLimiterConfig{
|
||||
rateLimitConfig := &ratelimit.RateLimiterConfig{
|
||||
RequestsPerMinute: 1,
|
||||
Burst: 1,
|
||||
CleanupInterval: 5 * time.Minute,
|
||||
@@ -426,9 +423,8 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) {
|
||||
func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) {
|
||||
return &types.User{}, nil
|
||||
},
|
||||
NewAPIRateLimiter(rateLimitConfig),
|
||||
ratelimit.NewAPIRateLimiter(rateLimitConfig),
|
||||
nil,
|
||||
func(_ context.Context, _, _, _ string) bool { return false },
|
||||
)
|
||||
|
||||
handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -453,7 +449,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) {
|
||||
|
||||
t.Run("PAT Token Rate Limiting Per Token", func(t *testing.T) {
|
||||
// Configure rate limiter
|
||||
rateLimitConfig := &RateLimiterConfig{
|
||||
rateLimitConfig := &ratelimit.RateLimiterConfig{
|
||||
RequestsPerMinute: 1,
|
||||
Burst: 1,
|
||||
CleanupInterval: 5 * time.Minute,
|
||||
@@ -471,9 +467,8 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) {
|
||||
func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) {
|
||||
return &types.User{}, nil
|
||||
},
|
||||
NewAPIRateLimiter(rateLimitConfig),
|
||||
ratelimit.NewAPIRateLimiter(rateLimitConfig),
|
||||
nil,
|
||||
func(_ context.Context, _, _, _ string) bool { return false },
|
||||
)
|
||||
|
||||
handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -518,7 +513,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) {
|
||||
|
||||
t.Run("Rate Limiter Cleanup", func(t *testing.T) {
|
||||
// Configure rate limiter with short cleanup interval and TTL for testing
|
||||
rateLimitConfig := &RateLimiterConfig{
|
||||
rateLimitConfig := &ratelimit.RateLimiterConfig{
|
||||
RequestsPerMinute: 60,
|
||||
Burst: 1,
|
||||
CleanupInterval: 100 * time.Millisecond,
|
||||
@@ -536,9 +531,8 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) {
|
||||
func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) {
|
||||
return &types.User{}, nil
|
||||
},
|
||||
NewAPIRateLimiter(rateLimitConfig),
|
||||
ratelimit.NewAPIRateLimiter(rateLimitConfig),
|
||||
nil,
|
||||
func(_ context.Context, _, _, _ string) bool { return false },
|
||||
)
|
||||
|
||||
handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -578,7 +572,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("Terraform User Agent Not Rate Limited", func(t *testing.T) {
|
||||
rateLimitConfig := &RateLimiterConfig{
|
||||
rateLimitConfig := &ratelimit.RateLimiterConfig{
|
||||
RequestsPerMinute: 1,
|
||||
Burst: 1,
|
||||
CleanupInterval: 5 * time.Minute,
|
||||
@@ -596,9 +590,8 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) {
|
||||
func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) {
|
||||
return &types.User{}, nil
|
||||
},
|
||||
NewAPIRateLimiter(rateLimitConfig),
|
||||
ratelimit.NewAPIRateLimiter(rateLimitConfig),
|
||||
nil,
|
||||
func(_ context.Context, _, _, _ string) bool { return false },
|
||||
)
|
||||
|
||||
handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -634,7 +627,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("Non-Terraform User Agent With PAT Is Rate Limited", func(t *testing.T) {
|
||||
rateLimitConfig := &RateLimiterConfig{
|
||||
rateLimitConfig := &ratelimit.RateLimiterConfig{
|
||||
RequestsPerMinute: 1,
|
||||
Burst: 1,
|
||||
CleanupInterval: 5 * time.Minute,
|
||||
@@ -652,9 +645,8 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) {
|
||||
func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) {
|
||||
return &types.User{}, nil
|
||||
},
|
||||
NewAPIRateLimiter(rateLimitConfig),
|
||||
ratelimit.NewAPIRateLimiter(rateLimitConfig),
|
||||
nil,
|
||||
func(_ context.Context, _, _, _ string) bool { return false },
|
||||
)
|
||||
|
||||
handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -740,7 +732,7 @@ func TestAuthMiddleware_Handler_Child(t *testing.T) {
|
||||
GetPATInfoFunc: mockGetAccountInfoFromPAT,
|
||||
}
|
||||
|
||||
disabledLimiter := NewAPIRateLimiter(nil)
|
||||
disabledLimiter := ratelimit.NewAPIRateLimiter(nil)
|
||||
disabledLimiter.SetEnabled(false)
|
||||
authMiddleware := NewAuthMiddleware(
|
||||
mockAuth,
|
||||
@@ -755,7 +747,6 @@ func TestAuthMiddleware_Handler_Child(t *testing.T) {
|
||||
},
|
||||
disabledLimiter,
|
||||
nil,
|
||||
func(_ context.Context, _, _, _ string) bool { return false },
|
||||
)
|
||||
|
||||
for _, tc := range tt {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/netbirdio/netbird/management/server/account"
|
||||
"github.com/netbirdio/netbird/management/server/auth"
|
||||
"github.com/netbirdio/netbird/management/server/telemetry"
|
||||
"github.com/netbirdio/netbird/shared/ratelimit"
|
||||
"github.com/rs/cors"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func BuildMiddleware(rateLimiter *ratelimit.APIRateLimiter, authManager auth.Manager, accountManager account.Manager, metrics telemetry.AppMetrics) []mux.MiddlewareFunc {
|
||||
toret := make([]mux.MiddlewareFunc, 0)
|
||||
|
||||
toret = append(toret, metrics.HTTPMiddleware().Handler)
|
||||
toret = append(toret, cors.AllowAll().Handler)
|
||||
|
||||
if rateLimiter == nil {
|
||||
log.Warn("NewAPIHandler: nil rate limiter, rate limiting disabled")
|
||||
rateLimiter = ratelimit.NewAPIRateLimiter(nil)
|
||||
rateLimiter.SetEnabled(false)
|
||||
}
|
||||
toret = append(toret, NewAuthMiddleware(
|
||||
authManager,
|
||||
accountManager.GetAccountIDFromUserAuth,
|
||||
accountManager.SyncUserJWTGroups,
|
||||
accountManager.GetUserFromUserAuth,
|
||||
rateLimiter,
|
||||
metrics.GetMeter(),
|
||||
).Handler)
|
||||
|
||||
return toret
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/time/rate"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/http/util"
|
||||
)
|
||||
|
||||
const (
|
||||
RateLimitingEnabledEnv = "NB_API_RATE_LIMITING_ENABLED"
|
||||
RateLimitingBurstEnv = "NB_API_RATE_LIMITING_BURST"
|
||||
RateLimitingRPMEnv = "NB_API_RATE_LIMITING_RPM"
|
||||
|
||||
defaultAPIRPM = 6
|
||||
defaultAPIBurst = 500
|
||||
)
|
||||
|
||||
// RateLimiterConfig holds configuration for the API rate limiter
|
||||
type RateLimiterConfig struct {
|
||||
// RequestsPerMinute defines the rate at which tokens are replenished
|
||||
RequestsPerMinute float64
|
||||
// Burst defines the maximum number of requests that can be made in a burst
|
||||
Burst int
|
||||
// CleanupInterval defines how often to clean up old limiters (how often garbage collection runs)
|
||||
CleanupInterval time.Duration
|
||||
// LimiterTTL defines how long a limiter should be kept after last use (age threshold for removal)
|
||||
LimiterTTL time.Duration
|
||||
}
|
||||
|
||||
// DefaultRateLimiterConfig returns a default configuration
|
||||
func DefaultRateLimiterConfig() *RateLimiterConfig {
|
||||
return &RateLimiterConfig{
|
||||
RequestsPerMinute: 100,
|
||||
Burst: 120,
|
||||
CleanupInterval: 5 * time.Minute,
|
||||
LimiterTTL: 10 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
func RateLimiterConfigFromEnv() (cfg *RateLimiterConfig, enabled bool) {
|
||||
rpm := defaultAPIRPM
|
||||
if v := os.Getenv(RateLimitingRPMEnv); v != "" {
|
||||
value, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
log.Warnf("parsing %s env var: %v, using default %d", RateLimitingRPMEnv, err, rpm)
|
||||
} else {
|
||||
rpm = value
|
||||
}
|
||||
}
|
||||
if rpm <= 0 {
|
||||
log.Warnf("%s=%d is non-positive, using default %d", RateLimitingRPMEnv, rpm, defaultAPIRPM)
|
||||
rpm = defaultAPIRPM
|
||||
}
|
||||
|
||||
burst := defaultAPIBurst
|
||||
if v := os.Getenv(RateLimitingBurstEnv); v != "" {
|
||||
value, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
log.Warnf("parsing %s env var: %v, using default %d", RateLimitingBurstEnv, err, burst)
|
||||
} else {
|
||||
burst = value
|
||||
}
|
||||
}
|
||||
if burst <= 0 {
|
||||
log.Warnf("%s=%d is non-positive, using default %d", RateLimitingBurstEnv, burst, defaultAPIBurst)
|
||||
burst = defaultAPIBurst
|
||||
}
|
||||
|
||||
return &RateLimiterConfig{
|
||||
RequestsPerMinute: float64(rpm),
|
||||
Burst: burst,
|
||||
CleanupInterval: 6 * time.Hour,
|
||||
LimiterTTL: 24 * time.Hour,
|
||||
}, os.Getenv(RateLimitingEnabledEnv) == "true"
|
||||
}
|
||||
|
||||
// limiterEntry holds a rate limiter and its last access time
|
||||
type limiterEntry struct {
|
||||
limiter *rate.Limiter
|
||||
lastAccess time.Time
|
||||
}
|
||||
|
||||
// APIRateLimiter manages rate limiting for API tokens
|
||||
type APIRateLimiter struct {
|
||||
config *RateLimiterConfig
|
||||
limiters map[string]*limiterEntry
|
||||
mu sync.RWMutex
|
||||
stopChan chan struct{}
|
||||
enabled atomic.Bool
|
||||
}
|
||||
|
||||
// NewAPIRateLimiter creates a new API rate limiter with the given configuration
|
||||
func NewAPIRateLimiter(config *RateLimiterConfig) *APIRateLimiter {
|
||||
if config == nil {
|
||||
config = DefaultRateLimiterConfig()
|
||||
}
|
||||
|
||||
rl := &APIRateLimiter{
|
||||
config: config,
|
||||
limiters: make(map[string]*limiterEntry),
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
rl.enabled.Store(true)
|
||||
|
||||
go rl.cleanupLoop()
|
||||
|
||||
return rl
|
||||
}
|
||||
|
||||
func (rl *APIRateLimiter) SetEnabled(enabled bool) {
|
||||
rl.enabled.Store(enabled)
|
||||
}
|
||||
|
||||
func (rl *APIRateLimiter) Enabled() bool {
|
||||
return rl.enabled.Load()
|
||||
}
|
||||
|
||||
func (rl *APIRateLimiter) UpdateConfig(config *RateLimiterConfig) {
|
||||
if config == nil {
|
||||
return
|
||||
}
|
||||
if config.RequestsPerMinute <= 0 || config.Burst <= 0 {
|
||||
log.Warnf("UpdateConfig: ignoring invalid rpm=%v burst=%d", config.RequestsPerMinute, config.Burst)
|
||||
return
|
||||
}
|
||||
|
||||
newRPS := rate.Limit(config.RequestsPerMinute / 60.0)
|
||||
newBurst := config.Burst
|
||||
|
||||
rl.mu.Lock()
|
||||
rl.config.RequestsPerMinute = config.RequestsPerMinute
|
||||
rl.config.Burst = newBurst
|
||||
snapshot := make([]*rate.Limiter, 0, len(rl.limiters))
|
||||
for _, entry := range rl.limiters {
|
||||
snapshot = append(snapshot, entry.limiter)
|
||||
}
|
||||
rl.mu.Unlock()
|
||||
|
||||
for _, l := range snapshot {
|
||||
l.SetLimit(newRPS)
|
||||
l.SetBurst(newBurst)
|
||||
}
|
||||
}
|
||||
|
||||
// Allow checks if a request for the given key (token) is allowed
|
||||
func (rl *APIRateLimiter) Allow(key string) bool {
|
||||
if !rl.enabled.Load() {
|
||||
return true
|
||||
}
|
||||
limiter := rl.getLimiter(key)
|
||||
return limiter.Allow()
|
||||
}
|
||||
|
||||
// Wait blocks until the rate limiter allows another request for the given key
|
||||
// Returns an error if the context is canceled
|
||||
func (rl *APIRateLimiter) Wait(ctx context.Context, key string) error {
|
||||
if !rl.enabled.Load() {
|
||||
return nil
|
||||
}
|
||||
limiter := rl.getLimiter(key)
|
||||
return limiter.Wait(ctx)
|
||||
}
|
||||
|
||||
// getLimiter retrieves or creates a rate limiter for the given key
|
||||
func (rl *APIRateLimiter) getLimiter(key string) *rate.Limiter {
|
||||
rl.mu.RLock()
|
||||
entry, exists := rl.limiters[key]
|
||||
rl.mu.RUnlock()
|
||||
|
||||
if exists {
|
||||
rl.mu.Lock()
|
||||
entry.lastAccess = time.Now()
|
||||
rl.mu.Unlock()
|
||||
return entry.limiter
|
||||
}
|
||||
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
|
||||
if entry, exists := rl.limiters[key]; exists {
|
||||
entry.lastAccess = time.Now()
|
||||
return entry.limiter
|
||||
}
|
||||
|
||||
requestsPerSecond := rl.config.RequestsPerMinute / 60.0
|
||||
limiter := rate.NewLimiter(rate.Limit(requestsPerSecond), rl.config.Burst)
|
||||
rl.limiters[key] = &limiterEntry{
|
||||
limiter: limiter,
|
||||
lastAccess: time.Now(),
|
||||
}
|
||||
|
||||
return limiter
|
||||
}
|
||||
|
||||
// cleanupLoop periodically removes old limiters that haven't been used recently
|
||||
func (rl *APIRateLimiter) cleanupLoop() {
|
||||
ticker := time.NewTicker(rl.config.CleanupInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
rl.cleanup()
|
||||
case <-rl.stopChan:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cleanup removes limiters that haven't been used within the TTL period
|
||||
func (rl *APIRateLimiter) cleanup() {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
for key, entry := range rl.limiters {
|
||||
if now.Sub(entry.lastAccess) > rl.config.LimiterTTL {
|
||||
delete(rl.limiters, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop stops the cleanup goroutine
|
||||
func (rl *APIRateLimiter) Stop() {
|
||||
close(rl.stopChan)
|
||||
}
|
||||
|
||||
// Reset removes the rate limiter for a specific key
|
||||
func (rl *APIRateLimiter) Reset(key string) {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
delete(rl.limiters, key)
|
||||
}
|
||||
|
||||
// Middleware returns an HTTP middleware that rate limits requests by client IP.
|
||||
// Returns 429 Too Many Requests if the rate limit is exceeded.
|
||||
func (rl *APIRateLimiter) Middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !rl.enabled.Load() {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
clientIP := getClientIP(r)
|
||||
if !rl.Allow(clientIP) {
|
||||
util.WriteErrorResponse("rate limit exceeded, please try again later", http.StatusTooManyRequests, w)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// getClientIP extracts the client IP address from the request.
|
||||
func getClientIP(r *http.Request) string {
|
||||
ip, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return ip
|
||||
}
|
||||
@@ -1,329 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAPIRateLimiter_Allow(t *testing.T) {
|
||||
rl := NewAPIRateLimiter(&RateLimiterConfig{
|
||||
RequestsPerMinute: 60, // 1 per second
|
||||
Burst: 2,
|
||||
CleanupInterval: time.Minute,
|
||||
LimiterTTL: time.Minute,
|
||||
})
|
||||
defer rl.Stop()
|
||||
|
||||
// First two requests should be allowed (burst)
|
||||
assert.True(t, rl.Allow("test-key"))
|
||||
assert.True(t, rl.Allow("test-key"))
|
||||
|
||||
// Third request should be denied (exceeded burst)
|
||||
assert.False(t, rl.Allow("test-key"))
|
||||
|
||||
// Different key should be allowed
|
||||
assert.True(t, rl.Allow("different-key"))
|
||||
}
|
||||
|
||||
func TestAPIRateLimiter_Middleware(t *testing.T) {
|
||||
rl := NewAPIRateLimiter(&RateLimiterConfig{
|
||||
RequestsPerMinute: 60, // 1 per second
|
||||
Burst: 2,
|
||||
CleanupInterval: time.Minute,
|
||||
LimiterTTL: time.Minute,
|
||||
})
|
||||
defer rl.Stop()
|
||||
|
||||
// Create a simple handler that returns 200 OK
|
||||
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
// Wrap with rate limiter middleware
|
||||
handler := rl.Middleware(nextHandler)
|
||||
|
||||
// First two requests should pass (burst)
|
||||
for i := 0; i < 2; i++ {
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.RemoteAddr = "192.168.1.1:12345"
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
assert.Equal(t, http.StatusOK, rr.Code, "request %d should be allowed", i+1)
|
||||
}
|
||||
|
||||
// Third request should be rate limited
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.RemoteAddr = "192.168.1.1:12345"
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
assert.Equal(t, http.StatusTooManyRequests, rr.Code)
|
||||
}
|
||||
|
||||
func TestAPIRateLimiter_Middleware_DifferentIPs(t *testing.T) {
|
||||
rl := NewAPIRateLimiter(&RateLimiterConfig{
|
||||
RequestsPerMinute: 60,
|
||||
Burst: 1,
|
||||
CleanupInterval: time.Minute,
|
||||
LimiterTTL: time.Minute,
|
||||
})
|
||||
defer rl.Stop()
|
||||
|
||||
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
handler := rl.Middleware(nextHandler)
|
||||
|
||||
// Request from first IP
|
||||
req1 := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req1.RemoteAddr = "192.168.1.1:12345"
|
||||
rr1 := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr1, req1)
|
||||
assert.Equal(t, http.StatusOK, rr1.Code)
|
||||
|
||||
// Second request from first IP should be rate limited
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req2.RemoteAddr = "192.168.1.1:12345"
|
||||
rr2 := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr2, req2)
|
||||
assert.Equal(t, http.StatusTooManyRequests, rr2.Code)
|
||||
|
||||
// Request from different IP should be allowed
|
||||
req3 := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req3.RemoteAddr = "192.168.1.2:12345"
|
||||
rr3 := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr3, req3)
|
||||
assert.Equal(t, http.StatusOK, rr3.Code)
|
||||
}
|
||||
|
||||
func TestGetClientIP(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
remoteAddr string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "remote addr with port",
|
||||
remoteAddr: "192.168.1.1:12345",
|
||||
expected: "192.168.1.1",
|
||||
},
|
||||
{
|
||||
name: "remote addr without port",
|
||||
remoteAddr: "192.168.1.1",
|
||||
expected: "192.168.1.1",
|
||||
},
|
||||
{
|
||||
name: "IPv6 with port",
|
||||
remoteAddr: "[::1]:12345",
|
||||
expected: "::1",
|
||||
},
|
||||
{
|
||||
name: "IPv6 without port",
|
||||
remoteAddr: "::1",
|
||||
expected: "::1",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.RemoteAddr = tc.remoteAddr
|
||||
assert.Equal(t, tc.expected, getClientIP(req))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIRateLimiter_Reset(t *testing.T) {
|
||||
rl := NewAPIRateLimiter(&RateLimiterConfig{
|
||||
RequestsPerMinute: 60,
|
||||
Burst: 1,
|
||||
CleanupInterval: time.Minute,
|
||||
LimiterTTL: time.Minute,
|
||||
})
|
||||
defer rl.Stop()
|
||||
|
||||
// Use up the burst
|
||||
assert.True(t, rl.Allow("test-key"))
|
||||
assert.False(t, rl.Allow("test-key"))
|
||||
|
||||
// Reset the limiter
|
||||
rl.Reset("test-key")
|
||||
|
||||
// Should be allowed again
|
||||
assert.True(t, rl.Allow("test-key"))
|
||||
}
|
||||
|
||||
func TestAPIRateLimiter_SetEnabled(t *testing.T) {
|
||||
rl := NewAPIRateLimiter(&RateLimiterConfig{
|
||||
RequestsPerMinute: 60,
|
||||
Burst: 1,
|
||||
CleanupInterval: time.Minute,
|
||||
LimiterTTL: time.Minute,
|
||||
})
|
||||
defer rl.Stop()
|
||||
|
||||
assert.True(t, rl.Allow("key"))
|
||||
assert.False(t, rl.Allow("key"), "burst exhausted while enabled")
|
||||
|
||||
rl.SetEnabled(false)
|
||||
assert.False(t, rl.Enabled())
|
||||
for i := 0; i < 5; i++ {
|
||||
assert.True(t, rl.Allow("key"), "disabled limiter must always allow")
|
||||
}
|
||||
|
||||
rl.SetEnabled(true)
|
||||
assert.True(t, rl.Enabled())
|
||||
assert.False(t, rl.Allow("key"), "re-enabled limiter retains prior bucket state")
|
||||
}
|
||||
|
||||
func TestAPIRateLimiter_UpdateConfig(t *testing.T) {
|
||||
rl := NewAPIRateLimiter(&RateLimiterConfig{
|
||||
RequestsPerMinute: 60,
|
||||
Burst: 2,
|
||||
CleanupInterval: time.Minute,
|
||||
LimiterTTL: time.Minute,
|
||||
})
|
||||
defer rl.Stop()
|
||||
|
||||
assert.True(t, rl.Allow("k1"))
|
||||
assert.True(t, rl.Allow("k1"))
|
||||
assert.False(t, rl.Allow("k1"), "burst=2 exhausted")
|
||||
|
||||
rl.UpdateConfig(&RateLimiterConfig{
|
||||
RequestsPerMinute: 60,
|
||||
Burst: 10,
|
||||
CleanupInterval: time.Minute,
|
||||
LimiterTTL: time.Minute,
|
||||
})
|
||||
|
||||
// New burst applies to existing keys in place; bucket refills up to new burst over time,
|
||||
// but importantly newly-added keys use the updated config immediately.
|
||||
assert.True(t, rl.Allow("k2"))
|
||||
for i := 0; i < 9; i++ {
|
||||
assert.True(t, rl.Allow("k2"))
|
||||
}
|
||||
assert.False(t, rl.Allow("k2"), "new burst=10 exhausted")
|
||||
}
|
||||
|
||||
func TestAPIRateLimiter_UpdateConfig_NilIgnored(t *testing.T) {
|
||||
rl := NewAPIRateLimiter(&RateLimiterConfig{
|
||||
RequestsPerMinute: 60,
|
||||
Burst: 1,
|
||||
CleanupInterval: time.Minute,
|
||||
LimiterTTL: time.Minute,
|
||||
})
|
||||
defer rl.Stop()
|
||||
|
||||
rl.UpdateConfig(nil) // must not panic or zero the config
|
||||
|
||||
assert.True(t, rl.Allow("k"))
|
||||
assert.False(t, rl.Allow("k"))
|
||||
}
|
||||
|
||||
func TestAPIRateLimiter_UpdateConfig_NonPositiveIgnored(t *testing.T) {
|
||||
rl := NewAPIRateLimiter(&RateLimiterConfig{
|
||||
RequestsPerMinute: 60,
|
||||
Burst: 1,
|
||||
CleanupInterval: time.Minute,
|
||||
LimiterTTL: time.Minute,
|
||||
})
|
||||
defer rl.Stop()
|
||||
|
||||
assert.True(t, rl.Allow("k"))
|
||||
assert.False(t, rl.Allow("k"))
|
||||
|
||||
rl.UpdateConfig(&RateLimiterConfig{RequestsPerMinute: 0, Burst: 0, CleanupInterval: time.Minute, LimiterTTL: time.Minute})
|
||||
rl.UpdateConfig(&RateLimiterConfig{RequestsPerMinute: -1, Burst: 5, CleanupInterval: time.Minute, LimiterTTL: time.Minute})
|
||||
rl.UpdateConfig(&RateLimiterConfig{RequestsPerMinute: 60, Burst: -1, CleanupInterval: time.Minute, LimiterTTL: time.Minute})
|
||||
|
||||
rl.Reset("k")
|
||||
assert.True(t, rl.Allow("k"))
|
||||
assert.False(t, rl.Allow("k"), "burst should still be 1 — invalid UpdateConfig calls were ignored")
|
||||
}
|
||||
|
||||
func TestAPIRateLimiter_ConcurrentAllowAndUpdate(t *testing.T) {
|
||||
rl := NewAPIRateLimiter(&RateLimiterConfig{
|
||||
RequestsPerMinute: 600,
|
||||
Burst: 10,
|
||||
CleanupInterval: time.Minute,
|
||||
LimiterTTL: time.Minute,
|
||||
})
|
||||
defer rl.Stop()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
stop := make(chan struct{})
|
||||
|
||||
for i := 0; i < 8; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
key := fmt.Sprintf("k%d", id)
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
rl.Allow(key)
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 200; i++ {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
rl.UpdateConfig(&RateLimiterConfig{
|
||||
RequestsPerMinute: float64(30 + (i % 90)),
|
||||
Burst: 1 + (i % 20),
|
||||
CleanupInterval: time.Minute,
|
||||
LimiterTTL: time.Minute,
|
||||
})
|
||||
rl.SetEnabled(i%2 == 0)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestRateLimiterConfigFromEnv(t *testing.T) {
|
||||
t.Setenv(RateLimitingEnabledEnv, "true")
|
||||
t.Setenv(RateLimitingRPMEnv, "42")
|
||||
t.Setenv(RateLimitingBurstEnv, "7")
|
||||
|
||||
cfg, enabled := RateLimiterConfigFromEnv()
|
||||
assert.True(t, enabled)
|
||||
assert.Equal(t, float64(42), cfg.RequestsPerMinute)
|
||||
assert.Equal(t, 7, cfg.Burst)
|
||||
|
||||
t.Setenv(RateLimitingEnabledEnv, "false")
|
||||
_, enabled = RateLimiterConfigFromEnv()
|
||||
assert.False(t, enabled)
|
||||
|
||||
t.Setenv(RateLimitingEnabledEnv, "")
|
||||
t.Setenv(RateLimitingRPMEnv, "")
|
||||
t.Setenv(RateLimitingBurstEnv, "")
|
||||
cfg, enabled = RateLimiterConfigFromEnv()
|
||||
assert.False(t, enabled)
|
||||
assert.Equal(t, float64(defaultAPIRPM), cfg.RequestsPerMinute)
|
||||
assert.Equal(t, defaultAPIBurst, cfg.Burst)
|
||||
|
||||
t.Setenv(RateLimitingRPMEnv, "0")
|
||||
t.Setenv(RateLimitingBurstEnv, "-5")
|
||||
cfg, _ = RateLimiterConfigFromEnv()
|
||||
assert.Equal(t, float64(defaultAPIRPM), cfg.RequestsPerMinute, "non-positive rpm must fall back to default")
|
||||
assert.Equal(t, defaultAPIBurst, cfg.Burst, "non-positive burst must fall back to default")
|
||||
}
|
||||
@@ -29,7 +29,6 @@ import (
|
||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/peers"
|
||||
ephemeral_manager "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager"
|
||||
"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
|
||||
"github.com/netbirdio/netbird/management/server/job"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server"
|
||||
@@ -40,20 +39,21 @@ import (
|
||||
"github.com/netbirdio/netbird/management/server/geolocation"
|
||||
"github.com/netbirdio/netbird/management/server/groups"
|
||||
http2 "github.com/netbirdio/netbird/management/server/http"
|
||||
"github.com/netbirdio/netbird/management/server/http/middleware"
|
||||
"github.com/netbirdio/netbird/management/server/http/testing/testing_tools"
|
||||
"github.com/netbirdio/netbird/management/server/networks"
|
||||
"github.com/netbirdio/netbird/management/server/networks/resources"
|
||||
"github.com/netbirdio/netbird/management/server/networks/routers"
|
||||
"github.com/netbirdio/netbird/management/server/permissions"
|
||||
"github.com/netbirdio/netbird/management/server/settings"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
nbstore "github.com/netbirdio/netbird/management/server/store"
|
||||
"github.com/netbirdio/netbird/management/server/telemetry"
|
||||
"github.com/netbirdio/netbird/management/server/users"
|
||||
"github.com/netbirdio/netbird/shared/auth"
|
||||
)
|
||||
|
||||
func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPeerUpdate *network_map.UpdateMessage, validateUpdate bool) (http.Handler, account.Manager, chan struct{}) {
|
||||
store, cleanup, err := store.NewTestStoreFromSQL(context.Background(), sqlFile, t.TempDir())
|
||||
store, cleanup, err := nbstore.NewTestStoreFromSQL(context.Background(), sqlFile, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test store: %v", err)
|
||||
}
|
||||
@@ -88,7 +88,6 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee
|
||||
|
||||
geoMock := &geolocation.Mock{}
|
||||
validatorMock := server.MockIntegratedValidator{}
|
||||
proxyController := integrations.NewController(store)
|
||||
userManager := users.NewManager(store)
|
||||
permissionsManager := permissions.NewManager(store)
|
||||
settingsManager := settings.NewManager(store, userManager, integrations.NewManager(&activity.InMemoryEventStore{}), permissionsManager, settings.IdpConfig{})
|
||||
@@ -102,21 +101,21 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee
|
||||
}
|
||||
|
||||
requestBuffer := server.NewAccountRequestBuffer(ctx, store)
|
||||
networkMapController := controller.NewController(ctx, store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsManager, "", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peersManager), &config.Config{}, nil)
|
||||
am, err := server.BuildManager(ctx, nil, store, networkMapController, jobManager, nil, "", &activity.InMemoryEventStore{}, geoMock, false, validatorMock, metrics, proxyController, settingsManager, permissionsManager, false, cacheStore)
|
||||
networkMapController := controller.NewController(ctx, store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsManager, "", ephemeral_manager.NewEphemeralManager(store, peersManager), &config.Config{}, nil)
|
||||
am, err := server.BuildManager(ctx, nil, store, networkMapController, jobManager, nil, "", &activity.InMemoryEventStore{}, geoMock, false, validatorMock, metrics, settingsManager, permissionsManager, false, cacheStore)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create manager: %v", err)
|
||||
}
|
||||
|
||||
accessLogsManager := accesslogsmanager.NewManager(store, permissionsManager, nil)
|
||||
accessLogsManager := accesslogsmanager.NewManager(accesslogsmanager.NewRepository(store.(*nbstore.SqlStore).Conn()), store, permissionsManager, nil)
|
||||
proxyTokenStore := nbgrpc.NewOneTimeTokenStore(ctx, cacheStore)
|
||||
pkceverifierStore := nbgrpc.NewPKCEVerifierStore(ctx, cacheStore)
|
||||
singleUseStore := nbgrpc.NewSingleUseStore(ctx, cacheStore)
|
||||
noopMeter := noop.NewMeterProvider().Meter("")
|
||||
proxyMgr, err := proxymanager.NewManager(store, noopMeter)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create proxy manager: %v", err)
|
||||
}
|
||||
proxyServiceServer := nbgrpc.NewProxyServiceServer(accessLogsManager, proxyTokenStore, pkceverifierStore, nbgrpc.ProxyOIDCConfig{}, peersManager, userManager, nil, proxyMgr, nil)
|
||||
proxyServiceServer := nbgrpc.NewProxyServiceServer(accessLogsManager, proxyTokenStore, singleUseStore, nbgrpc.ProxyOIDCConfig{}, peersManager, userManager, nil, proxyMgr, nil)
|
||||
// NewProxyServiceServer starts cleanupStaleProxies on a context it derives
|
||||
// from context.Background(), independent of the cancellable ctx above;
|
||||
// Close() cancels it so the goroutine does not outlive the test.
|
||||
@@ -147,7 +146,10 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee
|
||||
zoneRecordsManager := recordsManager.NewManager(store, am, permissionsManager)
|
||||
|
||||
apiRouter := mux.NewRouter().PathPrefix("/api").Subrouter()
|
||||
apiHandler, err := http2.NewAPIHandler(ctx, apiRouter, am, networksManager, resourcesManager, routersManager, groupsManager, geoMock, authManagerMock, metrics, permissionsManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, serviceManager, nil, nil, nil, nil, nil, nil, nil)
|
||||
apiRouter.Use(middleware.BuildMiddleware(nil, authManagerMock, am, metrics)...)
|
||||
apiHandler, err := http2.NewAPIHandler(ctx, apiRouter, am, networksManager, resourcesManager, routersManager,
|
||||
groupsManager, geoMock, permissionsManager, settingsManager, customZonesManager, zoneRecordsManager,
|
||||
networkMapController, nil, serviceManager, nil, nil, nil, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create API handler: %v", err)
|
||||
}
|
||||
@@ -204,7 +206,7 @@ func PeerShouldNotReceiveAnyUpdate(t testing_tools.TB, updateMessage <-chan *net
|
||||
// BuildApiBlackBoxWithDBStateAndPeerChannel creates the API handler and returns
|
||||
// the peer update channel directly so tests can verify updates inline.
|
||||
func BuildApiBlackBoxWithDBStateAndPeerChannel(t testing_tools.TB, sqlFile string) (http.Handler, account.Manager, <-chan *network_map.UpdateMessage) {
|
||||
store, cleanup, err := store.NewTestStoreFromSQL(context.Background(), sqlFile, t.TempDir())
|
||||
store, cleanup, err := nbstore.NewTestStoreFromSQL(context.Background(), sqlFile, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test store: %v", err)
|
||||
}
|
||||
@@ -228,7 +230,6 @@ func BuildApiBlackBoxWithDBStateAndPeerChannel(t testing_tools.TB, sqlFile strin
|
||||
|
||||
geoMock := &geolocation.Mock{}
|
||||
validatorMock := server.MockIntegratedValidator{}
|
||||
proxyController := integrations.NewController(store)
|
||||
userManager := users.NewManager(store)
|
||||
permissionsManager := permissions.NewManager(store)
|
||||
settingsManager := settings.NewManager(store, userManager, integrations.NewManager(&activity.InMemoryEventStore{}), permissionsManager, settings.IdpConfig{})
|
||||
@@ -242,21 +243,21 @@ func BuildApiBlackBoxWithDBStateAndPeerChannel(t testing_tools.TB, sqlFile strin
|
||||
}
|
||||
|
||||
requestBuffer := server.NewAccountRequestBuffer(ctx, store)
|
||||
networkMapController := controller.NewController(ctx, store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsManager, "", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peersManager), &config.Config{}, nil)
|
||||
am, err := server.BuildManager(ctx, nil, store, networkMapController, jobManager, nil, "", &activity.InMemoryEventStore{}, geoMock, false, validatorMock, metrics, proxyController, settingsManager, permissionsManager, false, cacheStore)
|
||||
networkMapController := controller.NewController(ctx, store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsManager, "", ephemeral_manager.NewEphemeralManager(store, peersManager), &config.Config{}, nil)
|
||||
am, err := server.BuildManager(ctx, nil, store, networkMapController, jobManager, nil, "", &activity.InMemoryEventStore{}, geoMock, false, validatorMock, metrics, settingsManager, permissionsManager, false, cacheStore)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create manager: %v", err)
|
||||
}
|
||||
|
||||
accessLogsManager := accesslogsmanager.NewManager(store, permissionsManager, nil)
|
||||
accessLogsManager := accesslogsmanager.NewManager(accesslogsmanager.NewRepository(store.(*nbstore.SqlStore).Conn()), store, permissionsManager, nil)
|
||||
proxyTokenStore := nbgrpc.NewOneTimeTokenStore(ctx, cacheStore)
|
||||
pkceverifierStore := nbgrpc.NewPKCEVerifierStore(ctx, cacheStore)
|
||||
singleUseStore := nbgrpc.NewSingleUseStore(ctx, cacheStore)
|
||||
noopMeter := noop.NewMeterProvider().Meter("")
|
||||
proxyMgr, err := proxymanager.NewManager(store, noopMeter)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create proxy manager: %v", err)
|
||||
}
|
||||
proxyServiceServer := nbgrpc.NewProxyServiceServer(accessLogsManager, proxyTokenStore, pkceverifierStore, nbgrpc.ProxyOIDCConfig{}, peersManager, userManager, nil, proxyMgr, nil)
|
||||
proxyServiceServer := nbgrpc.NewProxyServiceServer(accessLogsManager, proxyTokenStore, singleUseStore, nbgrpc.ProxyOIDCConfig{}, peersManager, userManager, nil, proxyMgr, nil)
|
||||
// NewProxyServiceServer starts cleanupStaleProxies on a context it derives
|
||||
// from context.Background(), independent of the cancellable ctx above;
|
||||
// Close() cancels it so the goroutine does not outlive the test.
|
||||
@@ -287,7 +288,10 @@ func BuildApiBlackBoxWithDBStateAndPeerChannel(t testing_tools.TB, sqlFile strin
|
||||
zoneRecordsManager := recordsManager.NewManager(store, am, permissionsManager)
|
||||
|
||||
apiRouter := mux.NewRouter().PathPrefix("/api").Subrouter()
|
||||
apiHandler, err := http2.NewAPIHandler(ctx, apiRouter, am, networksManager, resourcesManager, routersManager, groupsManager, geoMock, authManagerMock, metrics, permissionsManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, serviceManager, nil, nil, nil, nil, nil, nil, nil)
|
||||
apiRouter.Use(middleware.BuildMiddleware(nil, authManagerMock, am, metrics)...)
|
||||
apiHandler, err := http2.NewAPIHandler(ctx, apiRouter, am, networksManager, resourcesManager, routersManager, groupsManager,
|
||||
geoMock, permissionsManager, settingsManager, customZonesManager, zoneRecordsManager,
|
||||
networkMapController, nil, serviceManager, nil, nil, nil, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create API handler: %v", err)
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ import (
|
||||
"github.com/netbirdio/netbird/management/server/activity"
|
||||
"github.com/netbirdio/netbird/management/server/cache"
|
||||
"github.com/netbirdio/netbird/management/server/idp"
|
||||
"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
|
||||
"github.com/netbirdio/netbird/management/server/job"
|
||||
"github.com/netbirdio/netbird/management/server/permissions"
|
||||
"github.com/netbirdio/netbird/management/server/settings"
|
||||
@@ -112,8 +111,8 @@ func createManagerWithEmbeddedIdPModeAndSetup(
|
||||
|
||||
updateManager := update_channel.NewPeersUpdateManager(metrics)
|
||||
requestBuffer := NewAccountRequestBuffer(ctx, testStore)
|
||||
networkMapController := controller.NewController(ctx, testStore, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(testStore, peersManager), &config.Config{}, nil)
|
||||
manager, err := BuildManager(ctx, &config.Config{}, testStore, networkMapController, job.NewJobManager(nil, testStore, peersManager), idpManager, singleAccountModeDomain, eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
|
||||
networkMapController := controller.NewController(ctx, testStore, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", ephemeral_manager.NewEphemeralManager(testStore, peersManager), &config.Config{}, nil)
|
||||
manager, err := BuildManager(ctx, &config.Config{}, testStore, networkMapController, job.NewJobManager(nil, testStore, peersManager), idpManager, singleAccountModeDomain, eventStore, nil, false, MockIntegratedValidator{}, metrics, settingsMockManager, permissionsManager, false, cacheStore)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
@@ -132,13 +132,7 @@ type ConnectionOptions struct {
|
||||
|
||||
// NewAuth0Manager creates a new instance of the Auth0Manager
|
||||
func NewAuth0Manager(config Auth0ClientConfig, appMetrics telemetry.AppMetrics) (*Auth0Manager, error) {
|
||||
httpTransport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
httpTransport.MaxIdleConns = 5
|
||||
|
||||
httpClient := &http.Client{
|
||||
Timeout: idpTimeout(),
|
||||
Transport: httpTransport,
|
||||
}
|
||||
httpClient := newHTTPClient()
|
||||
|
||||
helper := JsonParser{}
|
||||
|
||||
|
||||
@@ -49,13 +49,7 @@ type AuthentikCredentials struct {
|
||||
|
||||
// NewAuthentikManager creates a new instance of the AuthentikManager.
|
||||
func NewAuthentikManager(config AuthentikClientConfig, appMetrics telemetry.AppMetrics) (*AuthentikManager, error) {
|
||||
httpTransport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
httpTransport.MaxIdleConns = 5
|
||||
|
||||
httpClient := &http.Client{
|
||||
Timeout: idpTimeout(),
|
||||
Transport: httpTransport,
|
||||
}
|
||||
httpClient := newHTTPClient()
|
||||
|
||||
helper := JsonParser{}
|
||||
|
||||
|
||||
@@ -54,13 +54,7 @@ type azureProfile map[string]any
|
||||
|
||||
// NewAzureManager creates a new instance of the AzureManager.
|
||||
func NewAzureManager(config AzureClientConfig, appMetrics telemetry.AppMetrics) (*AzureManager, error) {
|
||||
httpTransport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
httpTransport.MaxIdleConns = 5
|
||||
|
||||
httpClient := &http.Client{
|
||||
Timeout: idpTimeout(),
|
||||
Transport: httpTransport,
|
||||
}
|
||||
httpClient := newHTTPClient()
|
||||
|
||||
helper := JsonParser{}
|
||||
|
||||
|
||||
@@ -4,10 +4,8 @@ import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dexidp/dex/api/v2"
|
||||
log "github.com/sirupsen/logrus"
|
||||
@@ -44,13 +42,7 @@ func NewDexManager(config DexClientConfig, appMetrics telemetry.AppMetrics) (*De
|
||||
return nil, fmt.Errorf("dex IdP configuration is incomplete, GRPCAddr is missing")
|
||||
}
|
||||
|
||||
httpTransport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
httpTransport.MaxIdleConns = 5
|
||||
|
||||
httpClient := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
Transport: httpTransport,
|
||||
}
|
||||
httpClient := newHTTPClient()
|
||||
helper := JsonParser{}
|
||||
|
||||
return &DexManager{
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/oauth2/google"
|
||||
@@ -44,13 +43,7 @@ func (gc *GoogleWorkspaceCredentials) Authenticate(_ context.Context) (JWTToken,
|
||||
|
||||
// NewGoogleWorkspaceManager creates a new instance of the GoogleWorkspaceManager.
|
||||
func NewGoogleWorkspaceManager(ctx context.Context, config GoogleWorkspaceClientConfig, appMetrics telemetry.AppMetrics) (*GoogleWorkspaceManager, error) {
|
||||
httpTransport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
httpTransport.MaxIdleConns = 5
|
||||
|
||||
httpClient := &http.Client{
|
||||
Timeout: idpTimeout(),
|
||||
Transport: httpTransport,
|
||||
}
|
||||
httpClient := newHTTPClient()
|
||||
|
||||
helper := JsonParser{}
|
||||
|
||||
|
||||
@@ -58,13 +58,7 @@ type JumpCloudCredentials struct {
|
||||
|
||||
// NewJumpCloudManager creates a new instance of the JumpCloudManager.
|
||||
func NewJumpCloudManager(config JumpCloudClientConfig, appMetrics telemetry.AppMetrics) (*JumpCloudManager, error) {
|
||||
httpTransport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
httpTransport.MaxIdleConns = 5
|
||||
|
||||
httpClient := &http.Client{
|
||||
Timeout: idpTimeout(),
|
||||
Transport: httpTransport,
|
||||
}
|
||||
httpClient := newHTTPClient()
|
||||
|
||||
helper := JsonParser{}
|
||||
|
||||
|
||||
@@ -59,13 +59,7 @@ type keycloakProfile struct {
|
||||
|
||||
// NewKeycloakManager creates a new instance of the KeycloakManager.
|
||||
func NewKeycloakManager(config KeycloakClientConfig, appMetrics telemetry.AppMetrics) (*KeycloakManager, error) {
|
||||
httpTransport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
httpTransport.MaxIdleConns = 5
|
||||
|
||||
httpClient := &http.Client{
|
||||
Timeout: idpTimeout(),
|
||||
Transport: httpTransport,
|
||||
}
|
||||
httpClient := newHTTPClient()
|
||||
|
||||
helper := JsonParser{}
|
||||
|
||||
|
||||
@@ -40,13 +40,7 @@ type OktaCredentials struct {
|
||||
|
||||
// NewOktaManager creates a new instance of the OktaManager.
|
||||
func NewOktaManager(config OktaClientConfig, appMetrics telemetry.AppMetrics) (*OktaManager, error) {
|
||||
httpTransport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
httpTransport.MaxIdleConns = 5
|
||||
|
||||
httpClient := &http.Client{
|
||||
Timeout: idpTimeout(),
|
||||
Transport: httpTransport,
|
||||
}
|
||||
httpClient := newHTTPClient()
|
||||
|
||||
helper := JsonParser{}
|
||||
config.Issuer = baseURL(config.Issuer)
|
||||
|
||||
@@ -83,13 +83,7 @@ type pocketIdUserGroupDto struct {
|
||||
}
|
||||
|
||||
func NewPocketIdManager(config PocketIdClientConfig, appMetrics telemetry.AppMetrics) (*PocketIdManager, error) {
|
||||
httpTransport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
httpTransport.MaxIdleConns = 5
|
||||
|
||||
httpClient := &http.Client{
|
||||
Timeout: idpTimeout(),
|
||||
Transport: httpTransport,
|
||||
}
|
||||
httpClient := newHTTPClient()
|
||||
|
||||
helper := JsonParser{}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ package idp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -81,6 +83,23 @@ const (
|
||||
defaultTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// errRedirectRefused is returned instead of http.ErrUseLastResponse so the
|
||||
// client closes the redirect response rather than handing it back unread.
|
||||
var errRedirectRefused = errors.New("redirect refused")
|
||||
|
||||
func newHTTPClient() *http.Client {
|
||||
httpTransport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
httpTransport.MaxIdleConns = 5
|
||||
|
||||
return &http.Client{
|
||||
Timeout: idpTimeout(),
|
||||
Transport: httpTransport,
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return errRedirectRefused
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// idpTimeout returns a timeout value for the IDP
|
||||
func idpTimeout() time.Duration {
|
||||
timeoutStr, ok := os.LookupEnv(idpTimeoutEnv)
|
||||
|
||||
@@ -160,13 +160,7 @@ func verifyJWTConfig(config ZitadelClientConfig) error {
|
||||
|
||||
// NewZitadelManager creates a new instance of the ZitadelManager.
|
||||
func NewZitadelManager(config ZitadelClientConfig, appMetrics telemetry.AppMetrics) (*ZitadelManager, error) {
|
||||
httpTransport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
httpTransport.MaxIdleConns = 5
|
||||
|
||||
httpClient := &http.Client{
|
||||
Timeout: idpTimeout(),
|
||||
Transport: httpTransport,
|
||||
}
|
||||
httpClient := newHTTPClient()
|
||||
|
||||
helper := JsonParser{}
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ func (am *DefaultAccountManager) GetValidatedPeers(ctx context.Context, accountI
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
peers, err = am.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "")
|
||||
peers, err = am.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "", "")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
package port_forwarding
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/peer"
|
||||
nbtypes "github.com/netbirdio/netbird/management/server/types"
|
||||
)
|
||||
|
||||
type Controller interface {
|
||||
SendUpdate(ctx context.Context, accountID string, affectedProxyID string, affectedPeerIDs []string, accountPeers map[string]*peer.Peer)
|
||||
GetProxyNetworkMaps(ctx context.Context, accountID, peerID string, accountPeers map[string]*peer.Peer) (map[string]*nbtypes.NetworkMap, error)
|
||||
GetProxyNetworkMapsAll(ctx context.Context, accountID string, accountPeers map[string]*peer.Peer) (map[string]*nbtypes.NetworkMap, error)
|
||||
IsPeerInIngressPorts(ctx context.Context, accountID, peerID string) (bool, error)
|
||||
}
|
||||
|
||||
type ControllerMock struct {
|
||||
}
|
||||
|
||||
func NewControllerMock() *ControllerMock {
|
||||
return &ControllerMock{}
|
||||
}
|
||||
|
||||
func (c *ControllerMock) SendUpdate(ctx context.Context, accountID string, affectedProxyID string, affectedPeerIDs []string, accountPeers map[string]*peer.Peer) {
|
||||
// noop
|
||||
}
|
||||
|
||||
func (c *ControllerMock) GetProxyNetworkMaps(ctx context.Context, accountID, peerID string, accountPeers map[string]*peer.Peer) (map[string]*nbtypes.NetworkMap, error) {
|
||||
return make(map[string]*nbtypes.NetworkMap), nil
|
||||
}
|
||||
|
||||
func (c *ControllerMock) GetProxyNetworkMapsAll(ctx context.Context, accountID string, accountPeers map[string]*peer.Peer) (map[string]*nbtypes.NetworkMap, error) {
|
||||
return make(map[string]*nbtypes.NetworkMap), nil
|
||||
}
|
||||
|
||||
func (c *ControllerMock) IsPeerInIngressPorts(ctx context.Context, accountID, peerID string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
@@ -57,6 +57,7 @@ func (jm *Manager) CreateJobChannel(ctx context.Context, accountID, peerID strin
|
||||
if ch, ok := jm.jobChannels[peerID]; ok {
|
||||
ch.Close()
|
||||
delete(jm.jobChannels, peerID)
|
||||
jm.failPendingLocked(ctx, accountID, peerID, "Pending job cleanup: job stream replaced by a newer stream")
|
||||
}
|
||||
|
||||
ch := NewChannel()
|
||||
@@ -127,24 +128,35 @@ func (jm *Manager) HandleResponse(ctx context.Context, resp *proto.JobResponse,
|
||||
return nil
|
||||
}
|
||||
|
||||
// CloseChannel closes a peer’s channel and cleans up its jobs
|
||||
func (jm *Manager) CloseChannel(ctx context.Context, accountID, peerID string) {
|
||||
// CloseChannel closes the peer's job channel session and fails its pending jobs. It does nothing
|
||||
// when a newer job stream has registered a different channel for the peer.
|
||||
func (jm *Manager) CloseChannel(ctx context.Context, accountID, peerID string, session *Channel) {
|
||||
jm.mu.Lock()
|
||||
defer jm.mu.Unlock()
|
||||
|
||||
if ch, ok := jm.jobChannels[peerID]; ok {
|
||||
if ch != session {
|
||||
log.WithContext(ctx).Debugf("skipped closing job channel: peer %s is owned by a newer job stream", peerID)
|
||||
return
|
||||
}
|
||||
ch.Close()
|
||||
delete(jm.jobChannels, peerID)
|
||||
}
|
||||
|
||||
jm.failPendingLocked(ctx, accountID, peerID, "Time out peer disconnected")
|
||||
}
|
||||
|
||||
// failPendingLocked marks the peer's pending jobs as failed and drops them from memory. The caller
|
||||
// must hold jm.mu.
|
||||
func (jm *Manager) failPendingLocked(ctx context.Context, accountID, peerID, reason string) {
|
||||
for jobID, ev := range jm.pending {
|
||||
if ev.PeerID == peerID {
|
||||
// if the client disconnect and there is pending job then mark it as failed
|
||||
if err := jm.Store.MarkPendingJobsAsFailed(ctx, accountID, peerID, jobID, "Time out peer disconnected"); err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to mark pending jobs as failed: %v", err)
|
||||
}
|
||||
delete(jm.pending, jobID)
|
||||
if ev.PeerID != peerID {
|
||||
continue
|
||||
}
|
||||
if err := jm.Store.MarkPendingJobsAsFailed(ctx, accountID, peerID, jobID, reason); err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to mark pending jobs as failed: %v", err)
|
||||
}
|
||||
delete(jm.pending, jobID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
)
|
||||
|
||||
func TestCloseChannel_OwnSession(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mockStore := store.NewMockStore(gomock.NewController(t))
|
||||
jm := NewJobManager(nil, mockStore, nil)
|
||||
|
||||
session := NewChannel()
|
||||
jm.jobChannels["peer-1"] = session
|
||||
jm.pending["job-1"] = &Event{PeerID: "peer-1"}
|
||||
jm.pending["job-2"] = &Event{PeerID: "peer-2"}
|
||||
|
||||
mockStore.EXPECT().MarkPendingJobsAsFailed(gomock.Any(), "account-1", "peer-1", "job-1", gomock.Any()).Return(nil)
|
||||
|
||||
jm.CloseChannel(ctx, "account-1", "peer-1", session)
|
||||
|
||||
assert.False(t, jm.IsPeerConnected("peer-1"))
|
||||
_, err := session.Event(ctx)
|
||||
assert.ErrorIs(t, err, ErrJobChannelClosed)
|
||||
assert.NotContains(t, jm.pending, "job-1")
|
||||
assert.Contains(t, jm.pending, "job-2", "jobs of other peers must be kept")
|
||||
}
|
||||
|
||||
func TestCloseChannel_NewerSessionOwnsPeer(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
jm := NewJobManager(nil, store.NewMockStore(gomock.NewController(t)), nil)
|
||||
|
||||
stale := NewChannel()
|
||||
current := NewChannel()
|
||||
jm.jobChannels["peer-1"] = current
|
||||
jm.pending["job-1"] = &Event{PeerID: "peer-1"}
|
||||
|
||||
jm.CloseChannel(ctx, "account-1", "peer-1", stale)
|
||||
|
||||
require.True(t, jm.IsPeerConnected("peer-1"))
|
||||
assert.Contains(t, jm.pending, "job-1", "jobs served by the newer stream must not be failed")
|
||||
|
||||
event := &Event{PeerID: "peer-1"}
|
||||
require.NoError(t, current.AddEvent(ctx, time.Second, event))
|
||||
got, err := current.Event(ctx)
|
||||
require.NoError(t, err, "newer session channel must stay open")
|
||||
assert.Equal(t, event, got)
|
||||
}
|
||||
|
||||
func TestCreateJobChannel_ReplacesSessionAndDropsItsPendingJobs(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mockStore := store.NewMockStore(gomock.NewController(t))
|
||||
jm := NewJobManager(nil, mockStore, nil)
|
||||
|
||||
stale := NewChannel()
|
||||
jm.jobChannels["peer-1"] = stale
|
||||
jm.pending["job-1"] = &Event{PeerID: "peer-1"}
|
||||
jm.pending["job-2"] = &Event{PeerID: "peer-2"}
|
||||
|
||||
mockStore.EXPECT().MarkAllPendingJobsAsFailed(gomock.Any(), "account-1", "peer-1", gomock.Any()).Return(nil)
|
||||
mockStore.EXPECT().MarkPendingJobsAsFailed(gomock.Any(), "account-1", "peer-1", "job-1", "Pending job cleanup: job stream replaced by a newer stream").Return(nil)
|
||||
|
||||
current := jm.CreateJobChannel(ctx, "account-1", "peer-1")
|
||||
|
||||
require.NotSame(t, stale, current)
|
||||
_, err := stale.Event(ctx)
|
||||
assert.ErrorIs(t, err, ErrJobChannelClosed, "replaced channel must be closed")
|
||||
assert.True(t, jm.IsPeerConnected("peer-1"))
|
||||
assert.False(t, jm.IsPeerHasPendingJobs("peer-1"), "jobs of the replaced stream must be dropped")
|
||||
assert.Contains(t, jm.pending, "job-2", "jobs of other peers must be kept")
|
||||
}
|
||||
@@ -12,9 +12,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/mock/gomock"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
@@ -31,7 +31,6 @@ import (
|
||||
"github.com/netbirdio/netbird/management/server/activity"
|
||||
"github.com/netbirdio/netbird/management/server/cache"
|
||||
"github.com/netbirdio/netbird/management/server/groups"
|
||||
"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
|
||||
"github.com/netbirdio/netbird/management/server/job"
|
||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||
"github.com/netbirdio/netbird/management/server/permissions"
|
||||
@@ -376,9 +375,9 @@ func startManagementForTest(t *testing.T, testFile string, config *config.Config
|
||||
return nil, nil, "", cleanup, err
|
||||
}
|
||||
|
||||
networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeralMgr, config, nil)
|
||||
networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", ephemeralMgr, config, nil)
|
||||
accountManager, err := BuildManager(ctx, nil, store, networkMapController, jobManager, nil, "",
|
||||
eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
|
||||
eventStore, nil, false, MockIntegratedValidator{}, metrics, settingsMockManager, permissionsManager, false, cacheStore)
|
||||
|
||||
if err != nil {
|
||||
cleanup()
|
||||
|
||||
@@ -10,10 +10,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/mock/gomock"
|
||||
pb "github.com/golang/protobuf/proto" //nolint
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"go.uber.org/mock/gomock"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
@@ -30,7 +30,6 @@ import (
|
||||
"github.com/netbirdio/netbird/management/server/activity"
|
||||
nbcache "github.com/netbirdio/netbird/management/server/cache"
|
||||
"github.com/netbirdio/netbird/management/server/groups"
|
||||
"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
|
||||
"github.com/netbirdio/netbird/management/server/job"
|
||||
"github.com/netbirdio/netbird/management/server/permissions"
|
||||
"github.com/netbirdio/netbird/management/server/settings"
|
||||
@@ -216,7 +215,7 @@ func startServer(
|
||||
|
||||
updateManager := update_channel.NewPeersUpdateManager(metrics)
|
||||
requestBuffer := server.NewAccountRequestBuffer(ctx, str)
|
||||
networkMapController := controller.NewController(ctx, str, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(str, peers.NewManager(str, permissionsManager)), config, nil)
|
||||
networkMapController := controller.NewController(ctx, str, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", ephemeral_manager.NewEphemeralManager(str, peers.NewManager(str, permissionsManager)), config, nil)
|
||||
|
||||
accountManager, err := server.BuildManager(
|
||||
context.Background(),
|
||||
@@ -231,7 +230,6 @@ func startServer(
|
||||
false,
|
||||
server.MockIntegratedValidator{},
|
||||
metrics,
|
||||
port_forwarding.NewControllerMock(),
|
||||
settingsMockManager,
|
||||
permissionsManager,
|
||||
false,
|
||||
|
||||
@@ -39,7 +39,7 @@ type MockAccountManager struct {
|
||||
GetAccountIDByUserIdFunc func(ctx context.Context, userAuth auth.UserAuth) (string, error)
|
||||
GetUserFromUserAuthFunc func(ctx context.Context, userAuth auth.UserAuth) (*types.User, error)
|
||||
ListUsersFunc func(ctx context.Context, accountID string) ([]*types.User, error)
|
||||
GetPeersFunc func(ctx context.Context, accountID, userID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error)
|
||||
GetPeersFunc func(ctx context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error)
|
||||
MarkPeerConnectedFunc func(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64, nmap *types.NetworkMap) error
|
||||
MarkPeerDisconnectedFunc func(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64) error
|
||||
SyncAndMarkPeerFunc func(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*nmdata.PostureChecks, int64, error)
|
||||
@@ -133,6 +133,7 @@ type MockAccountManager struct {
|
||||
GetOrCreateAccountByPrivateDomainFunc func(ctx context.Context, initiatorId, domain string) (*types.Account, bool, error)
|
||||
|
||||
AllowSyncFunc func(string, uint64) bool
|
||||
TrackCertificateChallengesFunc func(ctx context.Context, accountID, peerID string, streamStart time.Time)
|
||||
UpdateAccountPeersFunc func(ctx context.Context, accountID string, reason types.UpdateReason)
|
||||
ExpandAndUpdateAffectedFunc func(ctx context.Context, accountID string, snap *affectedpeers.Snapshot, change affectedpeers.Change)
|
||||
BufferUpdateAccountPeersFunc func(ctx context.Context, accountID string, reason types.UpdateReason)
|
||||
@@ -206,6 +207,16 @@ func (am *MockAccountManager) UpdateGroups(ctx context.Context, accountID, userI
|
||||
return status.Errorf(codes.Unimplemented, "method UpdateGroups is not implemented")
|
||||
}
|
||||
|
||||
// TrackCertificateChallenges mocks TrackCertificateChallenges of the account manager.
|
||||
func (am *MockAccountManager) TrackCertificateChallenges(ctx context.Context, accountID, peerID string, streamStart time.Time) {
|
||||
if am.TrackCertificateChallengesFunc != nil {
|
||||
am.TrackCertificateChallengesFunc(ctx, accountID, peerID, streamStart)
|
||||
}
|
||||
}
|
||||
|
||||
// UntrackCertificateChallenges mocks UntrackCertificateChallenges of the account manager.
|
||||
func (am *MockAccountManager) UntrackCertificateChallenges(string, string, time.Time) {}
|
||||
|
||||
func (am *MockAccountManager) UpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) {
|
||||
if am.UpdateAccountPeersFunc != nil {
|
||||
am.UpdateAccountPeersFunc(ctx, accountID, reason)
|
||||
@@ -807,9 +818,9 @@ func (am *MockAccountManager) GetAccountIDFromUserAuth(ctx context.Context, user
|
||||
}
|
||||
|
||||
// GetPeers mocks GetPeers of the AccountManager interface
|
||||
func (am *MockAccountManager) GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error) {
|
||||
func (am *MockAccountManager) GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) {
|
||||
if am.GetPeersFunc != nil {
|
||||
return am.GetPeersFunc(ctx, accountID, userID, nameFilter, ipFilter)
|
||||
return am.GetPeersFunc(ctx, accountID, userID, nameFilter, ipFilter, macFilter)
|
||||
}
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetPeers is not implemented")
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
"github.com/netbirdio/netbird/management/internals/server/config"
|
||||
"github.com/netbirdio/netbird/management/server/activity"
|
||||
"github.com/netbirdio/netbird/management/server/cache"
|
||||
"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
|
||||
"github.com/netbirdio/netbird/management/server/job"
|
||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||
"github.com/netbirdio/netbird/management/server/permissions"
|
||||
@@ -803,9 +802,9 @@ func createNSManager(t *testing.T) (*DefaultAccountManager, error) {
|
||||
|
||||
updateManager := update_channel.NewPeersUpdateManager(metrics)
|
||||
requestBuffer := NewAccountRequestBuffer(ctx, store)
|
||||
networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nil)
|
||||
networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nil)
|
||||
|
||||
return BuildManager(context.Background(), nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
|
||||
return BuildManager(context.Background(), nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, settingsMockManager, permissionsManager, false, cacheStore)
|
||||
}
|
||||
|
||||
func createNSStore(t *testing.T) (store.Store, error) {
|
||||
@@ -966,147 +965,151 @@ func TestValidateDomain(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNameServerAccountPeersUpdate(t *testing.T) {
|
||||
manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t)
|
||||
runPeerUpdateTest(t, func(t *testing.T) {
|
||||
manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t)
|
||||
|
||||
var newNameServerGroupA *nbdns.NameServerGroup
|
||||
var newNameServerGroupB *nbdns.NameServerGroup
|
||||
var newNameServerGroupA *nbdns.NameServerGroup
|
||||
var newNameServerGroupB *nbdns.NameServerGroup
|
||||
|
||||
err := manager.CreateGroup(context.Background(), account.Id, userID, &types.Group{
|
||||
ID: "groupA",
|
||||
Name: "GroupA",
|
||||
Peers: []string{},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = manager.CreateGroup(context.Background(), account.Id, userID, &types.Group{
|
||||
ID: "groupB",
|
||||
Name: "GroupB",
|
||||
Peers: []string{peer1.ID, peer2.ID, peer3.ID},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
updMsg := updateManager.CreateChannel(context.Background(), peer1.ID)
|
||||
t.Cleanup(func() {
|
||||
updateManager.CloseChannel(context.Background(), peer1.ID)
|
||||
})
|
||||
|
||||
// Creating a nameserver group with a distribution group no peers should not update account peers
|
||||
// and not send peer update
|
||||
t.Run("creating nameserver group with distribution group no peers", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldNotReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
newNameServerGroupA, err = manager.CreateNameServerGroup(
|
||||
context.Background(), account.Id, "nsGroupA", "nsGroupA", []nbdns.NameServer{{
|
||||
IP: netip.MustParseAddr("1.1.1.1"),
|
||||
NSType: nbdns.UDPNameServerType,
|
||||
Port: nbdns.DefaultDNSPort,
|
||||
}},
|
||||
[]string{"groupA"},
|
||||
true, []string{}, true, userID, false,
|
||||
)
|
||||
err := manager.CreateGroup(context.Background(), account.Id, userID, &types.Group{
|
||||
ID: "groupA",
|
||||
Name: "GroupA",
|
||||
Peers: []string{},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("timeout waiting for peerShouldNotReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
// saving a nameserver group with a distribution group with no peers should not update account peers
|
||||
// and not send peer update
|
||||
t.Run("saving nameserver group with distribution group no peers", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldNotReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
err = manager.SaveNameServerGroup(context.Background(), account.Id, userID, newNameServerGroupA)
|
||||
err = manager.CreateGroup(context.Background(), account.Id, userID, &types.Group{
|
||||
ID: "groupB",
|
||||
Name: "GroupB",
|
||||
Peers: []string{peer1.ID, peer2.ID, peer3.ID},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("timeout waiting for peerShouldNotReceiveUpdate")
|
||||
}
|
||||
})
|
||||
updMsg := updateManager.CreateChannel(context.Background(), peer1.ID)
|
||||
t.Cleanup(func() {
|
||||
updateManager.CloseChannel(context.Background(), peer1.ID)
|
||||
})
|
||||
|
||||
// Creating a nameserver group with a distribution group no peers should update account peers and send peer update
|
||||
t.Run("creating nameserver group with distribution group has peers", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
// Creating a nameserver group with a distribution group no peers should not update account peers
|
||||
// and not send peer update
|
||||
step(t, "creating nameserver group with distribution group no peers", func(t *testing.T) {
|
||||
settleAffectedUpdates(updMsg)
|
||||
|
||||
newNameServerGroupB, err = manager.CreateNameServerGroup(
|
||||
context.Background(), account.Id, "nsGroupB", "nsGroupB", []nbdns.NameServer{{
|
||||
IP: netip.MustParseAddr("1.1.1.1"),
|
||||
NSType: nbdns.UDPNameServerType,
|
||||
Port: nbdns.DefaultDNSPort,
|
||||
}},
|
||||
[]string{"groupB"},
|
||||
true, []string{}, true, userID, false,
|
||||
)
|
||||
assert.NoError(t, err)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldNotReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("timeout waiting for peerShouldNotReceiveUpdate")
|
||||
}
|
||||
})
|
||||
newNameServerGroupA, err = manager.CreateNameServerGroup(
|
||||
context.Background(), account.Id, "nsGroupA", "nsGroupA", []nbdns.NameServer{{
|
||||
IP: netip.MustParseAddr("1.1.1.1"),
|
||||
NSType: nbdns.UDPNameServerType,
|
||||
Port: nbdns.DefaultDNSPort,
|
||||
}},
|
||||
[]string{"groupA"},
|
||||
true, []string{}, true, userID, false,
|
||||
)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// saving a nameserver group with a distribution group with peers should update account peers and send peer update
|
||||
t.Run("saving nameserver group with distribution group has peers", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("timeout waiting for peerShouldNotReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
newNameServerGroupB.NameServers = []nbdns.NameServer{
|
||||
{
|
||||
IP: netip.MustParseAddr("1.1.1.2"),
|
||||
NSType: nbdns.UDPNameServerType,
|
||||
Port: nbdns.DefaultDNSPort,
|
||||
},
|
||||
{
|
||||
IP: netip.MustParseAddr("8.8.8.8"),
|
||||
NSType: nbdns.UDPNameServerType,
|
||||
Port: nbdns.DefaultDNSPort,
|
||||
},
|
||||
}
|
||||
err = manager.SaveNameServerGroup(context.Background(), account.Id, userID, newNameServerGroupB)
|
||||
assert.NoError(t, err)
|
||||
// saving a nameserver group with a distribution group with no peers should not update account peers
|
||||
// and not send peer update
|
||||
step(t, "saving nameserver group with distribution group no peers", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldNotReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
err = manager.SaveNameServerGroup(context.Background(), account.Id, userID, newNameServerGroupA)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Deleting a nameserver group should update account peers and send peer update
|
||||
t.Run("deleting nameserver group", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("timeout waiting for peerShouldNotReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
err = manager.DeleteNameServerGroup(context.Background(), account.Id, newNameServerGroupB.ID, userID)
|
||||
assert.NoError(t, err)
|
||||
// Creating a nameserver group with a distribution group no peers should update account peers and send peer update
|
||||
step(t, "creating nameserver group with distribution group has peers", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
newNameServerGroupB, err = manager.CreateNameServerGroup(
|
||||
context.Background(), account.Id, "nsGroupB", "nsGroupB", []nbdns.NameServer{{
|
||||
IP: netip.MustParseAddr("1.1.1.1"),
|
||||
NSType: nbdns.UDPNameServerType,
|
||||
Port: nbdns.DefaultDNSPort,
|
||||
}},
|
||||
[]string{"groupB"},
|
||||
true, []string{}, true, userID, false,
|
||||
)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
// saving a nameserver group with a distribution group with peers should update account peers and send peer update
|
||||
step(t, "saving nameserver group with distribution group has peers", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
newNameServerGroupB.NameServers = []nbdns.NameServer{
|
||||
{
|
||||
IP: netip.MustParseAddr("1.1.1.2"),
|
||||
NSType: nbdns.UDPNameServerType,
|
||||
Port: nbdns.DefaultDNSPort,
|
||||
},
|
||||
{
|
||||
IP: netip.MustParseAddr("8.8.8.8"),
|
||||
NSType: nbdns.UDPNameServerType,
|
||||
Port: nbdns.DefaultDNSPort,
|
||||
},
|
||||
}
|
||||
err = manager.SaveNameServerGroup(context.Background(), account.Id, userID, newNameServerGroupB)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
// Deleting a nameserver group should update account peers and send peer update
|
||||
step(t, "deleting nameserver group", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
err = manager.DeleteNameServerGroup(context.Background(), account.Id, newNameServerGroupB.ID, userID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
+110
-72
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
b64 "encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"github.com/rs/xid"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/exp/maps"
|
||||
"gorm.io/gorm"
|
||||
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
"github.com/netbirdio/netbird/management/server/idp"
|
||||
@@ -47,7 +49,7 @@ const (
|
||||
|
||||
// GetPeers returns peers visible to the user within an account.
|
||||
// Users with "peers:read" see all peers. Otherwise, users see only their own peers, or none if restricted by account settings.
|
||||
func (am *DefaultAccountManager) GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error) {
|
||||
func (am *DefaultAccountManager) GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) {
|
||||
user, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthNone, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -59,7 +61,7 @@ func (am *DefaultAccountManager) GetPeers(ctx context.Context, accountID, userID
|
||||
}
|
||||
|
||||
if allowed {
|
||||
return am.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, nameFilter, ipFilter)
|
||||
return am.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, nameFilter, ipFilter, macFilter)
|
||||
}
|
||||
|
||||
settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID)
|
||||
@@ -288,19 +290,34 @@ func (am *DefaultAccountManager) UpdatePeer(ctx context.Context, accountID, user
|
||||
var newLabel string
|
||||
|
||||
newLabel, err = nbdns.GetParsedDomainLabel(update.Name)
|
||||
if err != nil {
|
||||
newLabel = ""
|
||||
} else {
|
||||
_, err := transaction.GetPeerIdByLabel(ctx, store.LockingStrengthNone, accountID, newLabel)
|
||||
if err == nil {
|
||||
newLabel = ""
|
||||
}
|
||||
}
|
||||
|
||||
if newLabel == "" {
|
||||
newLabel, err = getPeerIPDNSLabel(peer.IP, update.Name)
|
||||
if settings.PeerHostnameCollisionRejected {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get free DNS label: %w", err)
|
||||
return status.Errorf(status.InvalidArgument, "invalid peer name: %v", err)
|
||||
}
|
||||
if newLabel != peer.DNSLabel {
|
||||
taken, err := peerLabelTaken(ctx, transaction, accountID, newLabel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if taken {
|
||||
return errPeerHostnameInUse(update.Name)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
newLabel = ""
|
||||
} else {
|
||||
_, err := transaction.GetPeerIdByLabel(ctx, store.LockingStrengthNone, accountID, newLabel)
|
||||
if err == nil {
|
||||
newLabel = ""
|
||||
}
|
||||
}
|
||||
|
||||
if newLabel == "" {
|
||||
newLabel, err = getPeerIPDNSLabel(peer.IP, update.Name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get free DNS label: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
peer.Name = update.Name
|
||||
@@ -336,6 +353,9 @@ func (am *DefaultAccountManager) UpdatePeer(ctx context.Context, accountID, user
|
||||
return transaction.SavePeer(ctx, accountID, peer)
|
||||
})
|
||||
if err != nil {
|
||||
if peerLabelChanged && isUniqueConstraintError(err) {
|
||||
return nil, errPeerHostnameInUse(peer.Name)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -710,11 +730,11 @@ func (am *DefaultAccountManager) handleUserAddedPeer(ctx context.Context, accoun
|
||||
func (am *DefaultAccountManager) handleSetupKeyAddedPeer(ctx context.Context, encodedHashedKey string, peer *nbpeer.Peer, opEvent *activity.Event, config *peerAddAuthConfig) error {
|
||||
sk, err := am.Store.GetSetupKeyBySecret(ctx, store.LockingStrengthNone, encodedHashedKey)
|
||||
if err != nil {
|
||||
return status.Errorf(status.NotFound, "couldn't add peer: setup key is invalid")
|
||||
return err
|
||||
}
|
||||
|
||||
if !sk.IsValid() {
|
||||
return status.Errorf(status.NotFound, "couldn't add peer: setup key is invalid")
|
||||
return status.Errorf(status.PermissionDenied, "couldn't add peer: setup key is invalid")
|
||||
}
|
||||
|
||||
if !sk.AllowExtraDNSLabels && len(peer.ExtraDNSLabels) > 0 {
|
||||
@@ -832,6 +852,14 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe
|
||||
return nil, nil, nil, false, fmt.Errorf("failed getting network: %w", err)
|
||||
}
|
||||
|
||||
hostLabel, err := nbdns.GetParsedDomainLabel(peer.Meta.Hostname)
|
||||
if err != nil {
|
||||
return nil, nil, nil, false, status.Errorf(status.InvalidArgument, "invalid peer hostname: %v", err)
|
||||
}
|
||||
rejectLabelCollision := settings.PeerHostnameCollisionRejected
|
||||
labelTaken := false
|
||||
|
||||
var insertErr error
|
||||
maxAttempts := 10
|
||||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||
netPrefix, err := netip.ParsePrefix(network.Net.String())
|
||||
@@ -843,17 +871,12 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe
|
||||
return nil, nil, nil, false, fmt.Errorf("failed to get free IP: %w", err)
|
||||
}
|
||||
|
||||
var freeLabel string
|
||||
if ephemeral || attempt > 1 {
|
||||
freeLabel := hostLabel
|
||||
if !rejectLabelCollision && (ephemeral || labelTaken) {
|
||||
freeLabel, err = getPeerIPDNSLabel(freeIP, peer.Meta.Hostname)
|
||||
if err != nil {
|
||||
return nil, nil, nil, false, fmt.Errorf("failed to get free DNS label: %w", err)
|
||||
}
|
||||
} else {
|
||||
freeLabel, err = nbdns.GetParsedDomainLabel(peer.Meta.Hostname)
|
||||
if err != nil {
|
||||
return nil, nil, nil, false, fmt.Errorf("failed to get free DNS label: %w", err)
|
||||
}
|
||||
}
|
||||
newPeer.DNSLabel = freeLabel
|
||||
newPeer.IP = freeIP
|
||||
@@ -884,7 +907,7 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe
|
||||
}
|
||||
}
|
||||
|
||||
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
|
||||
insertErr = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
|
||||
err = transaction.AddPeerToAccount(ctx, newPeer)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -915,12 +938,12 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe
|
||||
case addedBySetupKey:
|
||||
sk, err := transaction.GetSetupKeyBySecret(ctx, store.LockingStrengthUpdate, encodedHashedKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get setup key: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// we validate at the end to not block the setup key for too long
|
||||
if !sk.IsValid() {
|
||||
return status.Errorf(status.PreconditionFailed, "couldn't add peer: setup key is invalid")
|
||||
return status.Errorf(status.PermissionDenied, "couldn't add peer: setup key is invalid")
|
||||
}
|
||||
|
||||
err = transaction.IncrementSetupKeyUsage(ctx, peerAddConfig.SetupKeyID)
|
||||
@@ -942,16 +965,28 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe
|
||||
log.WithContext(ctx).Debugf("Peer %s added to account %s", newPeer.ID, accountID)
|
||||
return nil
|
||||
})
|
||||
if err == nil {
|
||||
if insertErr == nil {
|
||||
break
|
||||
}
|
||||
|
||||
if isUniqueConstraintError(err) {
|
||||
log.WithContext(ctx).WithFields(log.Fields{"dns_label": freeLabel, "ip": freeIP}).Tracef("Failed to add peer in attempt %d, retrying: %v", attempt, err)
|
||||
if isUniqueConstraintError(insertErr) {
|
||||
if freeLabel == hostLabel {
|
||||
labelTaken, err = peerLabelTaken(ctx, am.Store, accountID, hostLabel)
|
||||
if err != nil {
|
||||
return nil, nil, nil, false, err
|
||||
}
|
||||
if labelTaken && rejectLabelCollision {
|
||||
return nil, nil, nil, false, errPeerHostnameInUse(peer.Meta.Hostname)
|
||||
}
|
||||
}
|
||||
log.WithContext(ctx).WithFields(log.Fields{"dns_label": freeLabel, "ip": freeIP}).Tracef("Failed to add peer in attempt %d, retrying: %v", attempt, insertErr)
|
||||
continue
|
||||
}
|
||||
|
||||
return nil, nil, nil, false, fmt.Errorf("failed to add peer to database: %w", err)
|
||||
return nil, nil, nil, false, fmt.Errorf("failed to add peer to database: %w", insertErr)
|
||||
}
|
||||
if insertErr != nil {
|
||||
return nil, nil, nil, false, fmt.Errorf("add peer after %d attempts: %w", maxAttempts, insertErr)
|
||||
}
|
||||
if newPeer == nil {
|
||||
return nil, nil, nil, false, fmt.Errorf("new peer is nil")
|
||||
@@ -985,6 +1020,24 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe
|
||||
return newPeer, network, postureChecks, enableSSH, nil
|
||||
}
|
||||
|
||||
// peerLabelTaken reports whether a peer of the account already uses the DNS label.
|
||||
func peerLabelTaken(ctx context.Context, s store.Store, accountID, label string) (bool, error) {
|
||||
_, err := s.GetPeerIdByLabel(ctx, store.LockingStrengthNone, accountID, label)
|
||||
switch {
|
||||
case err == nil:
|
||||
return true, nil
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
return false, nil
|
||||
default:
|
||||
return false, fmt.Errorf("check peer DNS label: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// errPeerHostnameInUse is a PermissionDenied error because the client treats only that gRPC code as a permanent login failure instead of retrying.
|
||||
func errPeerHostnameInUse(hostname string) error {
|
||||
return status.Errorf(status.PermissionDenied, "peer with hostname %q is already registered", hostname)
|
||||
}
|
||||
|
||||
func getPeerIPDNSLabel(ip netip.Addr, peerHostName string) (string, error) {
|
||||
if !ip.Is4() {
|
||||
return "", fmt.Errorf("DNS label generation requires an IPv4 address, got %s", ip)
|
||||
@@ -1543,30 +1596,20 @@ func (am *DefaultAccountManager) UpdateAccountPeers(ctx context.Context, account
|
||||
// ExpandAndUpdateAffected expands a Snapshot (loaded INSIDE the now-committed
|
||||
// transaction) into the affected peers and dispatches the network-map refresh.
|
||||
// Pure in-memory work plus dispatch, so it runs AFTER commit — the fan-out walk
|
||||
// never holds the write lock, over the consistent in-tx snapshot. Exported so the
|
||||
// networks sub-package managers (which hold only account.Manager) share it.
|
||||
// never holds the write lock, over the consistent in-tx snapshot. The walk runs
|
||||
// before the dispatch goroutine starts, so the snapshot is released on return.
|
||||
// Exported so the networks sub-package managers (which hold only account.Manager)
|
||||
// share it.
|
||||
func (am *DefaultAccountManager) ExpandAndUpdateAffected(ctx context.Context, accountID string, snap *affectedpeers.Snapshot, change affectedpeers.Change) {
|
||||
go am.dispatchAffected(ctx, accountID, []*affectedpeers.Snapshot{snap}, []affectedpeers.Change{change})
|
||||
go am.dispatchAffectedPeerIDs(ctx, accountID, snap.Expand(ctx, accountID, change))
|
||||
}
|
||||
|
||||
// dispatchAffected expands one or more (snapshot, change) pairs — collected across
|
||||
// one or several transactions — unions their affected peers, and dispatches a
|
||||
// single network-map refresh. Each snapshot must already be loaded inside its
|
||||
// transaction; this runs AFTER commit (pure in-memory + dispatch). It is spawned
|
||||
// in a goroutine that outlives the request, so it detaches from the request
|
||||
// context's cancellation up front.
|
||||
func (am *DefaultAccountManager) dispatchAffected(ctx context.Context, accountID string, snaps []*affectedpeers.Snapshot, changes []affectedpeers.Change) {
|
||||
// dispatchAffectedPeerIDs dispatches a single network-map refresh for already
|
||||
// expanded affected peer IDs. It may run in a goroutine that outlives the
|
||||
// request, so it detaches from the request context's cancellation up front.
|
||||
func (am *DefaultAccountManager) dispatchAffectedPeerIDs(ctx context.Context, accountID string, affectedPeerIDs []string) {
|
||||
ctx = context.WithoutCancel(ctx)
|
||||
|
||||
var lists [][]string
|
||||
for i, snap := range snaps {
|
||||
if snap == nil {
|
||||
continue
|
||||
}
|
||||
lists = append(lists, snap.Expand(ctx, accountID, changes[i]))
|
||||
}
|
||||
|
||||
affectedPeerIDs := unionStrings(lists...)
|
||||
if len(affectedPeerIDs) == 0 {
|
||||
log.WithContext(ctx).Tracef("no affected peers for account %s", accountID)
|
||||
return
|
||||
@@ -1576,21 +1619,19 @@ func (am *DefaultAccountManager) dispatchAffected(ctx context.Context, accountID
|
||||
_ = am.networkMapController.UpdateAffectedPeers(ctx, accountID, affectedPeerIDs)
|
||||
}
|
||||
|
||||
// unionStrings concatenates the given string lists into one deduplicated slice,
|
||||
// preserving first-occurrence order.
|
||||
func unionStrings(lists ...[]string) []string {
|
||||
seen := make(map[string]struct{})
|
||||
var out []string
|
||||
for _, list := range lists {
|
||||
for _, id := range list {
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
// affectedPeerSet collects the affected peer IDs of several snapshot expansions,
|
||||
// deduplicating as they are added so it stays bounded by the account's peers
|
||||
// however many items a batch has.
|
||||
type affectedPeerSet map[string]struct{}
|
||||
|
||||
func (s affectedPeerSet) add(peerIDs []string) {
|
||||
for _, id := range peerIDs {
|
||||
s[id] = struct{}{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s affectedPeerSet) ids() []string {
|
||||
return maps.Keys(s)
|
||||
}
|
||||
|
||||
// affectedPeerIDsFromNetworkMap returns the peer IDs referenced by a peer's
|
||||
@@ -1661,6 +1702,9 @@ func (am *DefaultAccountManager) getNextPeerExpiration(ctx context.Context, acco
|
||||
}
|
||||
|
||||
settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID)
|
||||
if isNotFound(err) {
|
||||
return 0, false
|
||||
}
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to get account settings: %v", err)
|
||||
return peerSchedulerRetryInterval, true
|
||||
@@ -1704,6 +1748,9 @@ func (am *DefaultAccountManager) getNextInactivePeerExpiration(ctx context.Conte
|
||||
}
|
||||
|
||||
settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID)
|
||||
if isNotFound(err) {
|
||||
return 0, false
|
||||
}
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to get account settings: %v", err)
|
||||
return peerSchedulerRetryInterval, true
|
||||
@@ -1839,15 +1886,6 @@ func deletePeers(ctx context.Context, am *DefaultAccountManager, transaction sto
|
||||
|
||||
// validatePeerDelete checks if the peer can be deleted.
|
||||
func (am *DefaultAccountManager) validatePeerDelete(ctx context.Context, transaction store.Store, accountId, peerId string) error {
|
||||
linkedInIngressPorts, err := am.proxyController.IsPeerInIngressPorts(ctx, accountId, peerId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if linkedInIngressPorts {
|
||||
return status.Errorf(status.PreconditionFailed, "peer is linked to ingress ports: %s", peerId)
|
||||
}
|
||||
|
||||
linked, router := isPeerLinkedToNetworkRouter(ctx, transaction, accountId, peerId)
|
||||
if linked {
|
||||
return status.Errorf(status.PreconditionFailed, "peer is linked to a network router: %s", router.ID)
|
||||
|
||||
@@ -175,6 +175,7 @@ type PeerSystemMeta struct { //nolint:revive
|
||||
Environment Environment `gorm:"serializer:json"`
|
||||
Flags Flags `gorm:"serializer:json"`
|
||||
Files []File `gorm:"serializer:json"`
|
||||
Certificates []string `gorm:"serializer:json"`
|
||||
Capabilities []int32 `gorm:"serializer:json"`
|
||||
SyncMessageVersion int
|
||||
}
|
||||
@@ -200,7 +201,8 @@ func (p PeerSystemMeta) isEmpty() bool {
|
||||
p.SystemManufacturer == "" &&
|
||||
p.Environment.Cloud == "" &&
|
||||
p.Environment.Platform == "" &&
|
||||
len(p.Files) == 0
|
||||
len(p.Files) == 0 &&
|
||||
len(p.Certificates) == 0
|
||||
}
|
||||
|
||||
// AddedWithSSOLogin indicates whether this peer has been added with an SSO login by a user.
|
||||
@@ -419,6 +421,9 @@ func diffMeta(oldMeta, newMeta PeerSystemMeta, oldLocation, newLocation Location
|
||||
if !sameMultiset(oldMeta.Files, newMeta.Files) {
|
||||
add("files", fmt.Sprintf("%v", oldMeta.Files), fmt.Sprintf("%v", newMeta.Files))
|
||||
}
|
||||
if !sameMultiset(oldMeta.Certificates, newMeta.Certificates) {
|
||||
add("certificates", len(oldMeta.Certificates), len(newMeta.Certificates))
|
||||
}
|
||||
if oldMeta.SyncMessageVersion != newMeta.SyncMessageVersion {
|
||||
add("sync_meta_version", fmt.Sprintf("%d", oldMeta.SyncMessageVersion), fmt.Sprintf("%d", newMeta.SyncMessageVersion))
|
||||
}
|
||||
|
||||
+628
-400
File diff suppressed because it is too large
Load Diff
@@ -62,11 +62,11 @@ func TestAgentNetworkAdminRole(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestUsageViewerRole pins the least-privilege cost role: read on the
|
||||
// aggregated usage overview plus read-only on the resources its filters
|
||||
// and display columns resolve against (users, groups, peers, the provider
|
||||
// list) — no policies, no request-level logs (which can contain captured
|
||||
// prompts), nothing else in the account.
|
||||
// TestUsageViewerRole pins the read-only usage role: read on the aggregated
|
||||
// usage overview and the account-wide request-level logs, plus read-only on
|
||||
// the resources their filters and display columns resolve against (users,
|
||||
// groups, peers, the provider list) — no policies, guardrails, budgets, or
|
||||
// settings, nothing else in the account.
|
||||
func TestUsageViewerRole(t *testing.T) {
|
||||
manager := NewManager(nil)
|
||||
ctx := context.Background()
|
||||
@@ -76,6 +76,7 @@ func TestUsageViewerRole(t *testing.T) {
|
||||
|
||||
readOnly := []modules.Module{
|
||||
modules.AgentNetworkUsage,
|
||||
modules.AgentNetworkLogs,
|
||||
modules.AgentNetworkProviders,
|
||||
modules.Users,
|
||||
modules.Groups,
|
||||
@@ -83,7 +84,7 @@ func TestUsageViewerRole(t *testing.T) {
|
||||
}
|
||||
for _, m := range readOnly {
|
||||
assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, m, operations.Read),
|
||||
"usage_viewer must read %s for the usage view and its filters", m)
|
||||
"usage_viewer must read %s for the usage and log views and their filters", m)
|
||||
for _, op := range []operations.Operation{operations.Create, operations.Update, operations.Delete} {
|
||||
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, m, op),
|
||||
"usage_viewer must not have %s on %s", op, m)
|
||||
@@ -95,7 +96,6 @@ func TestUsageViewerRole(t *testing.T) {
|
||||
modules.AgentNetworkPolicies,
|
||||
modules.AgentNetworkGuardrails,
|
||||
modules.AgentNetworkBudgets,
|
||||
modules.AgentNetworkLogs,
|
||||
modules.AgentNetworkSettings,
|
||||
modules.Networks,
|
||||
modules.SetupKeys,
|
||||
|
||||
@@ -7,16 +7,15 @@ import (
|
||||
)
|
||||
|
||||
// UsageViewer is the regular User baseline plus read access to the
|
||||
// aggregated Agent Network usage and cost overview, and read-only access
|
||||
// to the resources the usage filters and display columns resolve against:
|
||||
// users and groups (identity filters and name resolution), peers (agent
|
||||
// principals in the caller column), and the provider list (provider and
|
||||
// model filter options — the manager redacts connection config such as
|
||||
// upstream URLs and operator-supplied header values for callers holding
|
||||
// read without update). It sees no policies and no account-wide
|
||||
// request-level access logs (which can contain captured prompts); its own
|
||||
// requests remain readable through the self-scoped endpoints, like any
|
||||
// caller's.
|
||||
// aggregated Agent Network usage and cost overview and to the account-wide
|
||||
// request-level access logs (which can contain captured prompts), and
|
||||
// read-only access to the resources the usage and log filters and display
|
||||
// columns resolve against: users and groups (identity filters and name
|
||||
// resolution), peers (agent principals in the caller column), and the
|
||||
// provider list (provider and model filter options — the manager redacts
|
||||
// connection config such as upstream URLs and operator-supplied header
|
||||
// values for callers holding read without update). It sees no policies,
|
||||
// guardrails, budgets, or Agent Network settings.
|
||||
var UsageViewer = RolePermissions{
|
||||
Role: types.UserRoleUsageViewer,
|
||||
AutoAllowNew: map[operations.Operation]bool{
|
||||
@@ -32,6 +31,12 @@ var UsageViewer = RolePermissions{
|
||||
operations.Update: false,
|
||||
operations.Delete: false,
|
||||
},
|
||||
modules.AgentNetworkLogs: {
|
||||
operations.Read: true,
|
||||
operations.Create: false,
|
||||
operations.Update: false,
|
||||
operations.Delete: false,
|
||||
},
|
||||
modules.AgentNetworkProviders: {
|
||||
operations.Read: true,
|
||||
operations.Create: false,
|
||||
|
||||
+278
-274
@@ -1135,287 +1135,291 @@ func sortFunc() func(a *types.FirewallRule, b *types.FirewallRule) int {
|
||||
}
|
||||
|
||||
func TestPolicyAccountPeersUpdate(t *testing.T) {
|
||||
manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t)
|
||||
runPeerUpdateTest(t, func(t *testing.T) {
|
||||
manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t)
|
||||
|
||||
g := []*types.Group{
|
||||
{
|
||||
ID: "groupA",
|
||||
Name: "GroupA",
|
||||
Peers: []string{peer1.ID, peer3.ID},
|
||||
},
|
||||
{
|
||||
ID: "groupB",
|
||||
Name: "GroupB",
|
||||
Peers: []string{},
|
||||
},
|
||||
{
|
||||
ID: "groupC",
|
||||
Name: "GroupC",
|
||||
Peers: []string{},
|
||||
},
|
||||
{
|
||||
ID: "groupD",
|
||||
Name: "GroupD",
|
||||
Peers: []string{peer1.ID, peer2.ID},
|
||||
},
|
||||
}
|
||||
for _, group := range g {
|
||||
err := manager.CreateGroup(context.Background(), account.Id, userID, group)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
updMsg := updateManager.CreateChannel(context.Background(), peer1.ID)
|
||||
t.Cleanup(func() {
|
||||
updateManager.CloseChannel(context.Background(), peer1.ID)
|
||||
})
|
||||
|
||||
var policyWithGroupRulesNoPeers *types.Policy
|
||||
var policyWithDestinationPeersOnly *types.Policy
|
||||
var policyWithSourceAndDestinationPeers *types.Policy
|
||||
var err error
|
||||
|
||||
// Saving policy with rule groups with no peers should not update account's peers and not send peer update
|
||||
t.Run("saving policy with rule groups with no peers", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldNotReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
policyWithGroupRulesNoPeers, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{
|
||||
AccountID: account.Id,
|
||||
Enabled: true,
|
||||
Rules: []*types.PolicyRule{
|
||||
{
|
||||
Enabled: true,
|
||||
Sources: []string{"groupB"},
|
||||
Destinations: []string{"groupC"},
|
||||
Bidirectional: true,
|
||||
Action: types.PolicyTrafficActionAccept,
|
||||
},
|
||||
g := []*types.Group{
|
||||
{
|
||||
ID: "groupA",
|
||||
Name: "GroupA",
|
||||
Peers: []string{peer1.ID, peer3.ID},
|
||||
},
|
||||
}, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("timeout waiting for peerShouldNotReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
// Saving policy with source group containing peers, but destination group without peers should
|
||||
// update account's peers and send peer update
|
||||
t.Run("saving policy where source has peers but destination does not", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
_, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{
|
||||
AccountID: account.Id,
|
||||
Enabled: true,
|
||||
Rules: []*types.PolicyRule{
|
||||
{
|
||||
Enabled: true,
|
||||
Sources: []string{"groupA"},
|
||||
Destinations: []string{"groupB"},
|
||||
Protocol: types.PolicyRuleProtocolTCP,
|
||||
Bidirectional: true,
|
||||
Action: types.PolicyTrafficActionAccept,
|
||||
},
|
||||
{
|
||||
ID: "groupB",
|
||||
Name: "GroupB",
|
||||
Peers: []string{},
|
||||
},
|
||||
}, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
// Saving policy with destination group containing peers, but source group without peers should
|
||||
// update account's peers and send peer update
|
||||
t.Run("saving policy where destination has peers but source does not", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
policyWithDestinationPeersOnly, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{
|
||||
AccountID: account.Id,
|
||||
Enabled: true,
|
||||
Rules: []*types.PolicyRule{
|
||||
{
|
||||
Enabled: true,
|
||||
Sources: []string{"groupC"},
|
||||
Destinations: []string{"groupD"},
|
||||
Bidirectional: true,
|
||||
Protocol: types.PolicyRuleProtocolTCP,
|
||||
Action: types.PolicyTrafficActionAccept,
|
||||
},
|
||||
{
|
||||
ID: "groupC",
|
||||
Name: "GroupC",
|
||||
Peers: []string{},
|
||||
},
|
||||
}, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
// Saving policy with destination and source groups containing peers should update account's peers
|
||||
// and send peer update
|
||||
t.Run("saving policy with source and destination groups with peers", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
policyWithSourceAndDestinationPeers, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{
|
||||
AccountID: account.Id,
|
||||
Enabled: true,
|
||||
Rules: []*types.PolicyRule{
|
||||
{
|
||||
Enabled: true,
|
||||
Sources: []string{"groupA"},
|
||||
Destinations: []string{"groupD"},
|
||||
Bidirectional: true,
|
||||
Action: types.PolicyTrafficActionAccept,
|
||||
},
|
||||
{
|
||||
ID: "groupD",
|
||||
Name: "GroupD",
|
||||
Peers: []string{peer1.ID, peer2.ID},
|
||||
},
|
||||
}, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
// Disabling policy with destination and source groups containing peers should update account's peers
|
||||
// and send peer update
|
||||
t.Run("disabling policy with source and destination groups with peers", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
policyWithSourceAndDestinationPeers.Enabled = false
|
||||
policyWithSourceAndDestinationPeers, err = manager.SavePolicy(context.Background(), account.Id, userID, policyWithSourceAndDestinationPeers, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
// Updating disabled policy with destination and source groups containing peers should still update account's peers
|
||||
// because affected peer resolution does not filter by policy enabled state
|
||||
t.Run("updating disabled policy with source and destination groups with peers", func(t *testing.T) {
|
||||
drainPeerUpdates(updMsg)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
policyWithSourceAndDestinationPeers.Description = "updated description"
|
||||
policyWithSourceAndDestinationPeers.Rules[0].Destinations = []string{"groupA"}
|
||||
policyWithSourceAndDestinationPeers, err = manager.SavePolicy(context.Background(), account.Id, userID, policyWithSourceAndDestinationPeers, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
// Enabling policy with destination and source groups containing peers should update account's peers
|
||||
// and send peer update
|
||||
t.Run("enabling policy with source and destination groups with peers", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
policyWithSourceAndDestinationPeers.Enabled = true
|
||||
policyWithSourceAndDestinationPeers, err = manager.SavePolicy(context.Background(), account.Id, userID, policyWithSourceAndDestinationPeers, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
// Deleting policy should trigger account peers update and send peer update
|
||||
t.Run("deleting policy with source and destination groups with peers", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
err := manager.DeletePolicy(context.Background(), account.Id, policyWithSourceAndDestinationPeers.ID, userID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
for _, group := range g {
|
||||
err := manager.CreateGroup(context.Background(), account.Id, userID, group)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
updMsg := updateManager.CreateChannel(context.Background(), peer1.ID)
|
||||
t.Cleanup(func() {
|
||||
updateManager.CloseChannel(context.Background(), peer1.ID)
|
||||
})
|
||||
|
||||
var policyWithGroupRulesNoPeers *types.Policy
|
||||
var policyWithDestinationPeersOnly *types.Policy
|
||||
var policyWithSourceAndDestinationPeers *types.Policy
|
||||
var err error
|
||||
|
||||
// Saving policy with rule groups with no peers should not update account's peers and not send peer update
|
||||
step(t, "saving policy with rule groups with no peers", func(t *testing.T) {
|
||||
settleAffectedUpdates(updMsg)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldNotReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
policyWithGroupRulesNoPeers, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{
|
||||
AccountID: account.Id,
|
||||
Enabled: true,
|
||||
Rules: []*types.PolicyRule{
|
||||
{
|
||||
Enabled: true,
|
||||
Sources: []string{"groupB"},
|
||||
Destinations: []string{"groupC"},
|
||||
Bidirectional: true,
|
||||
Action: types.PolicyTrafficActionAccept,
|
||||
},
|
||||
},
|
||||
}, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("timeout waiting for peerShouldNotReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
// Saving policy with source group containing peers, but destination group without peers should
|
||||
// update account's peers and send peer update
|
||||
step(t, "saving policy where source has peers but destination does not", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
_, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{
|
||||
AccountID: account.Id,
|
||||
Enabled: true,
|
||||
Rules: []*types.PolicyRule{
|
||||
{
|
||||
Enabled: true,
|
||||
Sources: []string{"groupA"},
|
||||
Destinations: []string{"groupB"},
|
||||
Protocol: types.PolicyRuleProtocolTCP,
|
||||
Bidirectional: true,
|
||||
Action: types.PolicyTrafficActionAccept,
|
||||
},
|
||||
},
|
||||
}, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
// Saving policy with destination group containing peers, but source group without peers should
|
||||
// update account's peers and send peer update
|
||||
step(t, "saving policy where destination has peers but source does not", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
policyWithDestinationPeersOnly, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{
|
||||
AccountID: account.Id,
|
||||
Enabled: true,
|
||||
Rules: []*types.PolicyRule{
|
||||
{
|
||||
Enabled: true,
|
||||
Sources: []string{"groupC"},
|
||||
Destinations: []string{"groupD"},
|
||||
Bidirectional: true,
|
||||
Protocol: types.PolicyRuleProtocolTCP,
|
||||
Action: types.PolicyTrafficActionAccept,
|
||||
},
|
||||
},
|
||||
}, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
// Saving policy with destination and source groups containing peers should update account's peers
|
||||
// and send peer update
|
||||
step(t, "saving policy with source and destination groups with peers", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
policyWithSourceAndDestinationPeers, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{
|
||||
AccountID: account.Id,
|
||||
Enabled: true,
|
||||
Rules: []*types.PolicyRule{
|
||||
{
|
||||
Enabled: true,
|
||||
Sources: []string{"groupA"},
|
||||
Destinations: []string{"groupD"},
|
||||
Bidirectional: true,
|
||||
Action: types.PolicyTrafficActionAccept,
|
||||
},
|
||||
},
|
||||
}, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
// Disabling policy with destination and source groups containing peers should update account's peers
|
||||
// and send peer update
|
||||
step(t, "disabling policy with source and destination groups with peers", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
policyWithSourceAndDestinationPeers.Enabled = false
|
||||
policyWithSourceAndDestinationPeers, err = manager.SavePolicy(context.Background(), account.Id, userID, policyWithSourceAndDestinationPeers, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
// Updating disabled policy with destination and source groups containing peers should still update account's peers
|
||||
// because affected peer resolution does not filter by policy enabled state
|
||||
step(t, "updating disabled policy with source and destination groups with peers", func(t *testing.T) {
|
||||
drainPeerUpdates(updMsg)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
policyWithSourceAndDestinationPeers.Description = "updated description"
|
||||
policyWithSourceAndDestinationPeers.Rules[0].Destinations = []string{"groupA"}
|
||||
policyWithSourceAndDestinationPeers, err = manager.SavePolicy(context.Background(), account.Id, userID, policyWithSourceAndDestinationPeers, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
// Enabling policy with destination and source groups containing peers should update account's peers
|
||||
// and send peer update
|
||||
step(t, "enabling policy with source and destination groups with peers", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
policyWithSourceAndDestinationPeers.Enabled = true
|
||||
policyWithSourceAndDestinationPeers, err = manager.SavePolicy(context.Background(), account.Id, userID, policyWithSourceAndDestinationPeers, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
// Deleting policy should trigger account peers update and send peer update
|
||||
step(t, "deleting policy with source and destination groups with peers", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
err := manager.DeletePolicy(context.Background(), account.Id, policyWithSourceAndDestinationPeers.ID, userID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
// Deleting policy with destination group containing peers, but source group without peers should
|
||||
// update account's peers and send peer update
|
||||
step(t, "deleting policy where destination has peers but source does not", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
err := manager.DeletePolicy(context.Background(), account.Id, policyWithDestinationPeersOnly.ID, userID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
// Deleting policy with no peers in groups should not update account's peers and not send peer update
|
||||
step(t, "deleting policy with no peers in groups", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldNotReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
err := manager.DeletePolicy(context.Background(), account.Id, policyWithGroupRulesNoPeers.ID, userID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("timeout waiting for peerShouldNotReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
// Deleting policy with destination group containing peers, but source group without peers should
|
||||
// update account's peers and send peer update
|
||||
t.Run("deleting policy where destination has peers but source does not", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
err := manager.DeletePolicy(context.Background(), account.Id, policyWithDestinationPeersOnly.ID, userID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
// Deleting policy with no peers in groups should not update account's peers and not send peer update
|
||||
t.Run("deleting policy with no peers in groups", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldNotReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
err := manager.DeletePolicy(context.Background(), account.Id, policyWithGroupRulesNoPeers.ID, userID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("timeout waiting for peerShouldNotReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package posture
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||
"github.com/netbirdio/netbird/shared/management/certposture"
|
||||
)
|
||||
|
||||
// CertificateCheck passes when the peer holds a certificate, proven at meta ingestion,
|
||||
// that chains to one of the configured PEM encoded CA certificates.
|
||||
type CertificateCheck struct {
|
||||
CACertificates []string
|
||||
}
|
||||
|
||||
var _ Check = (*CertificateCheck)(nil)
|
||||
|
||||
func (c *CertificateCheck) Check(_ context.Context, peer nbpeer.Peer) (bool, error) {
|
||||
return certposture.AnyChainMatchesCAs(peer.Meta.Certificates, c.CACertificates, time.Now()), nil
|
||||
}
|
||||
|
||||
func (c *CertificateCheck) Name() string {
|
||||
return CertificateCheckName
|
||||
}
|
||||
|
||||
func (c *CertificateCheck) Validate() error {
|
||||
if len(c.CACertificates) == 0 {
|
||||
return fmt.Errorf("%s ca certificates shouldn't be empty", c.Name())
|
||||
}
|
||||
if _, err := certposture.ParseCAs(c.CACertificates); err != nil {
|
||||
return fmt.Errorf("%s: %w", c.Name(), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package posture
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/peer"
|
||||
"github.com/netbirdio/netbird/shared/management/certposture"
|
||||
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
|
||||
)
|
||||
|
||||
func TestCertificateCheck_Check(t *testing.T) {
|
||||
ca := certtest.NewCA(t, "corp-root")
|
||||
otherCA := certtest.NewCA(t, "other-root")
|
||||
chain := certposture.EncodeChainPEM([]*x509.Certificate{ca.Issue(t, certtest.ECDSAKey(t), "device")})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
certificates []string
|
||||
cas []string
|
||||
want bool
|
||||
}{
|
||||
{"chains to configured CA", []string{chain}, []string{ca.PEM}, true},
|
||||
{"one of several CAs matches", []string{chain}, []string{otherCA.PEM, ca.PEM}, true},
|
||||
{"unrelated CA", []string{chain}, []string{otherCA.PEM}, false},
|
||||
{"no certificates proven", nil, []string{ca.PEM}, false},
|
||||
{"garbage entry does not hide a valid one", []string{"garbage", chain}, []string{ca.PEM}, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
check := CertificateCheck{CACertificates: tt.cas}
|
||||
got, err := check.Check(context.Background(), peer.Peer{Meta: peer.PeerSystemMeta{Certificates: tt.certificates}})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertificateCheck_Validate(t *testing.T) {
|
||||
ca := certtest.NewCA(t, "corp-root")
|
||||
|
||||
assert.Error(t, (&CertificateCheck{}).Validate())
|
||||
assert.Error(t, (&CertificateCheck{CACertificates: []string{"not a pem"}}).Validate())
|
||||
assert.NoError(t, (&CertificateCheck{CACertificates: []string{ca.PEM}}).Validate())
|
||||
}
|
||||
|
||||
func TestChecks_CertificateCheckRegistered(t *testing.T) {
|
||||
ca := certtest.NewCA(t, "corp-root")
|
||||
checks := &Checks{Name: "cert", Checks: ChecksDefinition{CertificateCheck: &CertificateCheck{CACertificates: []string{ca.PEM}}}}
|
||||
|
||||
require.NoError(t, checks.Validate())
|
||||
require.Len(t, checks.GetChecks(), 1)
|
||||
assert.Equal(t, CertificateCheckName, checks.GetChecks()[0].Name())
|
||||
|
||||
copied := checks.Copy()
|
||||
checks.Checks.CertificateCheck.CACertificates[0] = "mutated"
|
||||
assert.Equal(t, ca.PEM, copied.Checks.CertificateCheck.CACertificates[0])
|
||||
|
||||
api := checks.ToAPIResponse()
|
||||
require.NotNil(t, api.Checks.CertificateCheck)
|
||||
roundTrip, err := NewChecksFromAPIPostureCheck(*api)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, checks.Checks.CertificateCheck.CACertificates, roundTrip.Checks.CertificateCheck.CACertificates)
|
||||
}
|
||||
@@ -19,6 +19,7 @@ const (
|
||||
GeoLocationCheckName = "GeoLocationCheck"
|
||||
PeerNetworkRangeCheckName = "PeerNetworkRangeCheck"
|
||||
ProcessCheckName = "ProcessCheck"
|
||||
CertificateCheckName = "CertificateCheck"
|
||||
|
||||
CheckActionAllow string = "allow"
|
||||
CheckActionDeny string = "deny"
|
||||
@@ -61,6 +62,7 @@ type ChecksDefinition struct {
|
||||
GeoLocationCheck *GeoLocationCheck `json:",omitempty"`
|
||||
PeerNetworkRangeCheck *PeerNetworkRangeCheck `json:",omitempty"`
|
||||
ProcessCheck *ProcessCheck `json:",omitempty"`
|
||||
CertificateCheck *CertificateCheck `json:",omitempty"`
|
||||
}
|
||||
|
||||
// Copy returns a copy of a checks definition.
|
||||
@@ -113,6 +115,12 @@ func (cd ChecksDefinition) Copy() ChecksDefinition {
|
||||
}
|
||||
copy(cdCopy.ProcessCheck.Processes, processCheck.Processes)
|
||||
}
|
||||
if cd.CertificateCheck != nil {
|
||||
cdCopy.CertificateCheck = &CertificateCheck{
|
||||
CACertificates: make([]string, len(cd.CertificateCheck.CACertificates)),
|
||||
}
|
||||
copy(cdCopy.CertificateCheck.CACertificates, cd.CertificateCheck.CACertificates)
|
||||
}
|
||||
return cdCopy
|
||||
}
|
||||
|
||||
@@ -157,6 +165,9 @@ func (pc *Checks) GetChecks() []Check {
|
||||
if pc.Checks.ProcessCheck != nil {
|
||||
checks = append(checks, pc.Checks.ProcessCheck)
|
||||
}
|
||||
if pc.Checks.CertificateCheck != nil {
|
||||
checks = append(checks, pc.Checks.CertificateCheck)
|
||||
}
|
||||
return checks
|
||||
}
|
||||
|
||||
@@ -212,6 +223,10 @@ func buildPostureCheck(postureChecksID string, name string, description string,
|
||||
postureChecks.Checks.ProcessCheck = toProcessCheck(processCheck)
|
||||
}
|
||||
|
||||
if certificateCheck := checks.CertificateCheck; certificateCheck != nil {
|
||||
postureChecks.Checks.CertificateCheck = &CertificateCheck{CACertificates: certificateCheck.CaCertificates}
|
||||
}
|
||||
|
||||
return &postureChecks, nil
|
||||
}
|
||||
|
||||
@@ -246,6 +261,10 @@ func (pc *Checks) ToAPIResponse() *api.PostureCheck {
|
||||
checks.ProcessCheck = toProcessCheckResponse(pc.Checks.ProcessCheck)
|
||||
}
|
||||
|
||||
if pc.Checks.CertificateCheck != nil {
|
||||
checks.CertificateCheck = &api.CertificateCheck{CaCertificates: pc.Checks.CertificateCheck.CACertificates}
|
||||
}
|
||||
|
||||
return &api.PostureCheck{
|
||||
Id: pc.ID,
|
||||
Name: pc.Name,
|
||||
|
||||
@@ -123,324 +123,334 @@ func initTestPostureChecksAccount(am *DefaultAccountManager) (*types.Account, er
|
||||
}
|
||||
|
||||
func TestPostureCheckAccountPeersUpdate(t *testing.T) {
|
||||
manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t)
|
||||
runPeerUpdateTest(t, func(t *testing.T) {
|
||||
manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t)
|
||||
|
||||
g := []*types.Group{
|
||||
{
|
||||
ID: "groupA",
|
||||
Name: "GroupA",
|
||||
Peers: []string{peer1.ID, peer2.ID, peer3.ID},
|
||||
},
|
||||
{
|
||||
ID: "groupB",
|
||||
Name: "GroupB",
|
||||
Peers: []string{},
|
||||
},
|
||||
{
|
||||
ID: "groupC",
|
||||
Name: "GroupC",
|
||||
Peers: []string{},
|
||||
},
|
||||
}
|
||||
for _, group := range g {
|
||||
err := manager.CreateGroup(context.Background(), account.Id, userID, group)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
updMsg := updateManager.CreateChannel(context.Background(), peer1.ID)
|
||||
t.Cleanup(func() {
|
||||
updateManager.CloseChannel(context.Background(), peer1.ID)
|
||||
})
|
||||
|
||||
postureCheckA := &posture.Checks{
|
||||
Name: "postureCheckA",
|
||||
AccountID: account.Id,
|
||||
Checks: posture.ChecksDefinition{
|
||||
ProcessCheck: &posture.ProcessCheck{
|
||||
Processes: []posture.Process{
|
||||
{LinuxPath: "/usr/bin/netbird", MacPath: "/usr/local/bin/netbird"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
postureCheckA, err := manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckA, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
postureCheckB := &posture.Checks{
|
||||
Name: "postureCheckB",
|
||||
AccountID: account.Id,
|
||||
Checks: posture.ChecksDefinition{
|
||||
NBVersionCheck: &posture.NBVersionCheck{
|
||||
MinVersion: "0.28.0",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Saving unused posture check should not update account peers and not send peer update
|
||||
t.Run("saving unused posture check", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldNotReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
postureCheckB, err = manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckB, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("timeout waiting for peerShouldNotReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
// Updating unused posture check should not update account peers and not send peer update
|
||||
t.Run("updating unused posture check", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldNotReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
postureCheckB.Checks = posture.ChecksDefinition{
|
||||
NBVersionCheck: &posture.NBVersionCheck{
|
||||
MinVersion: "0.29.0",
|
||||
},
|
||||
}
|
||||
_, err = manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckB, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("timeout waiting for peerShouldNotReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
policy := &types.Policy{
|
||||
Enabled: true,
|
||||
Rules: []*types.PolicyRule{
|
||||
g := []*types.Group{
|
||||
{
|
||||
Enabled: true,
|
||||
Sources: []string{"groupA"},
|
||||
Destinations: []string{"groupA"},
|
||||
Bidirectional: true,
|
||||
Action: types.PolicyTrafficActionAccept,
|
||||
ID: "groupA",
|
||||
Name: "GroupA",
|
||||
Peers: []string{peer1.ID, peer2.ID, peer3.ID},
|
||||
},
|
||||
},
|
||||
SourcePostureChecks: []string{postureCheckB.ID},
|
||||
}
|
||||
|
||||
// Linking posture check to policy should trigger update account peers and send peer update
|
||||
t.Run("linking posture check to policy with peers", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
policy, err = manager.SavePolicy(context.Background(), account.Id, userID, policy, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
// Updating linked posture checks should update account peers and send peer update
|
||||
t.Run("updating linked to posture check with peers", func(t *testing.T) {
|
||||
postureCheckB.Checks = posture.ChecksDefinition{
|
||||
NBVersionCheck: &posture.NBVersionCheck{
|
||||
MinVersion: "0.29.0",
|
||||
{
|
||||
ID: "groupB",
|
||||
Name: "GroupB",
|
||||
Peers: []string{},
|
||||
},
|
||||
ProcessCheck: &posture.ProcessCheck{
|
||||
Processes: []posture.Process{
|
||||
{LinuxPath: "/usr/bin/netbird", MacPath: "/usr/local/bin/netbird"},
|
||||
},
|
||||
{
|
||||
ID: "groupC",
|
||||
Name: "GroupC",
|
||||
Peers: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
_, err = manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckB, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
for _, group := range g {
|
||||
err := manager.CreateGroup(context.Background(), account.Id, userID, group)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
|
||||
// Removing posture check from policy should trigger account peers update and send peer update
|
||||
t.Run("removing posture check from policy", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
policy.SourcePostureChecks = []string{}
|
||||
_, err := manager.SavePolicy(context.Background(), account.Id, userID, policy, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
// Deleting unused posture check should not trigger account peers update and not send peer update
|
||||
t.Run("deleting unused posture check", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldNotReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
err := manager.DeletePostureChecks(context.Background(), account.Id, postureCheckA.ID, userID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("timeout waiting for peerShouldNotReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
_, err = manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckB, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Updating linked posture check to policy with no peers should not trigger account peers update and not send peer update
|
||||
t.Run("updating linked posture check to policy with no peers", func(t *testing.T) {
|
||||
_, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{
|
||||
Enabled: true,
|
||||
Rules: []*types.PolicyRule{
|
||||
{
|
||||
Enabled: true,
|
||||
Sources: []string{"groupB"},
|
||||
Destinations: []string{"groupC"},
|
||||
Bidirectional: true,
|
||||
Action: types.PolicyTrafficActionAccept,
|
||||
},
|
||||
},
|
||||
SourcePostureChecks: []string{postureCheckB.ID},
|
||||
}, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldNotReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
postureCheckB.Checks = posture.ChecksDefinition{
|
||||
NBVersionCheck: &posture.NBVersionCheck{
|
||||
MinVersion: "0.29.0",
|
||||
},
|
||||
}
|
||||
_, err = manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckB, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("timeout waiting for peerShouldNotReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
// Updating linked posture check to policy where destination has peers but source does not
|
||||
// should trigger account peers update and send peer update
|
||||
t.Run("updating linked posture check to policy where destination has peers but source does not", func(t *testing.T) {
|
||||
updMsg1 := updateManager.CreateChannel(context.Background(), peer2.ID)
|
||||
updMsg := updateManager.CreateChannel(context.Background(), peer1.ID)
|
||||
t.Cleanup(func() {
|
||||
updateManager.CloseChannel(context.Background(), peer2.ID)
|
||||
updateManager.CloseChannel(context.Background(), peer1.ID)
|
||||
})
|
||||
|
||||
_, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{
|
||||
postureCheckA := &posture.Checks{
|
||||
Name: "postureCheckA",
|
||||
AccountID: account.Id,
|
||||
Checks: posture.ChecksDefinition{
|
||||
ProcessCheck: &posture.ProcessCheck{
|
||||
Processes: []posture.Process{
|
||||
{LinuxPath: "/usr/bin/netbird", MacPath: "/usr/local/bin/netbird"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
postureCheckA, err := manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckA, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
postureCheckB := &posture.Checks{
|
||||
Name: "postureCheckB",
|
||||
AccountID: account.Id,
|
||||
Checks: posture.ChecksDefinition{
|
||||
NBVersionCheck: &posture.NBVersionCheck{
|
||||
MinVersion: "0.28.0",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Saving unused posture check should not update account peers and not send peer update
|
||||
step(t, "saving unused posture check", func(t *testing.T) {
|
||||
settleAffectedUpdates(updMsg)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldNotReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
postureCheckB, err = manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckB, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("timeout waiting for peerShouldNotReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
// Updating unused posture check should not update account peers and not send peer update
|
||||
step(t, "updating unused posture check", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldNotReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
postureCheckB.Checks = posture.ChecksDefinition{
|
||||
NBVersionCheck: &posture.NBVersionCheck{
|
||||
MinVersion: "0.29.0",
|
||||
},
|
||||
}
|
||||
_, err = manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckB, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("timeout waiting for peerShouldNotReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
policy := &types.Policy{
|
||||
Enabled: true,
|
||||
Rules: []*types.PolicyRule{
|
||||
{
|
||||
Enabled: true,
|
||||
Sources: []string{"groupB"},
|
||||
Sources: []string{"groupA"},
|
||||
Destinations: []string{"groupA"},
|
||||
Bidirectional: true,
|
||||
Action: types.PolicyTrafficActionAccept,
|
||||
},
|
||||
},
|
||||
SourcePostureChecks: []string{postureCheckB.ID},
|
||||
}, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg1)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
postureCheckB.Checks = posture.ChecksDefinition{
|
||||
NBVersionCheck: &posture.NBVersionCheck{
|
||||
MinVersion: "0.29.0",
|
||||
},
|
||||
}
|
||||
_, err = manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckB, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
// Linking posture check to policy should trigger update account peers and send peer update
|
||||
step(t, "linking posture check to policy with peers", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
// Updating linked client posture check to policy where source has peers but destination does not,
|
||||
// should trigger account peers update and send peer update
|
||||
t.Run("updating linked posture check to policy where source has peers but destination does not", func(t *testing.T) {
|
||||
_, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{
|
||||
Enabled: true,
|
||||
Rules: []*types.PolicyRule{
|
||||
{
|
||||
Enabled: true,
|
||||
Sources: []string{"groupA"},
|
||||
Destinations: []string{"groupB"},
|
||||
Bidirectional: true,
|
||||
Action: types.PolicyTrafficActionAccept,
|
||||
policy, err = manager.SavePolicy(context.Background(), account.Id, userID, policy, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
// Updating linked posture checks should update account peers and send peer update
|
||||
step(t, "updating linked to posture check with peers", func(t *testing.T) {
|
||||
postureCheckB.Checks = posture.ChecksDefinition{
|
||||
NBVersionCheck: &posture.NBVersionCheck{
|
||||
MinVersion: "0.29.0",
|
||||
},
|
||||
},
|
||||
SourcePostureChecks: []string{postureCheckB.ID},
|
||||
}, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
postureCheckB.Checks = posture.ChecksDefinition{
|
||||
ProcessCheck: &posture.ProcessCheck{
|
||||
Processes: []posture.Process{
|
||||
{
|
||||
LinuxPath: "/usr/bin/netbird",
|
||||
ProcessCheck: &posture.ProcessCheck{
|
||||
Processes: []posture.Process{
|
||||
{LinuxPath: "/usr/bin/netbird", MacPath: "/usr/local/bin/netbird"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
_, err = manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckB, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
// Removing posture check from policy should trigger account peers update and send peer update
|
||||
step(t, "removing posture check from policy", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
policy.SourcePostureChecks = []string{}
|
||||
_, err := manager.SavePolicy(context.Background(), account.Id, userID, policy, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
// Deleting unused posture check should not trigger account peers update and not send peer update
|
||||
step(t, "deleting unused posture check", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldNotReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
err := manager.DeletePostureChecks(context.Background(), account.Id, postureCheckA.ID, userID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("timeout waiting for peerShouldNotReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
_, err = manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckB, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
// Updating linked posture check to policy with no peers should not trigger account peers update and not send peer update
|
||||
step(t, "updating linked posture check to policy with no peers", func(t *testing.T) {
|
||||
_, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{
|
||||
Enabled: true,
|
||||
Rules: []*types.PolicyRule{
|
||||
{
|
||||
Enabled: true,
|
||||
Sources: []string{"groupB"},
|
||||
Destinations: []string{"groupC"},
|
||||
Bidirectional: true,
|
||||
Action: types.PolicyTrafficActionAccept,
|
||||
},
|
||||
},
|
||||
SourcePostureChecks: []string{postureCheckB.ID},
|
||||
}, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
settleAffectedUpdates(updMsg)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldNotReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
postureCheckB.Checks = posture.ChecksDefinition{
|
||||
NBVersionCheck: &posture.NBVersionCheck{
|
||||
MinVersion: "0.29.0",
|
||||
},
|
||||
}
|
||||
_, err = manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckB, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("timeout waiting for peerShouldNotReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
// Updating linked posture check to policy where destination has peers but source does not
|
||||
// should trigger account peers update and send peer update
|
||||
step(t, "updating linked posture check to policy where destination has peers but source does not", func(t *testing.T) {
|
||||
updMsg1 := updateManager.CreateChannel(context.Background(), peer2.ID)
|
||||
t.Cleanup(func() {
|
||||
updateManager.CloseChannel(context.Background(), peer2.ID)
|
||||
})
|
||||
|
||||
_, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{
|
||||
Enabled: true,
|
||||
Rules: []*types.PolicyRule{
|
||||
{
|
||||
Enabled: true,
|
||||
Sources: []string{"groupB"},
|
||||
Destinations: []string{"groupA"},
|
||||
Bidirectional: true,
|
||||
Action: types.PolicyTrafficActionAccept,
|
||||
},
|
||||
},
|
||||
SourcePostureChecks: []string{postureCheckB.ID},
|
||||
}, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
settleAffectedUpdates(updMsg, updMsg1)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg1)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
postureCheckB.Checks = posture.ChecksDefinition{
|
||||
NBVersionCheck: &posture.NBVersionCheck{
|
||||
MinVersion: "0.29.0",
|
||||
},
|
||||
}
|
||||
_, err = manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckB, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
// Updating linked client posture check to policy where source has peers but destination does not,
|
||||
// should trigger account peers update and send peer update
|
||||
step(t, "updating linked posture check to policy where source has peers but destination does not", func(t *testing.T) {
|
||||
_, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{
|
||||
Enabled: true,
|
||||
Rules: []*types.PolicyRule{
|
||||
{
|
||||
Enabled: true,
|
||||
Sources: []string{"groupA"},
|
||||
Destinations: []string{"groupB"},
|
||||
Bidirectional: true,
|
||||
Action: types.PolicyTrafficActionAccept,
|
||||
},
|
||||
},
|
||||
SourcePostureChecks: []string{postureCheckB.ID},
|
||||
}, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
settleAffectedUpdates(updMsg)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
postureCheckB.Checks = posture.ChecksDefinition{
|
||||
ProcessCheck: &posture.ProcessCheck{
|
||||
Processes: []posture.Process{
|
||||
{
|
||||
LinuxPath: "/usr/bin/netbird",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
_, err = manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckB, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+253
-246
@@ -6,10 +6,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/rs/xid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
|
||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel"
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
"github.com/netbirdio/netbird/management/internals/server/config"
|
||||
"github.com/netbirdio/netbird/management/server/activity"
|
||||
"github.com/netbirdio/netbird/management/server/cache"
|
||||
"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
|
||||
"github.com/netbirdio/netbird/management/server/job"
|
||||
resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
|
||||
routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
|
||||
@@ -1262,7 +1261,10 @@ func createRouterManager(t *testing.T) (*DefaultAccountManager, *update_channel.
|
||||
}
|
||||
eventStore := &activity.InMemoryEventStore{}
|
||||
|
||||
metrics, err := telemetry.NewDefaultAppMetrics(context.Background())
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
metrics, err := telemetry.NewDefaultAppMetrics(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
@@ -1290,21 +1292,22 @@ func createRouterManager(t *testing.T) (*DefaultAccountManager, *update_channel.
|
||||
permissionsManager := permissions.NewManager(store)
|
||||
peersManager := peers.NewManager(store, permissionsManager)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
cacheStore, err := cache.NewStore(ctx, 100*time.Millisecond, 300*time.Millisecond, 100)
|
||||
// A go-cache janitor only stops through a GC finalizer, which would leave synctest bubbles with a goroutine that never exits.
|
||||
cacheStore, err := cache.NewStore(ctx, 100*time.Millisecond, 0, 100)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
updateManager := update_channel.NewPeersUpdateManager(metrics)
|
||||
requestBuffer := NewAccountRequestBuffer(ctx, store)
|
||||
networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nil)
|
||||
networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nil)
|
||||
|
||||
am, err := BuildManager(context.Background(), nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
|
||||
am, err := BuildManager(ctx, nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, settingsMockManager, permissionsManager, false, cacheStore)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
cacheManager := am.cacheManager
|
||||
t.Cleanup(func() { _ = cacheManager.Close() })
|
||||
return am, updateManager, nil
|
||||
}
|
||||
|
||||
@@ -1893,265 +1896,269 @@ func TestAccount_getPeersRoutesFirewall(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRouteAccountPeersUpdate(t *testing.T) {
|
||||
manager, updateManager, err := createRouterManager(t)
|
||||
require.NoError(t, err, "failed to create account manager")
|
||||
runPeerUpdateTest(t, func(t *testing.T) {
|
||||
manager, updateManager, err := createRouterManager(t)
|
||||
require.NoError(t, err, "failed to create account manager")
|
||||
|
||||
account, err := initTestRouteAccount(t, manager)
|
||||
require.NoError(t, err, "failed to init testing account")
|
||||
account, err := initTestRouteAccount(t, manager)
|
||||
require.NoError(t, err, "failed to init testing account")
|
||||
|
||||
g := []*types.Group{
|
||||
{
|
||||
ID: "groupA",
|
||||
Name: "GroupA",
|
||||
Peers: []string{},
|
||||
},
|
||||
{
|
||||
ID: "groupB",
|
||||
Name: "GroupB",
|
||||
Peers: []string{},
|
||||
},
|
||||
{
|
||||
ID: "groupC",
|
||||
Name: "GroupC",
|
||||
Peers: []string{},
|
||||
},
|
||||
}
|
||||
for _, group := range g {
|
||||
err = manager.CreateGroup(context.Background(), account.Id, userID, group)
|
||||
require.NoError(t, err, "failed to create group %s", group.Name)
|
||||
}
|
||||
g := []*types.Group{
|
||||
{
|
||||
ID: "groupA",
|
||||
Name: "GroupA",
|
||||
Peers: []string{},
|
||||
},
|
||||
{
|
||||
ID: "groupB",
|
||||
Name: "GroupB",
|
||||
Peers: []string{},
|
||||
},
|
||||
{
|
||||
ID: "groupC",
|
||||
Name: "GroupC",
|
||||
Peers: []string{},
|
||||
},
|
||||
}
|
||||
for _, group := range g {
|
||||
err = manager.CreateGroup(context.Background(), account.Id, userID, group)
|
||||
require.NoError(t, err, "failed to create group %s", group.Name)
|
||||
}
|
||||
|
||||
updMsg := updateManager.CreateChannel(context.Background(), peer1ID)
|
||||
t.Cleanup(func() {
|
||||
updateManager.CloseChannel(context.Background(), peer1ID)
|
||||
})
|
||||
updMsg := updateManager.CreateChannel(context.Background(), peer1ID)
|
||||
t.Cleanup(func() {
|
||||
updateManager.CloseChannel(context.Background(), peer1ID)
|
||||
})
|
||||
|
||||
// Creating a route with no routing peer and no peers in PeerGroups or Groups should not update account peers and not send peer update
|
||||
t.Run("creating route no routing peer and no peers in groups", func(t *testing.T) {
|
||||
route := route.Route{
|
||||
ID: "testingRoute1",
|
||||
Network: netip.MustParsePrefix("100.65.250.202/32"),
|
||||
// Creating a route with no routing peer and no peers in PeerGroups or Groups should not update account peers and not send peer update
|
||||
step(t, "creating route no routing peer and no peers in groups", func(t *testing.T) {
|
||||
settleAffectedUpdates(updMsg)
|
||||
|
||||
route := route.Route{
|
||||
ID: "testingRoute1",
|
||||
Network: netip.MustParsePrefix("100.65.250.202/32"),
|
||||
NetID: "superNet",
|
||||
NetworkType: route.IPv4Network,
|
||||
PeerGroups: []string{"groupA"},
|
||||
Description: "super",
|
||||
Masquerade: false,
|
||||
Metric: 9999,
|
||||
Enabled: true,
|
||||
Groups: []string{"groupA"},
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldNotReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
_, err := manager.CreateRoute(
|
||||
context.Background(), account.Id, route.Network, route.NetworkType, route.Domains, route.Peer,
|
||||
route.PeerGroups, route.Description, route.NetID, route.Masquerade, route.Metric,
|
||||
route.Groups, []string{}, true, userID, route.KeepRoute, route.SkipAutoApply,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("timeout waiting for peerShouldNotReceiveUpdate")
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
// Creating a route with no routing peer and having peers in groups that don't include peer1 should not send peer1 an update
|
||||
step(t, "creating a route with peers in PeerGroups and Groups", func(t *testing.T) {
|
||||
drainPeerUpdates(updMsg)
|
||||
|
||||
route := route.Route{
|
||||
ID: "testingRoute2",
|
||||
Network: netip.MustParsePrefix("192.0.2.0/32"),
|
||||
NetID: "superNet",
|
||||
NetworkType: route.IPv4Network,
|
||||
PeerGroups: []string{routeGroup3},
|
||||
Description: "super",
|
||||
Masquerade: false,
|
||||
Metric: 9999,
|
||||
Enabled: true,
|
||||
Groups: []string{routeGroup3},
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldNotReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
_, err := manager.CreateRoute(
|
||||
context.Background(), account.Id, route.Network, route.NetworkType, route.Domains, route.Peer,
|
||||
route.PeerGroups, route.Description, route.NetID, route.Masquerade, route.Metric,
|
||||
route.Groups, []string{}, true, userID, route.KeepRoute, route.SkipAutoApply,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("timeout waiting for peerShouldNotReceiveUpdate")
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
baseRoute := route.Route{
|
||||
ID: "testingRoute3",
|
||||
Network: netip.MustParsePrefix("192.168.0.0/16"),
|
||||
NetID: "superNet",
|
||||
NetworkType: route.IPv4Network,
|
||||
PeerGroups: []string{"groupA"},
|
||||
Description: "super",
|
||||
Masquerade: false,
|
||||
Metric: 9999,
|
||||
Enabled: true,
|
||||
Groups: []string{"groupA"},
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldNotReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
_, err := manager.CreateRoute(
|
||||
context.Background(), account.Id, route.Network, route.NetworkType, route.Domains, route.Peer,
|
||||
route.PeerGroups, route.Description, route.NetID, route.Masquerade, route.Metric,
|
||||
route.Groups, []string{}, true, userID, route.KeepRoute, route.SkipAutoApply,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("timeout waiting for peerShouldNotReceiveUpdate")
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
// Creating a route with no routing peer and having peers in groups that don't include peer1 should not send peer1 an update
|
||||
t.Run("creating a route with peers in PeerGroups and Groups", func(t *testing.T) {
|
||||
drainPeerUpdates(updMsg)
|
||||
|
||||
route := route.Route{
|
||||
ID: "testingRoute2",
|
||||
Network: netip.MustParsePrefix("192.0.2.0/32"),
|
||||
NetID: "superNet",
|
||||
NetworkType: route.IPv4Network,
|
||||
PeerGroups: []string{routeGroup3},
|
||||
Description: "super",
|
||||
Masquerade: false,
|
||||
Metric: 9999,
|
||||
Enabled: true,
|
||||
Groups: []string{routeGroup3},
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldNotReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
_, err := manager.CreateRoute(
|
||||
context.Background(), account.Id, route.Network, route.NetworkType, route.Domains, route.Peer,
|
||||
route.PeerGroups, route.Description, route.NetID, route.Masquerade, route.Metric,
|
||||
route.Groups, []string{}, true, userID, route.KeepRoute, route.SkipAutoApply,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("timeout waiting for peerShouldNotReceiveUpdate")
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
baseRoute := route.Route{
|
||||
ID: "testingRoute3",
|
||||
Network: netip.MustParsePrefix("192.168.0.0/16"),
|
||||
NetID: "superNet",
|
||||
NetworkType: route.IPv4Network,
|
||||
Peer: peer1ID,
|
||||
Description: "super",
|
||||
Masquerade: false,
|
||||
Metric: 9999,
|
||||
Enabled: true,
|
||||
Groups: []string{routeGroup1},
|
||||
}
|
||||
|
||||
// Creating route should update account peers and send peer update
|
||||
t.Run("creating route with a routing peer", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
newRoute, err := manager.CreateRoute(
|
||||
context.Background(), account.Id, baseRoute.Network, baseRoute.NetworkType, baseRoute.Domains, baseRoute.Peer,
|
||||
baseRoute.PeerGroups, baseRoute.Description, baseRoute.NetID, baseRoute.Masquerade, baseRoute.Metric,
|
||||
baseRoute.Groups, []string{}, true, userID, baseRoute.KeepRoute, !baseRoute.SkipAutoApply,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
baseRoute = *newRoute
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
// Updating the route should update account peers and send peer update when there is peers in group
|
||||
t.Run("updating route", func(t *testing.T) {
|
||||
baseRoute.Groups = []string{routeGroup1, routeGroup2}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
err := manager.SaveRoute(context.Background(), account.Id, userID, &baseRoute)
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
// Deleting the route should update account peers and send peer update
|
||||
t.Run("deleting route", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
err := manager.DeleteRoute(context.Background(), account.Id, baseRoute.ID, userID)
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
// Adding peer to route peer groups that do not have any peers should update account peers and send peer update
|
||||
t.Run("adding peer to route peer groups that do not have any peers", func(t *testing.T) {
|
||||
newRoute := route.Route{
|
||||
Network: netip.MustParsePrefix("192.168.12.0/16"),
|
||||
NetID: "superNet",
|
||||
NetworkType: route.IPv4Network,
|
||||
PeerGroups: []string{"groupB"},
|
||||
Peer: peer1ID,
|
||||
Description: "super",
|
||||
Masquerade: false,
|
||||
Metric: 9999,
|
||||
Enabled: true,
|
||||
Groups: []string{routeGroup1},
|
||||
}
|
||||
_, err := manager.CreateRoute(
|
||||
context.Background(), account.Id, newRoute.Network, newRoute.NetworkType, newRoute.Domains, newRoute.Peer,
|
||||
newRoute.PeerGroups, newRoute.Description, newRoute.NetID, newRoute.Masquerade, newRoute.Metric,
|
||||
newRoute.Groups, []string{}, true, userID, newRoute.KeepRoute, !newRoute.SkipAutoApply,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
// Creating route should update account peers and send peer update
|
||||
step(t, "creating route with a routing peer", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
err = manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{
|
||||
ID: "groupB",
|
||||
Name: "GroupB",
|
||||
Peers: []string{peer1ID},
|
||||
newRoute, err := manager.CreateRoute(
|
||||
context.Background(), account.Id, baseRoute.Network, baseRoute.NetworkType, baseRoute.Domains, baseRoute.Peer,
|
||||
baseRoute.PeerGroups, baseRoute.Description, baseRoute.NetID, baseRoute.Masquerade, baseRoute.Metric,
|
||||
baseRoute.Groups, []string{}, true, userID, baseRoute.KeepRoute, !baseRoute.SkipAutoApply,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
baseRoute = *newRoute
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
// Updating the route should update account peers and send peer update when there is peers in group
|
||||
step(t, "updating route", func(t *testing.T) {
|
||||
baseRoute.Groups = []string{routeGroup1, routeGroup2}
|
||||
|
||||
// Adding peer to route groups that do not have any peers should update account peers and send peer update
|
||||
t.Run("adding peer to route groups that do not have any peers", func(t *testing.T) {
|
||||
newRoute := route.Route{
|
||||
Network: netip.MustParsePrefix("192.168.13.0/16"),
|
||||
NetID: "superNet",
|
||||
NetworkType: route.IPv4Network,
|
||||
PeerGroups: []string{"groupB"},
|
||||
Description: "super",
|
||||
Masquerade: false,
|
||||
Metric: 9999,
|
||||
Enabled: true,
|
||||
Groups: []string{"groupC"},
|
||||
}
|
||||
_, err := manager.CreateRoute(
|
||||
context.Background(), account.Id, newRoute.Network, newRoute.NetworkType, newRoute.Domains, newRoute.Peer,
|
||||
newRoute.PeerGroups, newRoute.Description, newRoute.NetID, newRoute.Masquerade, newRoute.Metric,
|
||||
newRoute.Groups, []string{}, true, userID, newRoute.KeepRoute, !newRoute.SkipAutoApply,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
err := manager.SaveRoute(context.Background(), account.Id, userID, &baseRoute)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{
|
||||
ID: "groupC",
|
||||
Name: "GroupC",
|
||||
Peers: []string{peer1ID},
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
// Deleting the route should update account peers and send peer update
|
||||
step(t, "deleting route", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
err := manager.DeleteRoute(context.Background(), account.Id, baseRoute.ID, userID)
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
// Adding peer to route peer groups that do not have any peers should update account peers and send peer update
|
||||
step(t, "adding peer to route peer groups that do not have any peers", func(t *testing.T) {
|
||||
newRoute := route.Route{
|
||||
Network: netip.MustParsePrefix("192.168.12.0/16"),
|
||||
NetID: "superNet",
|
||||
NetworkType: route.IPv4Network,
|
||||
PeerGroups: []string{"groupB"},
|
||||
Description: "super",
|
||||
Masquerade: false,
|
||||
Metric: 9999,
|
||||
Enabled: true,
|
||||
Groups: []string{routeGroup1},
|
||||
}
|
||||
_, err := manager.CreateRoute(
|
||||
context.Background(), account.Id, newRoute.Network, newRoute.NetworkType, newRoute.Domains, newRoute.Peer,
|
||||
newRoute.PeerGroups, newRoute.Description, newRoute.NetID, newRoute.Masquerade, newRoute.Metric,
|
||||
newRoute.Groups, []string{}, true, userID, newRoute.KeepRoute, !newRoute.SkipAutoApply,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
err = manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{
|
||||
ID: "groupB",
|
||||
Name: "GroupB",
|
||||
Peers: []string{peer1ID},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
// Adding peer to route groups that do not have any peers should update account peers and send peer update
|
||||
step(t, "adding peer to route groups that do not have any peers", func(t *testing.T) {
|
||||
newRoute := route.Route{
|
||||
Network: netip.MustParsePrefix("192.168.13.0/16"),
|
||||
NetID: "superNet",
|
||||
NetworkType: route.IPv4Network,
|
||||
PeerGroups: []string{"groupB"},
|
||||
Description: "super",
|
||||
Masquerade: false,
|
||||
Metric: 9999,
|
||||
Enabled: true,
|
||||
Groups: []string{"groupC"},
|
||||
}
|
||||
_, err := manager.CreateRoute(
|
||||
context.Background(), account.Id, newRoute.Network, newRoute.NetworkType, newRoute.Domains, newRoute.Peer,
|
||||
newRoute.PeerGroups, newRoute.Description, newRoute.NetID, newRoute.Masquerade, newRoute.Metric,
|
||||
newRoute.Groups, []string{}, true, userID, newRoute.KeepRoute, !newRoute.SkipAutoApply,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
err = manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{
|
||||
ID: "groupC",
|
||||
Name: "GroupC",
|
||||
Peers: []string{peer1ID},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(peerUpdateTimeout):
|
||||
t.Error("timeout waiting for peerShouldReceiveUpdate")
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -397,75 +397,77 @@ func TestSetupKey_Copy(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSetupKeyAccountPeersUpdate(t *testing.T) {
|
||||
manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t)
|
||||
runPeerUpdateTest(t, func(t *testing.T) {
|
||||
manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t)
|
||||
|
||||
err := manager.CreateGroup(context.Background(), account.Id, userID, &types.Group{
|
||||
ID: "groupA",
|
||||
Name: "GroupA",
|
||||
Peers: []string{peer1.ID, peer2.ID, peer3.ID},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
policy := &types.Policy{
|
||||
Enabled: true,
|
||||
Rules: []*types.PolicyRule{
|
||||
{
|
||||
Enabled: true,
|
||||
Sources: []string{"groupA"},
|
||||
Destinations: []string{"group"},
|
||||
Bidirectional: true,
|
||||
Action: types.PolicyTrafficActionAccept,
|
||||
},
|
||||
},
|
||||
}
|
||||
_, err = manager.SavePolicy(context.Background(), account.Id, userID, policy, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
updMsg := updateManager.CreateChannel(context.Background(), peer1.ID)
|
||||
t.Cleanup(func() {
|
||||
updateManager.CloseChannel(context.Background(), peer1.ID)
|
||||
})
|
||||
|
||||
// The setup policy above dispatches affected-peer updates asynchronously; drain
|
||||
// any in-flight ones so the assertions only observe the setup-key operations.
|
||||
settleAffectedUpdates(updMsg)
|
||||
|
||||
var setupKey *types.SetupKey
|
||||
|
||||
// Creating setup key should not update account peers and not send peer update
|
||||
t.Run("creating setup key", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldNotReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
setupKey, err = manager.CreateSetupKey(context.Background(), account.Id, "key1", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false)
|
||||
err := manager.CreateGroup(context.Background(), account.Id, userID, &types.Group{
|
||||
ID: "groupA",
|
||||
Name: "GroupA",
|
||||
Peers: []string{peer1.ID, peer2.ID, peer3.ID},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("timeout waiting for peerShouldNotReceiveUpdate")
|
||||
policy := &types.Policy{
|
||||
Enabled: true,
|
||||
Rules: []*types.PolicyRule{
|
||||
{
|
||||
Enabled: true,
|
||||
Sources: []string{"groupA"},
|
||||
Destinations: []string{"group"},
|
||||
Bidirectional: true,
|
||||
Action: types.PolicyTrafficActionAccept,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
// Saving setup key should not update account peers and not send peer update
|
||||
t.Run("saving setup key", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldNotReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
_, err = manager.SaveSetupKey(context.Background(), account.Id, setupKey, userID)
|
||||
_, err = manager.SavePolicy(context.Background(), account.Id, userID, policy, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("timeout waiting for peerShouldNotReceiveUpdate")
|
||||
}
|
||||
updMsg := updateManager.CreateChannel(context.Background(), peer1.ID)
|
||||
t.Cleanup(func() {
|
||||
updateManager.CloseChannel(context.Background(), peer1.ID)
|
||||
})
|
||||
|
||||
// The setup policy above dispatches affected-peer updates asynchronously; drain
|
||||
// any in-flight ones so the assertions only observe the setup-key operations.
|
||||
settleAffectedUpdates(updMsg)
|
||||
|
||||
var setupKey *types.SetupKey
|
||||
|
||||
// Creating setup key should not update account peers and not send peer update
|
||||
step(t, "creating setup key", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldNotReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
setupKey, err = manager.CreateSetupKey(context.Background(), account.Id, "key1", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("timeout waiting for peerShouldNotReceiveUpdate")
|
||||
}
|
||||
})
|
||||
|
||||
// Saving setup key should not update account peers and not send peer update
|
||||
step(t, "saving setup key", func(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
peerShouldNotReceiveUpdate(t, updMsg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
_, err = manager.SaveSetupKey(context.Background(), account.Id, setupKey, userID)
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("timeout waiting for peerShouldNotReceiveUpdate")
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,70 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
// GetAccountOnboarding retrieves the onboarding information for a specific account.
|
||||
func (s *SqlStore) GetAccountOnboarding(ctx context.Context, accountID string) (*types.AccountOnboarding, error) {
|
||||
var accountOnboarding types.AccountOnboarding
|
||||
result := s.db.Model(&accountOnboarding).Take(&accountOnboarding, accountIDCondition, accountID)
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, status.NewAccountOnboardingNotFoundError(accountID)
|
||||
}
|
||||
log.WithContext(ctx).Errorf("error when getting account onboarding %s from the store: %s", accountID, result.Error)
|
||||
return nil, status.NewGetAccountFromStoreError(result.Error)
|
||||
}
|
||||
|
||||
return &accountOnboarding, nil
|
||||
}
|
||||
|
||||
// SaveAccountOnboarding updates the onboarding information for a specific account.
|
||||
func (s *SqlStore) SaveAccountOnboarding(ctx context.Context, onboarding *types.AccountOnboarding) error {
|
||||
result := s.db.Clauses(clause.OnConflict{UpdateAll: true}).Create(onboarding)
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("error when saving account onboarding %s in the store: %s", onboarding.AccountID, result.Error)
|
||||
return status.Errorf(status.Internal, "error when saving account onboarding %s in the store: %s", onboarding.AccountID, result.Error)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SqlStore) getAccountOnboarding(ctx context.Context, accountID string, account *types.Account) error {
|
||||
const query = `SELECT account_id, onboarding_flow_pending, signup_form_pending, created_at, updated_at FROM account_onboardings WHERE account_id = $1`
|
||||
var onboardingFlowPending, signupFormPending sql.NullBool
|
||||
var createdAt, updatedAt sql.NullTime
|
||||
err := s.pgxPool().QueryRow(ctx, query, accountID).Scan(
|
||||
&account.Onboarding.AccountID,
|
||||
&onboardingFlowPending,
|
||||
&signupFormPending,
|
||||
&createdAt,
|
||||
&updatedAt,
|
||||
)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
if createdAt.Valid {
|
||||
account.Onboarding.CreatedAt = createdAt.Time
|
||||
}
|
||||
if updatedAt.Valid {
|
||||
account.Onboarding.UpdatedAt = updatedAt.Time
|
||||
}
|
||||
if onboardingFlowPending.Valid {
|
||||
account.Onboarding.OnboardingFlowPending = onboardingFlowPending.Bool
|
||||
}
|
||||
if signupFormPending.Valid {
|
||||
account.Onboarding.SignupFormPending = signupFormPending.Bool
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
)
|
||||
|
||||
func TestSqlStore_GetAccountOnboarding(t *testing.T) {
|
||||
store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir())
|
||||
t.Cleanup(cleanup)
|
||||
require.NoError(t, err)
|
||||
|
||||
accountID := "9439-34653001fc3b-bf1c8084-ba50-4ce7"
|
||||
a, err := store.GetAccount(context.Background(), accountID)
|
||||
require.NoError(t, err)
|
||||
t.Logf("Onboarding: %+v", a.Onboarding)
|
||||
err = store.SaveAccount(context.Background(), a)
|
||||
require.NoError(t, err)
|
||||
onboarding, err := store.GetAccountOnboarding(context.Background(), accountID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, onboarding)
|
||||
require.Equal(t, accountID, onboarding.AccountID)
|
||||
require.Equal(t, time.Date(2024, time.October, 2, 14, 1, 38, 210000000, time.UTC), onboarding.CreatedAt.UTC())
|
||||
}
|
||||
|
||||
func TestSqlStore_SaveAccountOnboarding(t *testing.T) {
|
||||
store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir())
|
||||
t.Cleanup(cleanup)
|
||||
require.NoError(t, err)
|
||||
t.Run("New onboarding should be saved correctly", func(t *testing.T) {
|
||||
accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
|
||||
onboarding := &types.AccountOnboarding{
|
||||
AccountID: accountID,
|
||||
SignupFormPending: true,
|
||||
OnboardingFlowPending: true,
|
||||
}
|
||||
|
||||
err = store.SaveAccountOnboarding(context.Background(), onboarding)
|
||||
require.NoError(t, err)
|
||||
|
||||
savedOnboarding, err := store.GetAccountOnboarding(context.Background(), accountID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, onboarding.SignupFormPending, savedOnboarding.SignupFormPending)
|
||||
require.Equal(t, onboarding.OnboardingFlowPending, savedOnboarding.OnboardingFlowPending)
|
||||
})
|
||||
|
||||
t.Run("Existing onboarding should be updated correctly", func(t *testing.T) {
|
||||
accountID := "9439-34653001fc3b-bf1c8084-ba50-4ce7"
|
||||
onboarding, err := store.GetAccountOnboarding(context.Background(), accountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
onboarding.OnboardingFlowPending = !onboarding.OnboardingFlowPending
|
||||
onboarding.SignupFormPending = !onboarding.SignupFormPending
|
||||
|
||||
err = store.SaveAccountOnboarding(context.Background(), onboarding)
|
||||
require.NoError(t, err)
|
||||
|
||||
savedOnboarding, err := store.GetAccountOnboarding(context.Background(), accountID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, onboarding.SignupFormPending, savedOnboarding.SignupFormPending)
|
||||
require.Equal(t, onboarding.OnboardingFlowPending, savedOnboarding.OnboardingFlowPending)
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,285 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
// CreateAgentNetworkAccessLog persists a flattened agent-network access-log
|
||||
// entry together with its authorising-group child rows in a single
|
||||
// transaction.
|
||||
func (s *SqlStore) CreateAgentNetworkAccessLog(ctx context.Context, entry *agentNetworkTypes.AgentNetworkAccessLog, groups []agentNetworkTypes.AgentNetworkAccessLogGroup) error {
|
||||
err := s.transaction(ctx, func(tx *gorm.DB) error {
|
||||
// Idempotent on the log id / (log_id, group_id) so a proxy resend of the
|
||||
// same entry can't fail the request.
|
||||
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(entry).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(groups) > 0 {
|
||||
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&groups).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
log.WithContext(ctx).WithFields(log.Fields{
|
||||
"account_id": entry.AccountID,
|
||||
"service_id": entry.ServiceID,
|
||||
"model": entry.Model,
|
||||
}).Errorf("failed to create agent-network access log entry in store: %v", err)
|
||||
return status.Errorf(status.Internal, "failed to create agent-network access log entry in store")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteOldAgentNetworkAccessLogs deletes an account's access-log rows (and
|
||||
// their authorising-group child rows) older than the cutoff. Usage records are
|
||||
// untouched — they are the long-term aggregate. Returns the number of log rows
|
||||
// deleted.
|
||||
func (s *SqlStore) DeleteOldAgentNetworkAccessLogs(ctx context.Context, accountID string, olderThan time.Time) (int64, error) {
|
||||
var deleted int64
|
||||
err := s.transaction(ctx, func(tx *gorm.DB) error {
|
||||
// Remove group child rows for the soon-to-be-deleted logs first.
|
||||
if err := tx.Exec(
|
||||
"DELETE FROM agent_network_access_log_group WHERE account_id = ? AND log_id IN (SELECT id FROM agent_network_access_log WHERE account_id = ? AND timestamp < ?)",
|
||||
accountID, accountID, olderThan,
|
||||
).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
res := tx.Where("account_id = ? AND timestamp < ?", accountID, olderThan).
|
||||
Delete(&agentNetworkTypes.AgentNetworkAccessLog{})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
deleted = res.RowsAffected
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to delete old agent-network access logs for account %s: %v", accountID, err)
|
||||
return 0, status.Errorf(status.Internal, "failed to delete old agent-network access logs")
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
// GetDeletedAccountIDsWithAgentNetworkAccessLogs returns the IDs of accounts that no
|
||||
// longer exist but still have access-log rows. The retention sweep is driven by settings
|
||||
// rows, which are deleted with the account, so it uses this to find logs it would
|
||||
// otherwise never expire.
|
||||
func (s *SqlStore) GetDeletedAccountIDsWithAgentNetworkAccessLogs(ctx context.Context) ([]string, error) {
|
||||
var accountIDs []string
|
||||
err := s.db.Model(&agentNetworkTypes.AgentNetworkAccessLog{}).
|
||||
Distinct("account_id").
|
||||
Where("NOT EXISTS (SELECT 1 FROM accounts WHERE accounts.id = agent_network_access_log.account_id)").
|
||||
Pluck("account_id", &accountIDs).Error
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to get deleted accounts with agent-network access logs: %v", err)
|
||||
return nil, status.Errorf(status.Internal, "failed to get deleted accounts with agent-network access logs")
|
||||
}
|
||||
return accountIDs, nil
|
||||
}
|
||||
|
||||
// GetAgentNetworkAccessLogs retrieves flattened agent-network access logs for
|
||||
// an account with server-side pagination, filtering and sorting. Authorising
|
||||
// group ids are hydrated from the group child table for the returned page.
|
||||
func (s *SqlStore) GetAgentNetworkAccessLogs(ctx context.Context, lockStrength LockingStrength, accountID string, filter agentNetworkTypes.AgentNetworkAccessLogFilter) ([]*agentNetworkTypes.AgentNetworkAccessLog, int64, error) {
|
||||
var logs []*agentNetworkTypes.AgentNetworkAccessLog
|
||||
var totalCount int64
|
||||
|
||||
countQuery := s.applyAgentNetworkAccessLogFilters(
|
||||
s.db.Model(&agentNetworkTypes.AgentNetworkAccessLog{}).Where(accountIDCondition, accountID),
|
||||
filter,
|
||||
)
|
||||
if err := countQuery.Count(&totalCount).Error; err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to count agent-network access logs: %v", err)
|
||||
return nil, 0, status.Errorf(status.Internal, "failed to count agent-network access logs")
|
||||
}
|
||||
|
||||
query := s.applyAgentNetworkAccessLogFilters(
|
||||
s.db.Where(accountIDCondition, accountID),
|
||||
filter,
|
||||
).
|
||||
Order(filter.GetSortColumn() + " " + filter.GetSortOrder()).
|
||||
Limit(filter.GetLimit()).
|
||||
Offset(filter.GetOffset())
|
||||
|
||||
if lockStrength != LockingStrengthNone {
|
||||
query = query.Clauses(clause.Locking{Strength: string(lockStrength)})
|
||||
}
|
||||
|
||||
if err := query.Find(&logs).Error; err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to get agent-network access logs from store: %v", err)
|
||||
return nil, 0, status.Errorf(status.Internal, "failed to get agent-network access logs from store")
|
||||
}
|
||||
|
||||
if err := s.hydrateAgentNetworkAccessLogGroups(ctx, accountID, logs); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return logs, totalCount, nil
|
||||
}
|
||||
|
||||
// applyAgentNetworkAccessLogFilters applies the filter conditions to a query.
|
||||
func (s *SqlStore) applyAgentNetworkAccessLogFilters(query *gorm.DB, filter agentNetworkTypes.AgentNetworkAccessLogFilter) *gorm.DB {
|
||||
if filter.Search != nil {
|
||||
p := "%" + *filter.Search + "%"
|
||||
query = query.Where(
|
||||
"id LIKE ? OR host LIKE ? OR path LIKE ? OR model LIKE ? OR user_id IN (SELECT id FROM users WHERE email LIKE ? OR name LIKE ?)",
|
||||
p, p, p, p, p, p,
|
||||
)
|
||||
}
|
||||
if filter.UserID != nil {
|
||||
query = query.Where("user_id = ?", *filter.UserID)
|
||||
}
|
||||
if filter.SessionID != nil {
|
||||
query = query.Where("session_id = ?", *filter.SessionID)
|
||||
}
|
||||
if filter.Decision != nil {
|
||||
query = query.Where("decision = ?", *filter.Decision)
|
||||
}
|
||||
if filter.PathPrefix != nil {
|
||||
query = query.Where("path LIKE ?", *filter.PathPrefix+"%")
|
||||
}
|
||||
if len(filter.ProviderIDs) > 0 {
|
||||
query = query.Where("resolved_provider_id IN ?", filter.ProviderIDs)
|
||||
}
|
||||
if len(filter.Models) > 0 {
|
||||
query = query.Where("model IN ?", filter.Models)
|
||||
}
|
||||
if len(filter.GroupIDs) > 0 {
|
||||
query = query.Where(
|
||||
"id IN (SELECT log_id FROM agent_network_access_log_group WHERE group_id IN ?)",
|
||||
filter.GroupIDs,
|
||||
)
|
||||
}
|
||||
if filter.StartDate != nil {
|
||||
query = query.Where("timestamp >= ?", *filter.StartDate)
|
||||
}
|
||||
if filter.EndDate != nil {
|
||||
query = query.Where("timestamp <= ?", *filter.EndDate)
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
// hydrateAgentNetworkAccessLogGroups loads the authorising group ids for the
|
||||
// given page of entries and assigns them onto each entry's GroupIDs field.
|
||||
func (s *SqlStore) hydrateAgentNetworkAccessLogGroups(ctx context.Context, accountID string, logs []*agentNetworkTypes.AgentNetworkAccessLog) error {
|
||||
if len(logs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ids := make([]string, 0, len(logs))
|
||||
for _, l := range logs {
|
||||
ids = append(ids, l.ID)
|
||||
}
|
||||
|
||||
var rows []agentNetworkTypes.AgentNetworkAccessLogGroup
|
||||
if err := s.db.
|
||||
Where(accountIDCondition, accountID).
|
||||
Where("log_id IN ?", ids).
|
||||
Find(&rows).Error; err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to hydrate agent-network access log groups: %v", err)
|
||||
return status.Errorf(status.Internal, "failed to hydrate agent-network access log groups")
|
||||
}
|
||||
|
||||
byLog := make(map[string][]string, len(logs))
|
||||
for _, r := range rows {
|
||||
byLog[r.LogID] = append(byLog[r.LogID], r.GroupID)
|
||||
}
|
||||
for _, l := range logs {
|
||||
l.GroupIDs = byLog[l.ID]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// agentNetworkSessionKeyExpr is the SQL group key for session-grouped access
|
||||
// logs: the row's session id, or — when the client sent none — the row id, so
|
||||
// session-less requests each form their own singleton group. COALESCE/NULLIF
|
||||
// are standard SQL, so this stays portable across SQLite and Postgres.
|
||||
const agentNetworkSessionKeyExpr = "COALESCE(NULLIF(session_id, ''), id)"
|
||||
|
||||
// GetAgentNetworkAccessLogSessions retrieves agent-network access logs grouped
|
||||
// by session, with server-side pagination, filtering and sorting at the session
|
||||
// level. It paginates over the distinct session keys (ordered by the requested
|
||||
// session-level aggregate), fetches every entry for the page's sessions, and
|
||||
// folds them into per-session summaries. The returned count is the number of
|
||||
// matching sessions. Filters apply to the entries, so a session's summary
|
||||
// reflects only its filter-matching requests.
|
||||
func (s *SqlStore) GetAgentNetworkAccessLogSessions(ctx context.Context, lockStrength LockingStrength, accountID string, filter agentNetworkTypes.AgentNetworkAccessLogFilter) ([]*agentNetworkTypes.AgentNetworkAccessLogSession, int64, error) {
|
||||
// Count distinct sessions via a grouped subquery — portable and avoids
|
||||
// relying on COUNT(DISTINCT <expr>) quoting quirks.
|
||||
sessionsSubquery := s.applyAgentNetworkAccessLogFilters(
|
||||
s.db.Model(&agentNetworkTypes.AgentNetworkAccessLog{}).Where(accountIDCondition, accountID),
|
||||
filter,
|
||||
).
|
||||
Select(agentNetworkSessionKeyExpr + " AS session_key").
|
||||
Group(agentNetworkSessionKeyExpr)
|
||||
|
||||
var totalCount int64
|
||||
if err := s.db.Table("(?) AS sessions", sessionsSubquery).Count(&totalCount).Error; err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to count agent-network access-log sessions: %v", err)
|
||||
return nil, 0, status.Errorf(status.Internal, "failed to count agent-network access-log sessions")
|
||||
}
|
||||
|
||||
// The page of session keys, ordered by the session-level aggregate. The
|
||||
// session-key tiebreaker keeps pagination deterministic when the primary
|
||||
// aggregate ties.
|
||||
type sessionKeyRow struct {
|
||||
SessionKey string
|
||||
}
|
||||
var keyRows []sessionKeyRow
|
||||
keyQuery := s.applyAgentNetworkAccessLogFilters(
|
||||
s.db.Model(&agentNetworkTypes.AgentNetworkAccessLog{}).Where(accountIDCondition, accountID),
|
||||
filter,
|
||||
).
|
||||
Select(agentNetworkSessionKeyExpr + " AS session_key").
|
||||
Group(agentNetworkSessionKeyExpr).
|
||||
Order(filter.GetSessionSortExpr() + " " + filter.GetSortOrder()).
|
||||
Order("session_key ASC").
|
||||
Limit(filter.GetLimit()).
|
||||
Offset(filter.GetOffset())
|
||||
if err := keyQuery.Scan(&keyRows).Error; err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to list agent-network access-log session keys: %v", err)
|
||||
return nil, 0, status.Errorf(status.Internal, "failed to list agent-network access-log session keys")
|
||||
}
|
||||
if len(keyRows) == 0 {
|
||||
return nil, totalCount, nil
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(keyRows))
|
||||
for _, r := range keyRows {
|
||||
keys = append(keys, r.SessionKey)
|
||||
}
|
||||
|
||||
// All entries for the page's sessions, contiguous per session and oldest
|
||||
// first within each — the fold relies on that ordering.
|
||||
var entries []*agentNetworkTypes.AgentNetworkAccessLog
|
||||
entriesQuery := s.applyAgentNetworkAccessLogFilters(
|
||||
s.db.Where(accountIDCondition, accountID),
|
||||
filter,
|
||||
).
|
||||
Where(agentNetworkSessionKeyExpr+" IN ?", keys).
|
||||
Order(agentNetworkSessionKeyExpr + ", timestamp ASC")
|
||||
|
||||
if lockStrength != LockingStrengthNone {
|
||||
entriesQuery = entriesQuery.Clauses(clause.Locking{Strength: string(lockStrength)})
|
||||
}
|
||||
|
||||
if err := entriesQuery.Find(&entries).Error; err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to get agent-network access-log session entries: %v", err)
|
||||
return nil, 0, status.Errorf(status.Internal, "failed to get agent-network access-log session entries")
|
||||
}
|
||||
|
||||
if err := s.hydrateAgentNetworkAccessLogGroups(ctx, accountID, entries); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return agentNetworkTypes.FoldAccessLogSessions(keys, entries), totalCount, nil
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
// CreateAgentNetworkUsage persists a stripped agent-network usage record
|
||||
// together with its authorising-group child rows in a single transaction.
|
||||
func (s *SqlStore) CreateAgentNetworkUsage(ctx context.Context, usage *agentNetworkTypes.AgentNetworkUsage, groups []agentNetworkTypes.AgentNetworkUsageGroup) error {
|
||||
err := s.transaction(ctx, func(tx *gorm.DB) error {
|
||||
// Idempotent on the usage id / (usage_id, group_id) so a proxy resend of
|
||||
// the same entry can't fail the request.
|
||||
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(usage).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(groups) > 0 {
|
||||
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&groups).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
log.WithContext(ctx).WithFields(log.Fields{
|
||||
"account_id": usage.AccountID,
|
||||
"model": usage.Model,
|
||||
}).Errorf("failed to create agent-network usage record in store: %v", err)
|
||||
return status.Errorf(status.Internal, "failed to create agent-network usage record in store")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAgentNetworkUsageRows returns the stripped usage rows for an account that
|
||||
// match the filter (date / user / group / provider / model). Aggregation into
|
||||
// time buckets happens in the manager so granularities stay engine-portable.
|
||||
func (s *SqlStore) GetAgentNetworkUsageRows(ctx context.Context, lockStrength LockingStrength, accountID string, filter agentNetworkTypes.AgentNetworkAccessLogFilter) ([]*agentNetworkTypes.AgentNetworkUsage, error) {
|
||||
var rows []*agentNetworkTypes.AgentNetworkUsage
|
||||
|
||||
query := s.applyAgentNetworkUsageFilters(
|
||||
s.db.Where(accountIDCondition, accountID),
|
||||
filter,
|
||||
).Order("timestamp ASC")
|
||||
|
||||
if lockStrength != LockingStrengthNone {
|
||||
query = query.Clauses(clause.Locking{Strength: string(lockStrength)})
|
||||
}
|
||||
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to get agent-network usage rows from store: %v", err)
|
||||
return nil, status.Errorf(status.Internal, "failed to get agent-network usage rows from store")
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// applyAgentNetworkUsageFilters applies the shared access-log filter's
|
||||
// date/user/group/provider/model conditions to a usage-table query. Pagination,
|
||||
// sort and free-text search are ignored — the overview is an aggregate.
|
||||
func (s *SqlStore) applyAgentNetworkUsageFilters(query *gorm.DB, filter agentNetworkTypes.AgentNetworkAccessLogFilter) *gorm.DB {
|
||||
if filter.UserID != nil {
|
||||
query = query.Where("user_id = ?", *filter.UserID)
|
||||
}
|
||||
if filter.SessionID != nil {
|
||||
query = query.Where("session_id = ?", *filter.SessionID)
|
||||
}
|
||||
if len(filter.ProviderIDs) > 0 {
|
||||
query = query.Where("resolved_provider_id IN ?", filter.ProviderIDs)
|
||||
}
|
||||
if len(filter.Models) > 0 {
|
||||
query = query.Where("model IN ?", filter.Models)
|
||||
}
|
||||
if len(filter.GroupIDs) > 0 {
|
||||
query = query.Where(
|
||||
"id IN (SELECT usage_id FROM agent_network_request_usage_group WHERE group_id IN ?)",
|
||||
filter.GroupIDs,
|
||||
)
|
||||
}
|
||||
if filter.StartDate != nil {
|
||||
query = query.Where("timestamp >= ?", *filter.StartDate)
|
||||
}
|
||||
if filter.EndDate != nil {
|
||||
query = query.Where("timestamp <= ?", *filter.EndDate)
|
||||
}
|
||||
return query
|
||||
}
|
||||
@@ -619,7 +619,7 @@ func (s *SqlStore) IncrementAgentNetworkConsumptionBatch(
|
||||
}
|
||||
|
||||
const tbl = "agent_network_consumption"
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
err := s.transaction(ctx, func(tx *gorm.DB) error {
|
||||
for _, k := range keys {
|
||||
if k.DimID == "" || k.WindowSeconds <= 0 {
|
||||
return status.Errorf(status.InvalidArgument, "dim_id and window_seconds must be set")
|
||||
@@ -663,6 +663,21 @@ func (s *SqlStore) IncrementAgentNetworkConsumptionBatch(
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteAgentNetworkConsumptionOfDeletedAccounts deletes every consumption counter whose
|
||||
// account no longer exists and returns the number of rows deleted. Counters grow with
|
||||
// traffic, so they are swept in the background instead of in the account-deletion
|
||||
// transaction, and the sweep also catches counters a proxy writes after the deletion.
|
||||
func (s *SqlStore) DeleteAgentNetworkConsumptionOfDeletedAccounts(ctx context.Context) (int64, error) {
|
||||
res := s.db.
|
||||
Where("NOT EXISTS (SELECT 1 FROM accounts WHERE accounts.id = agent_network_consumption.account_id)").
|
||||
Delete(&agentNetworkTypes.Consumption{})
|
||||
if res.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to delete agent-network consumption of deleted accounts: %v", res.Error)
|
||||
return 0, status.Errorf(status.Internal, "failed to delete agent-network consumption of deleted accounts")
|
||||
}
|
||||
return res.RowsAffected, nil
|
||||
}
|
||||
|
||||
// ListAgentNetworkConsumption returns every consumption row recorded
|
||||
// for the account, ordered by window_start descending. Backs the
|
||||
// dashboard's basic counter view.
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
)
|
||||
|
||||
// TestAgentNetworkUsage_RealStore_RoundTrip drives CreateAgentNetworkUsage and
|
||||
@@ -300,3 +301,37 @@ func TestDeleteOldAgentNetworkAccessLogs(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Len(t, usage, 1, "usage record for the deleted log must survive")
|
||||
}
|
||||
|
||||
// TestDeleteAgentNetworkConsumptionOfDeletedAccounts verifies that the sweep removes the
|
||||
// consumption counters of accounts that no longer exist and leaves live accounts' counters,
|
||||
// including those of a live account without a settings row.
|
||||
func TestDeleteAgentNetworkConsumptionOfDeletedAccounts(t *testing.T) {
|
||||
runTestForAllEngines(t, "", func(t *testing.T, s Store) {
|
||||
ctx := context.Background()
|
||||
const (
|
||||
liveAccountID = "acc-anet-consumption-live"
|
||||
deletedAccountID = "acc-anet-consumption-deleted"
|
||||
)
|
||||
require.NoError(t, s.SaveAccount(ctx, &types.Account{Id: liveAccountID}))
|
||||
|
||||
windowStart := time.Now().UTC().Truncate(time.Hour)
|
||||
for _, accountID := range []string{liveAccountID, deletedAccountID} {
|
||||
for _, dimID := range []string{"user-1", "user-2"} {
|
||||
require.NoError(t, s.IncrementAgentNetworkConsumption(ctx, accountID,
|
||||
agentNetworkTypes.DimensionUser, dimID, 3600, windowStart, 10, 5, 0.01))
|
||||
}
|
||||
}
|
||||
|
||||
deleted, err := s.DeleteAgentNetworkConsumptionOfDeletedAccounts(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(2), deleted, "both of the deleted account's counters should be removed")
|
||||
|
||||
rows, err := s.ListAgentNetworkConsumption(ctx, LockingStrengthNone, deletedAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, rows, "the deleted account should have no consumption counters left")
|
||||
|
||||
rows, err = s.ListAgentNetworkConsumption(ctx, LockingStrengthNone, liveAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, rows, 2, "the live account's consumption counters should survive")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rs/xid"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
|
||||
nbdomain "github.com/netbirdio/netbird/shared/management/domain"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
// GetCustomDomainsCounts returns the total and validated custom domain counts.
|
||||
func (s *SqlStore) GetCustomDomainsCounts(ctx context.Context) (int64, int64, error) {
|
||||
var total, validated int64
|
||||
if err := s.db.Model(&domain.Domain{}).Count(&total).Error; err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if err := s.db.Model(&domain.Domain{}).Where("validated = ?", true).Count(&validated).Error; err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return total, validated, nil
|
||||
}
|
||||
|
||||
func (s *SqlStore) GetCustomDomain(ctx context.Context, accountID string, domainID string) (*domain.Domain, error) {
|
||||
tx := s.db
|
||||
|
||||
customDomain := &domain.Domain{}
|
||||
result := tx.Take(&customDomain, accountAndIDQueryCondition, accountID, domainID)
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, status.Errorf(status.NotFound, "custom domain %s not found", domainID)
|
||||
}
|
||||
|
||||
log.WithContext(ctx).Errorf("failed to get custom domain from store: %v", result.Error)
|
||||
return nil, status.Errorf(status.Internal, "failed to get custom domain from store")
|
||||
}
|
||||
|
||||
return customDomain, nil
|
||||
}
|
||||
|
||||
func (s *SqlStore) ListFreeDomains(ctx context.Context, accountID string) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *SqlStore) ListCustomDomains(ctx context.Context, accountID string) ([]*domain.Domain, error) {
|
||||
tx := s.db
|
||||
|
||||
var domains []*domain.Domain
|
||||
result := tx.Find(&domains, accountIDCondition, accountID)
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to get reverse proxy custom domains from the store: %s", result.Error)
|
||||
return nil, status.Errorf(status.Internal, "failed to get reverse proxy custom domains from store")
|
||||
}
|
||||
|
||||
return domains, nil
|
||||
}
|
||||
|
||||
// GetCustomDomainByName returns the custom domain row holding the given name,
|
||||
// regardless of which account owns it.
|
||||
func (s *SqlStore) GetCustomDomainByName(ctx context.Context, domainName string) (*domain.Domain, error) {
|
||||
customDomain := &domain.Domain{}
|
||||
result := s.db.Take(customDomain, "domain = ?", domainName)
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, status.Errorf(status.NotFound, "custom domain %s not found", domainName)
|
||||
}
|
||||
|
||||
log.WithContext(ctx).Errorf("failed to get custom domain by name from store: %v", result.Error)
|
||||
return nil, status.Errorf(status.Internal, "failed to get custom domain from store")
|
||||
}
|
||||
|
||||
return customDomain, nil
|
||||
}
|
||||
|
||||
func (s *SqlStore) CreateCustomDomain(ctx context.Context, accountID string, domainName string, targetCluster string, validated bool) (*domain.Domain, error) {
|
||||
newDomain := &domain.Domain{
|
||||
ID: xid.New().String(), // Generate our own ID because gorm doesn't always configure the database to handle this for us.
|
||||
Domain: domainName,
|
||||
AccountID: accountID,
|
||||
TargetCluster: targetCluster,
|
||||
Type: domain.TypeCustom,
|
||||
Validated: validated,
|
||||
}
|
||||
if !validated {
|
||||
expiresAt := time.Now().UTC().Add(domain.ValidationTTL)
|
||||
newDomain.ValidationExpiresAt = &expiresAt
|
||||
}
|
||||
result := s.db.Create(newDomain)
|
||||
if result.Error != nil {
|
||||
// The unique index is the last guard when two requests clear the
|
||||
// manager's availability check at the same time. The one that loses the
|
||||
// insert is a conflict, not an internal failure.
|
||||
var count int64
|
||||
if err := s.db.Model(&domain.Domain{}).Where("domain = ?", domainName).Count(&count).Error; err == nil && count > 0 {
|
||||
// The insert error is logged even on this path: the name being taken
|
||||
// is what the caller has to act on, but if the insert also failed for
|
||||
// an unrelated reason the operator still needs to see it.
|
||||
log.WithContext(ctx).Warnf("create reverse proxy custom domain %s rejected, name already registered: %v", domainName, result.Error)
|
||||
return nil, status.Errorf(status.AlreadyExists, "domain %s is already registered", domainName)
|
||||
}
|
||||
|
||||
log.WithContext(ctx).Errorf("failed to create reverse proxy custom domain to store: %v", result.Error)
|
||||
return nil, status.Errorf(status.Internal, "failed to create reverse proxy custom domain to store")
|
||||
}
|
||||
|
||||
return newDomain, nil
|
||||
}
|
||||
|
||||
// UpdateCustomDomain completes validation only while the original registration is pending.
|
||||
func (s *SqlStore) UpdateCustomDomain(ctx context.Context, accountID string, d *domain.Domain) (*domain.Domain, error) {
|
||||
if !d.Validated {
|
||||
return nil, status.Errorf(status.InvalidArgument, "custom domain update must complete validation")
|
||||
}
|
||||
result := s.db.WithContext(ctx).Model(&domain.Domain{}).
|
||||
Where(accountAndIDQueryCondition, accountID, d.ID).
|
||||
Where("domain = ? AND target_cluster = ?", d.Domain, d.TargetCluster).
|
||||
Where("validated = ? AND validation_expires_at > ?", false, time.Now().UTC()).
|
||||
Update("validated", true)
|
||||
if result.Error != nil {
|
||||
return nil, fmt.Errorf("validate custom domain in store: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return nil, status.Errorf(status.PreconditionFailed, "custom domain registration is no longer pending validation")
|
||||
}
|
||||
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// LockCustomDomains holds shared locks on registrations covering a service until commit.
|
||||
func (s *SqlStore) LockCustomDomains(ctx context.Context, accountID string, serviceDomain nbdomain.Domain) ([]*domain.Domain, error) {
|
||||
var names []string
|
||||
for name := serviceDomain.PunycodeString(); name != ""; {
|
||||
names = append(names, name)
|
||||
_, name, _ = strings.Cut(name, ".")
|
||||
}
|
||||
|
||||
var domains []*domain.Domain
|
||||
if err := s.db.WithContext(ctx).Clauses(clause.Locking{Strength: string(LockingStrengthShare)}).
|
||||
Where(accountIDCondition, accountID).Where("domain IN ?", names).
|
||||
Order("id").Find(&domains).Error; err != nil {
|
||||
return nil, fmt.Errorf("lock custom domains: %w", err)
|
||||
}
|
||||
return domains, nil
|
||||
}
|
||||
|
||||
// DeleteCustomDomain removes a registration only when no service uses its namespace.
|
||||
func (s *SqlStore) DeleteCustomDomain(ctx context.Context, accountID string, domainID string) error {
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var d domain.Domain
|
||||
// Service writes hold a shared lock on this row through commit, so neither
|
||||
// operation can proceed against the other's outdated view of the domain.
|
||||
if err := tx.Clauses(clause.Locking{Strength: string(LockingStrengthUpdate)}).
|
||||
Take(&d, accountAndIDQueryCondition, accountID, domainID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return status.Errorf(status.NotFound, "custom domain not found")
|
||||
}
|
||||
return fmt.Errorf("lock custom domain for deletion: %w", err)
|
||||
}
|
||||
|
||||
result := tx.Where(accountAndIDQueryCondition, accountID, domainID).
|
||||
Where("NOT EXISTS (?)", customDomainServices(tx, &d).Select("1")).Delete(&domain.Domain{})
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("delete custom domain: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return status.Errorf(status.PreconditionFailed, "custom domain has dependent services; delete or move them before deleting the domain")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
nbdomain "github.com/netbirdio/netbird/shared/management/domain"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
func TestLockCustomDomains_ConcurrentServices(t *testing.T) {
|
||||
runTestForAllEngines(t, "", func(t *testing.T, store Store) {
|
||||
if store.GetStoreEngine() == types.SqliteStoreEngine {
|
||||
t.Skip("SQLite serializes transactions on one connection")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
require.NoError(t, store.SaveAccount(ctx, newAccountWithId(ctx, "owner", "admin", "")))
|
||||
_, err := store.CreateCustomDomain(ctx, "owner", "one.example.com", "cluster", true)
|
||||
require.NoError(t, err)
|
||||
_, err = store.CreateCustomDomain(ctx, "owner", "two.example.com", "cluster", true)
|
||||
require.NoError(t, err)
|
||||
|
||||
locked := make(chan error, 1)
|
||||
release := make(chan struct{})
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- store.ExecuteInTransaction(ctx, func(tx Store) error {
|
||||
_, err := tx.LockCustomDomains(ctx, "owner", "app.one.example.com")
|
||||
locked <- err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
select {
|
||||
case <-release:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
})
|
||||
}()
|
||||
var lockErr error
|
||||
select {
|
||||
case lockErr = <-locked:
|
||||
case err := <-done:
|
||||
t.Fatalf("transaction ended before locking: %v", err)
|
||||
}
|
||||
writeCtx, writeCancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer writeCancel()
|
||||
var writeErr error
|
||||
for _, name := range []nbdomain.Domain{"app.one.example.com", "app.two.example.com"} {
|
||||
writeErr = store.ExecuteInTransaction(writeCtx, func(tx Store) error {
|
||||
if _, err := tx.LockCustomDomains(writeCtx, "owner", name); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.CreateService(writeCtx, &rpservice.Service{
|
||||
ID: name.PunycodeString(), AccountID: "owner", Domain: name.PunycodeString(),
|
||||
})
|
||||
})
|
||||
if writeErr != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
close(release)
|
||||
require.NoError(t, <-done)
|
||||
require.NoError(t, lockErr)
|
||||
require.NoError(t, writeErr, "domain authorization locks must allow concurrent service writes")
|
||||
services, err := store.GetAccountServices(ctx, LockingStrengthNone, "owner")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, services, 2, "both services must commit while the first domain is locked")
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteCustomDomain_ServiceDependencies(t *testing.T) {
|
||||
runTestForAllEngines(t, "", func(t *testing.T, store Store) {
|
||||
ctx := context.Background()
|
||||
require.NoError(t, store.SaveAccount(ctx, newAccountWithId(ctx, "owner", "admin", "")))
|
||||
d, err := store.CreateCustomDomain(ctx, "owner", "example.com", "cluster", true)
|
||||
require.NoError(t, err)
|
||||
svc := &rpservice.Service{ID: "service", AccountID: "owner", Domain: "APP.EXAMPLE.COM."}
|
||||
require.NoError(t, store.CreateService(ctx, svc))
|
||||
|
||||
err = store.DeleteCustomDomain(ctx, "other", d.ID)
|
||||
require.Error(t, err)
|
||||
sErr, ok := status.FromError(err)
|
||||
require.True(t, ok, "cross-account deletion must return a typed error")
|
||||
assert.Equal(t, status.NotFound, sErr.Type(), "cross-account deletion must not reveal dependencies")
|
||||
|
||||
err = store.DeleteCustomDomain(ctx, "owner", d.ID)
|
||||
require.Error(t, err)
|
||||
sErr, ok = status.FromError(err)
|
||||
require.True(t, ok, "dependent services must return a typed error")
|
||||
assert.Equal(t, status.PreconditionFailed, sErr.Type(), "deletion must fail until services are removed")
|
||||
stored, err := store.GetCustomDomain(ctx, "owner", d.ID)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, stored.Validated, "rejected deletion must preserve validation")
|
||||
|
||||
require.NoError(t, store.DeleteService(ctx, "owner", svc.ID))
|
||||
require.NoError(t, store.DeleteCustomDomain(ctx, "owner", d.ID))
|
||||
_, err = store.GetCustomDomain(ctx, "owner", d.ID)
|
||||
require.Error(t, err, "the registration must be gone after successful deletion")
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteCustomDomain_OtherAccountSubdomainIsNotADependency(t *testing.T) {
|
||||
runTestForAllEngines(t, "", func(t *testing.T, store Store) {
|
||||
ctx := context.Background()
|
||||
require.NoError(t, store.SaveAccount(ctx, newAccountWithId(ctx, "owner", "admin", "")))
|
||||
require.NoError(t, store.SaveAccount(ctx, newAccountWithId(ctx, "other", "admin", "")))
|
||||
|
||||
// Registrations are unique by name, so a second account can hold a
|
||||
// subdomain of the first account's registration and serve from it.
|
||||
parent, err := store.CreateCustomDomain(ctx, "owner", "example.com", "cluster", true)
|
||||
require.NoError(t, err)
|
||||
_, err = store.CreateCustomDomain(ctx, "other", "team.example.com", "cluster", true)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, store.CreateService(ctx, &rpservice.Service{
|
||||
ID: "service", AccountID: "other", Domain: "app.team.example.com",
|
||||
}))
|
||||
|
||||
require.NoError(t, store.DeleteCustomDomain(ctx, "owner", parent.ID),
|
||||
"another account's service must not hold the registration open")
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteCustomDomain_ConcurrentServiceCreation(t *testing.T) {
|
||||
runTestForAllEngines(t, "", func(t *testing.T, store Store) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
require.NoError(t, store.SaveAccount(ctx, newAccountWithId(ctx, "owner", "admin", "")))
|
||||
for i := range 10 {
|
||||
d, err := store.CreateCustomDomain(ctx, "owner", fmt.Sprintf("app%d.example.com", i), "cluster", true)
|
||||
require.NoError(t, err)
|
||||
svc := &rpservice.Service{ID: fmt.Sprintf("service-%d", i), AccountID: "owner", Domain: "nested." + d.Domain}
|
||||
start := make(chan struct{})
|
||||
created := make(chan error, 1)
|
||||
deleted := make(chan error, 1)
|
||||
go func() {
|
||||
<-start
|
||||
created <- store.ExecuteInTransaction(ctx, func(tx Store) error {
|
||||
domains, err := tx.LockCustomDomains(ctx, "owner", nbdomain.Domain(svc.Domain))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, candidate := range domains {
|
||||
if candidate.ID == d.ID && candidate.Validated {
|
||||
return tx.CreateService(ctx, svc)
|
||||
}
|
||||
}
|
||||
return status.Errorf(status.PreconditionFailed, "registration was deleted")
|
||||
})
|
||||
}()
|
||||
go func() {
|
||||
<-start
|
||||
deleted <- store.DeleteCustomDomain(ctx, "owner", d.ID)
|
||||
}()
|
||||
close(start)
|
||||
createErr, deleteErr := <-created, <-deleted
|
||||
require.True(t, createErr == nil || deleteErr == nil, "one operation must succeed: create=%v, delete=%v", createErr, deleteErr)
|
||||
if createErr == nil {
|
||||
require.Error(t, deleteErr, "a committed service must block deletion")
|
||||
stored, err := store.GetCustomDomain(ctx, "owner", d.ID)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, stored.Validated, "the service must retain its authorization")
|
||||
require.NoError(t, store.DeleteService(ctx, "owner", svc.ID))
|
||||
require.NoError(t, store.DeleteCustomDomain(ctx, "owner", d.ID))
|
||||
continue
|
||||
}
|
||||
require.NoError(t, deleteErr)
|
||||
services, err := store.GetAccountServices(ctx, LockingStrengthNone, "owner")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, services, "a deleted registration must not leave a new service")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/zones/records"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
func (s *SqlStore) CreateDNSRecord(ctx context.Context, record *records.Record) error {
|
||||
result := s.db.Create(record)
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to create dns record to store: %v", result.Error)
|
||||
return status.Errorf(status.Internal, "failed to create dns record to store")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SqlStore) UpdateDNSRecord(ctx context.Context, record *records.Record) error {
|
||||
result := s.db.Select("*").Save(record)
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to update dns record to store: %v", result.Error)
|
||||
return status.Errorf(status.Internal, "failed to update dns record to store")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SqlStore) DeleteDNSRecord(ctx context.Context, accountID, zoneID, recordID string) error {
|
||||
result := s.db.Delete(&records.Record{}, "account_id = ? AND zone_id = ? AND id = ?", accountID, zoneID, recordID)
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to delete dns record from store: %v", result.Error)
|
||||
return status.Errorf(status.Internal, "failed to delete dns record from store")
|
||||
}
|
||||
|
||||
if result.RowsAffected == 0 {
|
||||
return status.NewDNSRecordNotFoundError(recordID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SqlStore) GetDNSRecordByID(ctx context.Context, lockStrength LockingStrength, accountID, zoneID, recordID string) (*records.Record, error) {
|
||||
tx := s.db
|
||||
if lockStrength != LockingStrengthNone {
|
||||
tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)})
|
||||
}
|
||||
|
||||
var record *records.Record
|
||||
result := tx.Where("account_id = ? AND zone_id = ? AND id = ?", accountID, zoneID, recordID).Take(&record)
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, status.NewDNSRecordNotFoundError(recordID)
|
||||
}
|
||||
|
||||
log.WithContext(ctx).Errorf("failed to get dns record from store: %v", result.Error)
|
||||
return nil, status.Errorf(status.Internal, "failed to get dns record from store")
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (s *SqlStore) GetZoneDNSRecords(ctx context.Context, lockStrength LockingStrength, accountID, zoneID string) ([]*records.Record, error) {
|
||||
tx := s.db
|
||||
if lockStrength != LockingStrengthNone {
|
||||
tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)})
|
||||
}
|
||||
|
||||
var recordsList []*records.Record
|
||||
result := tx.Where("account_id = ? AND zone_id = ?", accountID, zoneID).Find(&recordsList)
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to get zone dns records from the store: %s", result.Error)
|
||||
return nil, status.Errorf(status.Internal, "failed to get zone dns records from store")
|
||||
}
|
||||
|
||||
return recordsList, nil
|
||||
}
|
||||
|
||||
func (s *SqlStore) GetZoneDNSRecordsByName(ctx context.Context, lockStrength LockingStrength, accountID, zoneID, name string) ([]*records.Record, error) {
|
||||
tx := s.db
|
||||
if lockStrength != LockingStrengthNone {
|
||||
tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)})
|
||||
}
|
||||
|
||||
var recordsList []*records.Record
|
||||
result := tx.Where("account_id = ? AND zone_id = ? AND name = ?", accountID, zoneID, name).Find(&recordsList)
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to get zone dns records by name from the store: %s", result.Error)
|
||||
return nil, status.Errorf(status.Internal, "failed to get zone dns records by name from store")
|
||||
}
|
||||
|
||||
return recordsList, nil
|
||||
}
|
||||
|
||||
func (s *SqlStore) DeleteZoneDNSRecords(ctx context.Context, accountID, zoneID string) error {
|
||||
result := s.db.Delete(&records.Record{}, "account_id = ? AND zone_id = ?", accountID, zoneID)
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to delete zone dns records from store: %v", result.Error)
|
||||
return status.Errorf(status.Internal, "failed to delete zone dns records from store")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/zones"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/zones/records"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
func TestSqlStore_CreateDNSRecord(t *testing.T) {
|
||||
store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir())
|
||||
t.Cleanup(cleanup)
|
||||
require.NoError(t, err)
|
||||
|
||||
accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
|
||||
|
||||
zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"})
|
||||
err = store.CreateZone(context.Background(), zone)
|
||||
require.NoError(t, err)
|
||||
|
||||
record := records.NewRecord(accountID, zone.ID, "www.example.com", records.RecordTypeA, "192.168.1.1", 300)
|
||||
|
||||
err = store.CreateDNSRecord(context.Background(), record)
|
||||
require.NoError(t, err)
|
||||
|
||||
savedRecord, err := store.GetDNSRecordByID(context.Background(), LockingStrengthNone, accountID, zone.ID, record.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, savedRecord)
|
||||
assert.Equal(t, record.ID, savedRecord.ID)
|
||||
assert.Equal(t, record.Name, savedRecord.Name)
|
||||
assert.Equal(t, record.Type, savedRecord.Type)
|
||||
assert.Equal(t, record.Content, savedRecord.Content)
|
||||
assert.Equal(t, record.TTL, savedRecord.TTL)
|
||||
assert.Equal(t, zone.ID, savedRecord.ZoneID)
|
||||
}
|
||||
|
||||
func TestSqlStore_GetDNSRecordByID(t *testing.T) {
|
||||
store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir())
|
||||
t.Cleanup(cleanup)
|
||||
require.NoError(t, err)
|
||||
|
||||
accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
|
||||
|
||||
zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"})
|
||||
err = store.CreateZone(context.Background(), zone)
|
||||
require.NoError(t, err)
|
||||
|
||||
record := records.NewRecord(accountID, zone.ID, "www.example.com", records.RecordTypeA, "192.168.1.1", 300)
|
||||
err = store.CreateDNSRecord(context.Background(), record)
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
accountID string
|
||||
zoneID string
|
||||
recordID string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "retrieve existing record",
|
||||
accountID: accountID,
|
||||
zoneID: zone.ID,
|
||||
recordID: record.ID,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "retrieve non-existing record",
|
||||
accountID: accountID,
|
||||
zoneID: zone.ID,
|
||||
recordID: "non-existing",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "retrieve with empty record ID",
|
||||
accountID: accountID,
|
||||
zoneID: zone.ID,
|
||||
recordID: "",
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
savedRecord, err := store.GetDNSRecordByID(context.Background(), LockingStrengthNone, tt.accountID, tt.zoneID, tt.recordID)
|
||||
if tt.expectError {
|
||||
require.Error(t, err)
|
||||
sErr, ok := status.FromError(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, sErr.Type(), status.NotFound)
|
||||
require.Nil(t, savedRecord)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, savedRecord)
|
||||
assert.Equal(t, tt.recordID, savedRecord.ID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSqlStore_GetZoneDNSRecords(t *testing.T) {
|
||||
store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir())
|
||||
t.Cleanup(cleanup)
|
||||
require.NoError(t, err)
|
||||
|
||||
accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
|
||||
|
||||
zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"})
|
||||
err = store.CreateZone(context.Background(), zone)
|
||||
require.NoError(t, err)
|
||||
|
||||
recordA := records.NewRecord(accountID, zone.ID, "www.example.com", records.RecordTypeA, "192.168.1.1", 300)
|
||||
err = store.CreateDNSRecord(context.Background(), recordA)
|
||||
require.NoError(t, err)
|
||||
|
||||
recordAAAA := records.NewRecord(accountID, zone.ID, "ipv6.example.com", records.RecordTypeAAAA, "2001:db8::1", 300)
|
||||
err = store.CreateDNSRecord(context.Background(), recordAAAA)
|
||||
require.NoError(t, err)
|
||||
|
||||
recordCNAME := records.NewRecord(accountID, zone.ID, "alias.example.com", records.RecordTypeCNAME, "www.example.com", 300)
|
||||
err = store.CreateDNSRecord(context.Background(), recordCNAME)
|
||||
require.NoError(t, err)
|
||||
|
||||
allRecords, err := store.GetZoneDNSRecords(context.Background(), LockingStrengthNone, accountID, zone.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, allRecords)
|
||||
assert.Equal(t, 3, len(allRecords))
|
||||
|
||||
recordIDs := make(map[string]bool)
|
||||
for _, r := range allRecords {
|
||||
recordIDs[r.ID] = true
|
||||
}
|
||||
assert.True(t, recordIDs[recordA.ID])
|
||||
assert.True(t, recordIDs[recordAAAA.ID])
|
||||
assert.True(t, recordIDs[recordCNAME.ID])
|
||||
}
|
||||
|
||||
func TestSqlStore_GetZoneDNSRecordsByName(t *testing.T) {
|
||||
store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir())
|
||||
t.Cleanup(cleanup)
|
||||
require.NoError(t, err)
|
||||
|
||||
accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
|
||||
|
||||
zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"})
|
||||
err = store.CreateZone(context.Background(), zone)
|
||||
require.NoError(t, err)
|
||||
|
||||
record1 := records.NewRecord(accountID, zone.ID, "www.example.com", records.RecordTypeA, "192.168.1.1", 300)
|
||||
err = store.CreateDNSRecord(context.Background(), record1)
|
||||
require.NoError(t, err)
|
||||
|
||||
record2 := records.NewRecord(accountID, zone.ID, "www.example.com", records.RecordTypeAAAA, "2001:db8::1", 300)
|
||||
err = store.CreateDNSRecord(context.Background(), record2)
|
||||
require.NoError(t, err)
|
||||
|
||||
record3 := records.NewRecord(accountID, zone.ID, "mail.example.com", records.RecordTypeA, "192.168.1.2", 600)
|
||||
err = store.CreateDNSRecord(context.Background(), record3)
|
||||
require.NoError(t, err)
|
||||
|
||||
recordsByName, err := store.GetZoneDNSRecordsByName(context.Background(), LockingStrengthNone, accountID, zone.ID, "www.example.com")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, recordsByName)
|
||||
assert.Equal(t, 2, len(recordsByName))
|
||||
|
||||
for _, r := range recordsByName {
|
||||
assert.Equal(t, "www.example.com", r.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSqlStore_UpdateDNSRecord(t *testing.T) {
|
||||
store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir())
|
||||
t.Cleanup(cleanup)
|
||||
require.NoError(t, err)
|
||||
|
||||
accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
|
||||
|
||||
zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"})
|
||||
err = store.CreateZone(context.Background(), zone)
|
||||
require.NoError(t, err)
|
||||
|
||||
record := records.NewRecord(accountID, zone.ID, "www.example.com", records.RecordTypeA, "192.168.1.1", 300)
|
||||
err = store.CreateDNSRecord(context.Background(), record)
|
||||
require.NoError(t, err)
|
||||
|
||||
record.Name = "api.example.com"
|
||||
record.Content = "192.168.1.100"
|
||||
record.TTL = 600
|
||||
|
||||
err = store.UpdateDNSRecord(context.Background(), record)
|
||||
require.NoError(t, err)
|
||||
|
||||
updatedRecord, err := store.GetDNSRecordByID(context.Background(), LockingStrengthNone, accountID, zone.ID, record.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, updatedRecord)
|
||||
assert.Equal(t, "api.example.com", updatedRecord.Name)
|
||||
assert.Equal(t, "192.168.1.100", updatedRecord.Content)
|
||||
assert.Equal(t, 600, updatedRecord.TTL)
|
||||
}
|
||||
|
||||
func TestSqlStore_DeleteDNSRecord(t *testing.T) {
|
||||
store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir())
|
||||
t.Cleanup(cleanup)
|
||||
require.NoError(t, err)
|
||||
|
||||
accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
|
||||
|
||||
zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"})
|
||||
err = store.CreateZone(context.Background(), zone)
|
||||
require.NoError(t, err)
|
||||
|
||||
record := records.NewRecord(accountID, zone.ID, "www.example.com", records.RecordTypeA, "192.168.1.1", 300)
|
||||
err = store.CreateDNSRecord(context.Background(), record)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = store.DeleteDNSRecord(context.Background(), accountID, zone.ID, record.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
deletedRecord, err := store.GetDNSRecordByID(context.Background(), LockingStrengthNone, accountID, zone.ID, record.ID)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, deletedRecord)
|
||||
sErr, ok := status.FromError(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, sErr.Type(), status.NotFound)
|
||||
}
|
||||
|
||||
func TestSqlStore_DeleteZoneDNSRecords(t *testing.T) {
|
||||
store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir())
|
||||
t.Cleanup(cleanup)
|
||||
require.NoError(t, err)
|
||||
|
||||
accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
|
||||
|
||||
zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"})
|
||||
err = store.CreateZone(context.Background(), zone)
|
||||
require.NoError(t, err)
|
||||
|
||||
record1 := records.NewRecord(accountID, zone.ID, "www.example.com", records.RecordTypeA, "192.168.1.1", 300)
|
||||
err = store.CreateDNSRecord(context.Background(), record1)
|
||||
require.NoError(t, err)
|
||||
|
||||
record2 := records.NewRecord(accountID, zone.ID, "mail.example.com", records.RecordTypeA, "192.168.1.2", 600)
|
||||
err = store.CreateDNSRecord(context.Background(), record2)
|
||||
require.NoError(t, err)
|
||||
|
||||
allRecords, err := store.GetZoneDNSRecords(context.Background(), LockingStrengthNone, accountID, zone.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, len(allRecords))
|
||||
|
||||
err = store.DeleteZoneDNSRecords(context.Background(), accountID, zone.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
remainingRecords, err := store.GetZoneDNSRecords(context.Background(), LockingStrengthNone, accountID, zone.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, len(remainingRecords))
|
||||
}
|
||||
@@ -53,7 +53,10 @@ func customDomainServices(db *gorm.DB, d *domain.Domain) *gorm.DB {
|
||||
// Shared domain validation permits underscores, and older rows may contain
|
||||
// other LIKE metacharacters.
|
||||
escaped := strings.NewReplacer("!", "!!", "%", "!%", "_", "!_").Replace(name)
|
||||
return db.Model(&rpservice.Service{}).Where(
|
||||
// Registrations are unique by name, so another account can hold a subdomain
|
||||
// of this one and serve from it. Its services derive their cluster from that
|
||||
// account's own registration and are not dependents of this one.
|
||||
return db.Model(&rpservice.Service{}).Where(accountIDCondition, d.AccountID).Where(
|
||||
"LOWER(domain) IN ? OR LOWER(domain) LIKE ? ESCAPE '!' OR LOWER(domain) LIKE ? ESCAPE '!'",
|
||||
[]string{name, name + "."}, "%."+escaped, "%."+escaped+".",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
// CreateGroups creates the given list of groups to the database.
|
||||
// groupUpsertColumns is the explicit allowlist of columns that get updated when
|
||||
// CreateGroups / UpdateGroups hit a PK conflict. public_id is intentionally
|
||||
// omitted so a caller passing an entity with the zero value (e.g. an HTTP
|
||||
// handler-built struct) cannot reset the persisted public_id during an upsert.
|
||||
// Keep this in sync with the Group schema in management/server/types/group.go.
|
||||
func groupUpsertColumns() clause.Set {
|
||||
return clause.AssignmentColumns([]string{
|
||||
"account_id",
|
||||
"name",
|
||||
"issued",
|
||||
"integration_ref_id",
|
||||
"integration_ref_integration_type",
|
||||
"resources",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *SqlStore) CreateGroups(ctx context.Context, accountID string, groups []*types.Group) error {
|
||||
if len(groups) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return s.transaction(ctx, func(tx *gorm.DB) error {
|
||||
result := tx.
|
||||
Clauses(
|
||||
clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "id"}},
|
||||
Where: clause.Where{Exprs: []clause.Expression{clause.Eq{Column: "groups.account_id", Value: accountID}}},
|
||||
DoUpdates: groupUpsertColumns(),
|
||||
},
|
||||
).
|
||||
Omit(clause.Associations).
|
||||
Create(&groups)
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to save groups to store: %v", result.Error)
|
||||
return status.Errorf(status.Internal, "failed to save groups to store")
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateGroups updates the given list of groups to the database.
|
||||
func (s *SqlStore) UpdateGroups(ctx context.Context, accountID string, groups []*types.Group) error {
|
||||
if len(groups) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return s.transaction(ctx, func(tx *gorm.DB) error {
|
||||
result := tx.
|
||||
Clauses(
|
||||
clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "id"}},
|
||||
Where: clause.Where{Exprs: []clause.Expression{clause.Eq{Column: "groups.account_id", Value: accountID}}},
|
||||
DoUpdates: groupUpsertColumns(),
|
||||
},
|
||||
).
|
||||
Omit(clause.Associations).
|
||||
Create(&groups)
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to save groups to store: %v", result.Error)
|
||||
return status.Errorf(status.Internal, "failed to save groups to store")
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *SqlStore) GetAccountGroups(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types.Group, error) {
|
||||
tx := s.db
|
||||
if lockStrength != LockingStrengthNone {
|
||||
tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)})
|
||||
}
|
||||
|
||||
var groups []*types.Group
|
||||
result := tx.Preload(clause.Associations).Find(&groups, accountIDCondition, accountID)
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, status.Errorf(status.NotFound, "accountID not found: index lookup failed")
|
||||
}
|
||||
log.WithContext(ctx).Errorf("failed to get account groups from the store: %s", result.Error)
|
||||
return nil, status.Errorf(status.Internal, "failed to get account groups from the store")
|
||||
}
|
||||
|
||||
for _, g := range groups {
|
||||
g.LoadGroupPeers()
|
||||
}
|
||||
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
func (s *SqlStore) GetResourceGroups(ctx context.Context, lockStrength LockingStrength, accountID, resourceID string) ([]*types.Group, error) {
|
||||
tx := s.db
|
||||
if lockStrength != LockingStrengthNone {
|
||||
tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)})
|
||||
}
|
||||
|
||||
var groups []*types.Group
|
||||
|
||||
likePattern := `%"ID":"` + resourceID + `"%`
|
||||
|
||||
result := tx.
|
||||
Preload(clause.Associations).
|
||||
Where("resources LIKE ?", likePattern).
|
||||
Find(&groups)
|
||||
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, result.Error
|
||||
}
|
||||
|
||||
for _, g := range groups {
|
||||
g.LoadGroupPeers()
|
||||
}
|
||||
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
func (s *SqlStore) getGroups(ctx context.Context, accountID string) ([]*types.Group, error) {
|
||||
const query = `SELECT id, account_id, public_id, name, issued, resources, integration_ref_id, integration_ref_integration_type FROM groups WHERE account_id = $1`
|
||||
rows, err := s.pgxPool().Query(ctx, query, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groups, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (*types.Group, error) {
|
||||
var g types.Group
|
||||
var resources []byte
|
||||
var refID sql.NullInt64
|
||||
var refType sql.NullString
|
||||
err := row.Scan(&g.ID, &g.AccountID, &g.PublicID, &g.Name, &g.Issued, &resources, &refID, &refType)
|
||||
if err == nil {
|
||||
if refID.Valid {
|
||||
g.IntegrationReference.ID = int(refID.Int64)
|
||||
}
|
||||
if refType.Valid {
|
||||
g.IntegrationReference.IntegrationType = refType.String
|
||||
}
|
||||
if resources != nil {
|
||||
_ = json.Unmarshal(resources, &g.Resources)
|
||||
} else {
|
||||
g.Resources = []types.Resource{}
|
||||
}
|
||||
g.GroupPeers = []types.GroupPeer{}
|
||||
g.Peers = []string{}
|
||||
}
|
||||
return &g, err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
// AddResourceToGroup adds a resource to a group. Method always needs to run n a transaction
|
||||
func (s *SqlStore) AddResourceToGroup(ctx context.Context, accountId string, groupID string, resource *types.Resource) error {
|
||||
var group types.Group
|
||||
result := s.db.Where(accountAndIDQueryCondition, accountId, groupID).Take(&group)
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return status.NewGroupNotFoundError(groupID)
|
||||
}
|
||||
|
||||
return status.Errorf(status.Internal, "issue finding group: %s", result.Error)
|
||||
}
|
||||
|
||||
for _, res := range group.Resources {
|
||||
if res.ID == resource.ID {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
group.Resources = append(group.Resources, *resource)
|
||||
|
||||
if err := s.db.Save(&group).Error; err != nil {
|
||||
return status.Errorf(status.Internal, "issue updating group: %s", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveResourceFromGroup removes a resource from a group. Method always needs to run in a transaction
|
||||
func (s *SqlStore) RemoveResourceFromGroup(ctx context.Context, accountId string, groupID string, resourceID string) error {
|
||||
var group types.Group
|
||||
result := s.db.Where(accountAndIDQueryCondition, accountId, groupID).Take(&group)
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return status.NewGroupNotFoundError(groupID)
|
||||
}
|
||||
|
||||
return status.Errorf(status.Internal, "issue finding group: %s", result.Error)
|
||||
}
|
||||
|
||||
for i, res := range group.Resources {
|
||||
if res.ID == resourceID {
|
||||
group.Resources = append(group.Resources[:i], group.Resources[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.db.Save(&group).Error; err != nil {
|
||||
return status.Errorf(status.Internal, "issue updating group: %s", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetGroupByID retrieves a group by ID and account ID.
|
||||
func (s *SqlStore) GetGroupByID(ctx context.Context, lockStrength LockingStrength, accountID, groupID string) (*types.Group, error) {
|
||||
tx := s.db
|
||||
if lockStrength != LockingStrengthNone {
|
||||
tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)})
|
||||
}
|
||||
|
||||
var group *types.Group
|
||||
result := tx.Preload(clause.Associations).Take(&group, accountAndIDQueryCondition, accountID, groupID)
|
||||
if err := result.Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, status.NewGroupNotFoundError(groupID)
|
||||
}
|
||||
log.WithContext(ctx).Errorf("failed to get group from store: %s", err)
|
||||
return nil, status.Errorf(status.Internal, "failed to get group from store")
|
||||
}
|
||||
|
||||
group.LoadGroupPeers()
|
||||
|
||||
return group, nil
|
||||
}
|
||||
|
||||
// GetGroupByName retrieves a group by name and account ID.
|
||||
func (s *SqlStore) GetGroupByName(ctx context.Context, lockStrength LockingStrength, accountID, groupName string) (*types.Group, error) {
|
||||
tx := s.db
|
||||
|
||||
var group types.Group
|
||||
|
||||
// TODO: This fix is accepted for now, but if we need to handle this more frequently
|
||||
// we may need to reconsider changing the types.
|
||||
query := tx.Preload(clause.Associations)
|
||||
|
||||
result := query.
|
||||
Model(&types.Group{}).
|
||||
Joins("LEFT JOIN group_peers ON group_peers.group_id = groups.id").
|
||||
Where("groups.account_id = ? AND groups.name = ?", accountID, groupName).
|
||||
Group("groups.id").
|
||||
Order("COUNT(group_peers.peer_id) DESC").
|
||||
Limit(1).
|
||||
First(&group)
|
||||
if err := result.Error; err != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, status.NewGroupNotFoundError(groupName)
|
||||
}
|
||||
log.WithContext(ctx).Errorf("failed to get group by name from store: %v", result.Error)
|
||||
return nil, status.Errorf(status.Internal, "failed to get group by name from store")
|
||||
}
|
||||
|
||||
group.LoadGroupPeers()
|
||||
|
||||
return &group, nil
|
||||
}
|
||||
|
||||
// GetGroupsByIDs retrieves groups by their IDs and account ID.
|
||||
func (s *SqlStore) GetGroupsByIDs(ctx context.Context, lockStrength LockingStrength, accountID string, groupIDs []string) (map[string]*types.Group, error) {
|
||||
tx := s.db
|
||||
if lockStrength != LockingStrengthNone {
|
||||
tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)})
|
||||
}
|
||||
|
||||
var groups []*types.Group
|
||||
result := tx.Preload(clause.Associations).Find(&groups, accountAndIDsQueryCondition, accountID, groupIDs)
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to get groups by ID's from store: %s", result.Error)
|
||||
return nil, status.Errorf(status.Internal, "failed to get groups by ID's from store")
|
||||
}
|
||||
|
||||
groupsMap := make(map[string]*types.Group)
|
||||
for _, group := range groups {
|
||||
group.LoadGroupPeers()
|
||||
groupsMap[group.ID] = group
|
||||
}
|
||||
|
||||
return groupsMap, nil
|
||||
}
|
||||
|
||||
// CreateGroup creates a group in the store.
|
||||
func (s *SqlStore) CreateGroup(ctx context.Context, group *types.Group) error {
|
||||
if group == nil {
|
||||
return status.Errorf(status.InvalidArgument, "group is nil")
|
||||
}
|
||||
|
||||
if err := s.db.Omit(clause.Associations).Create(group).Error; err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to save group to store: %v", err)
|
||||
return status.Errorf(status.Internal, "failed to save group to store")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateGroup updates a group in the store.
|
||||
func (s *SqlStore) UpdateGroup(ctx context.Context, group *types.Group) error {
|
||||
if group == nil {
|
||||
return status.Errorf(status.InvalidArgument, "group is nil")
|
||||
}
|
||||
|
||||
if err := s.db.Omit(clause.Associations, "public_id").Save(group).Error; err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to save group to store: %v", err)
|
||||
return status.Errorf(status.Internal, "failed to save group to store")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteGroup deletes a group from the database.
|
||||
func (s *SqlStore) DeleteGroup(ctx context.Context, accountID, groupID string) error {
|
||||
result := s.db.Select(clause.Associations).
|
||||
Delete(&types.Group{}, accountAndIDQueryCondition, accountID, groupID)
|
||||
if err := result.Error; err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to delete group from store: %s", result.Error)
|
||||
return status.Errorf(status.Internal, "failed to delete group from store")
|
||||
}
|
||||
|
||||
if result.RowsAffected == 0 {
|
||||
return status.NewGroupNotFoundError(groupID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteGroups deletes groups from the database.
|
||||
func (s *SqlStore) DeleteGroups(ctx context.Context, accountID string, groupIDs []string) error {
|
||||
result := s.db.Select(clause.Associations).
|
||||
Delete(&types.Group{}, accountAndIDsQueryCondition, accountID, groupIDs)
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to delete groups from store: %v", result.Error)
|
||||
return status.Errorf(status.Internal, "failed to delete groups from store")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
func (s *SqlStore) getGroupPeers(ctx context.Context, groupIDs []string) ([]types.GroupPeer, error) {
|
||||
if len(groupIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
const query = `SELECT account_id, group_id, peer_id FROM group_peers WHERE group_id = ANY($1)`
|
||||
rows, err := s.pgxPool().Query(ctx, query, groupIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groupPeers, err := pgx.CollectRows(rows, pgx.RowToStructByName[types.GroupPeer])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return groupPeers, nil
|
||||
}
|
||||
|
||||
// AddPeerToAllGroup adds a peer to the 'All' group. Method always needs to run in a transaction
|
||||
func (s *SqlStore) AddPeerToAllGroup(ctx context.Context, accountID string, peerID string) error {
|
||||
var groupID string
|
||||
_ = s.db.Model(types.Group{}).
|
||||
Select("id").
|
||||
Where("account_id = ? AND name = ?", accountID, "All").
|
||||
Limit(1).
|
||||
Scan(&groupID)
|
||||
|
||||
if groupID == "" {
|
||||
return status.Errorf(status.NotFound, "group 'All' not found for account %s", accountID)
|
||||
}
|
||||
|
||||
err := s.db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "group_id"}, {Name: "peer_id"}},
|
||||
DoNothing: true,
|
||||
}).Create(&types.GroupPeer{
|
||||
AccountID: accountID,
|
||||
GroupID: groupID,
|
||||
PeerID: peerID,
|
||||
}).Error
|
||||
if err != nil {
|
||||
return status.Errorf(status.Internal, "error adding peer to group 'All': %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddPeerToGroup adds a peer to a group
|
||||
func (s *SqlStore) AddPeerToGroup(ctx context.Context, accountID, peerID, groupID string) error {
|
||||
peer := &types.GroupPeer{
|
||||
AccountID: accountID,
|
||||
GroupID: groupID,
|
||||
PeerID: peerID,
|
||||
}
|
||||
|
||||
err := s.db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "group_id"}, {Name: "peer_id"}},
|
||||
DoNothing: true,
|
||||
}).Create(peer).Error
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to add peer %s to group %s for account %s: %v", peerID, groupID, accountID, err)
|
||||
return status.Errorf(status.Internal, "failed to add peer to group")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemovePeerFromGroup removes a peer from a group
|
||||
func (s *SqlStore) RemovePeerFromGroup(ctx context.Context, peerID string, groupID string) error {
|
||||
err := s.db.
|
||||
Delete(&types.GroupPeer{}, "group_id = ? AND peer_id = ?", groupID, peerID).Error
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to remove peer %s from group %s: %v", peerID, groupID, err)
|
||||
return status.Errorf(status.Internal, "failed to remove peer from group")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemovePeerFromAllGroups removes a peer from all groups
|
||||
func (s *SqlStore) RemovePeerFromAllGroups(ctx context.Context, peerID string) error {
|
||||
err := s.db.
|
||||
Delete(&types.GroupPeer{}, "peer_id = ?", peerID).Error
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to remove peer %s from all groups: %v", peerID, err)
|
||||
return status.Errorf(status.Internal, "failed to remove peer from all groups")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPeerGroups retrieves all groups assigned to a specific peer in a given account.
|
||||
func (s *SqlStore) GetPeerGroups(ctx context.Context, lockStrength LockingStrength, accountId string, peerId string) ([]*types.Group, error) {
|
||||
tx := s.db
|
||||
if lockStrength != LockingStrengthNone {
|
||||
tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)})
|
||||
}
|
||||
|
||||
var groups []*types.Group
|
||||
query := tx.
|
||||
Joins("JOIN group_peers ON group_peers.group_id = groups.id").
|
||||
Where("groups.account_id = ? AND group_peers.peer_id = ?", accountId, peerId).
|
||||
Preload(clause.Associations).
|
||||
Find(&groups)
|
||||
|
||||
if query.Error != nil {
|
||||
return nil, query.Error
|
||||
}
|
||||
|
||||
for _, group := range groups {
|
||||
group.LoadGroupPeers()
|
||||
}
|
||||
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
// GetPeerGroupIDs retrieves all group IDs assigned to a specific peer in a given account.
|
||||
func (s *SqlStore) GetPeerGroupIDs(ctx context.Context, lockStrength LockingStrength, accountId string, peerId string) ([]string, error) {
|
||||
tx := s.db
|
||||
if lockStrength != LockingStrengthNone {
|
||||
tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)})
|
||||
}
|
||||
|
||||
var groupIDs []string
|
||||
query := tx.
|
||||
Model(&types.GroupPeer{}).
|
||||
Where("account_id = ? AND peer_id = ?", accountId, peerId).
|
||||
Pluck("group_id", &groupIDs)
|
||||
|
||||
if query.Error != nil {
|
||||
if errors.Is(query.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, status.Errorf(status.NotFound, "no groups found for peer %s in account %s", peerId, accountId)
|
||||
}
|
||||
log.WithContext(ctx).Errorf("failed to get group IDs for peer %s in account %s: %v", peerId, accountId, query.Error)
|
||||
return nil, status.Errorf(status.Internal, "failed to get group IDs for peer from store")
|
||||
}
|
||||
|
||||
return groupIDs, nil
|
||||
}
|
||||
|
||||
func (s *SqlStore) GetAccountGroupPeers(ctx context.Context, lockStrength LockingStrength, accountID string) (map[string]map[string]struct{}, error) {
|
||||
tx := s.db
|
||||
if lockStrength != LockingStrengthNone {
|
||||
tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)})
|
||||
}
|
||||
|
||||
var peers []types.GroupPeer
|
||||
result := tx.Find(&peers, accountIDCondition, accountID)
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to get account group peers from store: %s", result.Error)
|
||||
return nil, status.Errorf(status.Internal, "failed to get account group peers from store")
|
||||
}
|
||||
|
||||
groupPeers := make(map[string]map[string]struct{})
|
||||
for _, peer := range peers {
|
||||
if _, exists := groupPeers[peer.GroupID]; !exists {
|
||||
groupPeers[peer.GroupID] = make(map[string]struct{})
|
||||
}
|
||||
groupPeers[peer.GroupID][peer.PeerID] = struct{}{}
|
||||
}
|
||||
|
||||
return groupPeers, nil
|
||||
}
|
||||
|
||||
func (s *SqlStore) GetPeersByGroupIDs(ctx context.Context, accountID string, groupIDs []string) ([]*nbpeer.Peer, error) {
|
||||
if len(groupIDs) == 0 {
|
||||
return []*nbpeer.Peer{}, nil
|
||||
}
|
||||
|
||||
var peers []*nbpeer.Peer
|
||||
peerIDsSubquery := s.db.Model(&types.GroupPeer{}).
|
||||
Select("DISTINCT peer_id").
|
||||
Where("account_id = ? AND group_id IN ?", accountID, groupIDs)
|
||||
|
||||
result := s.db.Where("account_id = ? AND id IN (?)", accountID, peerIDsSubquery).Find(&peers)
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to get peers by group IDs: %s", result.Error)
|
||||
return nil, status.Errorf(status.Internal, "failed to get peers by group IDs")
|
||||
}
|
||||
|
||||
return peers, nil
|
||||
}
|
||||
|
||||
func (s *SqlStore) GetPeerIDsByGroups(ctx context.Context, accountID string, groupIDs []string) ([]string, error) {
|
||||
if len(groupIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var peerIDs []string
|
||||
result := s.db.Model(&types.GroupPeer{}).
|
||||
Select("DISTINCT peer_id").
|
||||
Where("account_id = ? AND group_id IN ?", accountID, groupIDs).
|
||||
Pluck("peer_id", &peerIDs)
|
||||
if result.Error != nil {
|
||||
return nil, status.Errorf(status.Internal, "failed to get peer IDs by groups: %s", result.Error)
|
||||
}
|
||||
|
||||
return peerIDs, nil
|
||||
}
|
||||
|
||||
func (s *SqlStore) GetGroupIDsByPeerIDs(ctx context.Context, accountID string, peerIDs []string) ([]string, error) {
|
||||
if len(peerIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var groupIDs []string
|
||||
result := s.db.Model(&types.GroupPeer{}).
|
||||
Select("DISTINCT group_id").
|
||||
Where("account_id = ? AND peer_id IN ?", accountID, peerIDs).
|
||||
Pluck("group_id", &groupIDs)
|
||||
if result.Error != nil {
|
||||
return nil, status.Errorf(status.Internal, "failed to get group IDs by peers: %s", result.Error)
|
||||
}
|
||||
|
||||
return groupIDs, nil
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
)
|
||||
|
||||
func TestSqlStore_AddPeerToGroup(t *testing.T) {
|
||||
store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store_policy_migrate.sql", t.TempDir())
|
||||
t.Cleanup(cleanup)
|
||||
require.NoError(t, err)
|
||||
|
||||
accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
|
||||
peerID := "cfefqs706sqkneg59g4g"
|
||||
groupID := "cfefqs706sqkneg59g4h"
|
||||
|
||||
group, err := store.GetGroupByID(context.Background(), LockingStrengthNone, accountID, groupID)
|
||||
require.NoError(t, err, "failed to get group")
|
||||
require.Len(t, group.Peers, 0, "group should have 0 peers")
|
||||
|
||||
err = store.AddPeerToGroup(context.Background(), accountID, peerID, groupID)
|
||||
require.NoError(t, err, "failed to add peer to group")
|
||||
|
||||
group, err = store.GetGroupByID(context.Background(), LockingStrengthNone, accountID, groupID)
|
||||
require.NoError(t, err, "failed to get group")
|
||||
require.Len(t, group.Peers, 1, "group should have 1 peers")
|
||||
require.Contains(t, group.Peers, peerID)
|
||||
}
|
||||
|
||||
func TestSqlStore_AddPeerToAllGroup(t *testing.T) {
|
||||
store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store_policy_migrate.sql", t.TempDir())
|
||||
t.Cleanup(cleanup)
|
||||
require.NoError(t, err)
|
||||
|
||||
accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
|
||||
groupID := "cfefqs706sqkneg59g3g"
|
||||
|
||||
peer := &nbpeer.Peer{
|
||||
ID: "peer1",
|
||||
AccountID: accountID,
|
||||
DNSLabel: "peer1.domain.test",
|
||||
}
|
||||
|
||||
group, err := store.GetGroupByID(context.Background(), LockingStrengthNone, accountID, groupID)
|
||||
require.NoError(t, err, "failed to get group")
|
||||
require.Len(t, group.Peers, 2, "group should have 2 peers")
|
||||
require.NotContains(t, group.Peers, peer.ID)
|
||||
|
||||
err = store.AddPeerToAccount(context.Background(), peer)
|
||||
require.NoError(t, err, "failed to add peer to account")
|
||||
|
||||
err = store.AddPeerToAllGroup(context.Background(), accountID, peer.ID)
|
||||
require.NoError(t, err, "failed to add peer to all group")
|
||||
|
||||
group, err = store.GetGroupByID(context.Background(), LockingStrengthNone, accountID, groupID)
|
||||
require.NoError(t, err, "failed to get group")
|
||||
require.Len(t, group.Peers, 3, "group should have peers")
|
||||
require.Contains(t, group.Peers, peer.ID)
|
||||
}
|
||||
|
||||
func TestSqlStore_GetPeerGroups(t *testing.T) {
|
||||
store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store_policy_migrate.sql", t.TempDir())
|
||||
t.Cleanup(cleanup)
|
||||
require.NoError(t, err)
|
||||
|
||||
accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
|
||||
peerID := "cfefqs706sqkneg59g4g"
|
||||
|
||||
groups, err := store.GetPeerGroups(context.Background(), LockingStrengthNone, accountID, peerID)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, groups, 1)
|
||||
assert.Equal(t, groups[0].Name, "All")
|
||||
|
||||
err = store.AddPeerToGroup(context.Background(), accountID, peerID, "cfefqs706sqkneg59g4h")
|
||||
require.NoError(t, err)
|
||||
|
||||
groups, err = store.GetPeerGroups(context.Background(), LockingStrengthNone, accountID, peerID)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, groups, 2)
|
||||
|
||||
foreignPeerID := "foreign-peer"
|
||||
err = store.AddPeerToGroup(context.Background(), accountID, foreignPeerID, "cfefqs706sqkneg59g4h")
|
||||
require.NoError(t, err)
|
||||
|
||||
groups, err = store.GetPeerGroups(context.Background(), LockingStrengthNone, "other-account", foreignPeerID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, groups, "groups of another account must not be returned")
|
||||
}
|
||||
|
||||
func TestSqlStore_GetPeersByGroupIDs(t *testing.T) {
|
||||
accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
|
||||
|
||||
group1ID := "test-group-1"
|
||||
group2ID := "test-group-2"
|
||||
emptyGroupID := "empty-group"
|
||||
|
||||
peer1 := "cfefqs706sqkneg59g4g"
|
||||
peer2 := "cfeg6sf06sqkneg59g50"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
groupIDs []string
|
||||
expectedPeers []string
|
||||
expectedCount int
|
||||
}{
|
||||
{
|
||||
name: "retrieve peers from single group with multiple peers",
|
||||
groupIDs: []string{group1ID},
|
||||
expectedPeers: []string{peer1, peer2},
|
||||
expectedCount: 2,
|
||||
},
|
||||
{
|
||||
name: "retrieve peers from single group with one peer",
|
||||
groupIDs: []string{group2ID},
|
||||
expectedPeers: []string{peer1},
|
||||
expectedCount: 1,
|
||||
},
|
||||
{
|
||||
name: "retrieve peers from multiple groups (with overlap)",
|
||||
groupIDs: []string{group1ID, group2ID},
|
||||
expectedPeers: []string{peer1, peer2}, // should deduplicate
|
||||
expectedCount: 2,
|
||||
},
|
||||
{
|
||||
name: "retrieve peers from existing 'All' group",
|
||||
groupIDs: []string{"cfefqs706sqkneg59g3g"}, // All group from test data
|
||||
expectedPeers: []string{peer1, peer2},
|
||||
expectedCount: 2,
|
||||
},
|
||||
{
|
||||
name: "retrieve peers from empty group",
|
||||
groupIDs: []string{emptyGroupID},
|
||||
expectedPeers: []string{},
|
||||
expectedCount: 0,
|
||||
},
|
||||
{
|
||||
name: "retrieve peers from non-existing group",
|
||||
groupIDs: []string{"non-existing-group"},
|
||||
expectedPeers: []string{},
|
||||
expectedCount: 0,
|
||||
},
|
||||
{
|
||||
name: "empty group IDs list",
|
||||
groupIDs: []string{},
|
||||
expectedPeers: []string{},
|
||||
expectedCount: 0,
|
||||
},
|
||||
{
|
||||
name: "mix of existing and non-existing groups",
|
||||
groupIDs: []string{group1ID, "non-existing-group"},
|
||||
expectedPeers: []string{peer1, peer2},
|
||||
expectedCount: 2,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store_policy_migrate.sql", t.TempDir())
|
||||
t.Cleanup(cleanup)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
groups := []*types.Group{
|
||||
{
|
||||
ID: group1ID,
|
||||
AccountID: accountID,
|
||||
},
|
||||
{
|
||||
ID: group2ID,
|
||||
AccountID: accountID,
|
||||
},
|
||||
}
|
||||
require.NoError(t, store.CreateGroups(ctx, accountID, groups))
|
||||
|
||||
otherAccount := newAccountWithId(ctx, "other-account", "other-user", "")
|
||||
require.NoError(t, store.SaveAccount(ctx, otherAccount))
|
||||
foreignPeer := &nbpeer.Peer{ID: "foreign-peer", AccountID: otherAccount.Id}
|
||||
require.NoError(t, store.AddPeerToAccount(ctx, foreignPeer))
|
||||
|
||||
require.NoError(t, store.AddPeerToGroup(ctx, accountID, peer1, group1ID))
|
||||
require.NoError(t, store.AddPeerToGroup(ctx, accountID, peer2, group1ID))
|
||||
require.NoError(t, store.AddPeerToGroup(ctx, accountID, peer1, group2ID))
|
||||
require.NoError(t, store.AddPeerToGroup(ctx, accountID, foreignPeer.ID, group1ID))
|
||||
|
||||
peers, err := store.GetPeersByGroupIDs(ctx, accountID, tt.groupIDs)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, peers, tt.expectedCount)
|
||||
|
||||
if tt.expectedCount > 0 {
|
||||
actualPeerIDs := make([]string, len(peers))
|
||||
for i, peer := range peers {
|
||||
actualPeerIDs[i] = peer.ID
|
||||
}
|
||||
assert.ElementsMatch(t, tt.expectedPeers, actualPeerIDs)
|
||||
|
||||
// Verify all returned peers belong to the correct account
|
||||
for _, peer := range peers {
|
||||
assert.Equal(t, accountID, peer.AccountID)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
func TestSqlite_GetGroupByName(t *testing.T) {
|
||||
store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir())
|
||||
t.Cleanup(cleanup)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
|
||||
|
||||
group, err := store.GetGroupByName(context.Background(), LockingStrengthNone, accountID, "All")
|
||||
require.NoError(t, err)
|
||||
require.True(t, group.IsGroupAll())
|
||||
}
|
||||
|
||||
func TestSqlStore_GetGroupsByIDs(t *testing.T) {
|
||||
store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir())
|
||||
t.Cleanup(cleanup)
|
||||
require.NoError(t, err)
|
||||
|
||||
accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
groupIDs []string
|
||||
expectedCount int
|
||||
}{
|
||||
{
|
||||
name: "retrieve existing groups by existing IDs",
|
||||
groupIDs: []string{"cfefqs706sqkneg59g4g", "cfefqs706sqkneg59g3g"},
|
||||
expectedCount: 2,
|
||||
},
|
||||
{
|
||||
name: "empty group IDs list",
|
||||
groupIDs: []string{},
|
||||
expectedCount: 0,
|
||||
},
|
||||
{
|
||||
name: "non-existing group IDs",
|
||||
groupIDs: []string{"nonexistent1", "nonexistent2"},
|
||||
expectedCount: 0,
|
||||
},
|
||||
{
|
||||
name: "mixed existing and non-existing group IDs",
|
||||
groupIDs: []string{"cfefqs706sqkneg59g4g", "nonexistent"},
|
||||
expectedCount: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
groups, err := store.GetGroupsByIDs(context.Background(), LockingStrengthNone, accountID, tt.groupIDs)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, groups, tt.expectedCount)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSqlStore_CreateGroup(t *testing.T) {
|
||||
if os.Getenv("CI") == "true" {
|
||||
t.Log("Skipping MySQL test on CI")
|
||||
}
|
||||
t.Setenv("NETBIRD_STORE_ENGINE", string(types.MysqlStoreEngine))
|
||||
store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir())
|
||||
t.Cleanup(cleanup)
|
||||
require.NoError(t, err)
|
||||
|
||||
accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
|
||||
|
||||
group := &types.Group{
|
||||
ID: "group-id",
|
||||
AccountID: accountID,
|
||||
Issued: "api",
|
||||
Peers: []string{},
|
||||
Resources: []types.Resource{},
|
||||
GroupPeers: []types.GroupPeer{},
|
||||
}
|
||||
err = store.CreateGroup(context.Background(), group)
|
||||
require.NoError(t, err)
|
||||
|
||||
savedGroup, err := store.GetGroupByID(context.Background(), LockingStrengthNone, accountID, "group-id")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, savedGroup, group)
|
||||
}
|
||||
|
||||
func TestSqlStore_CreateUpdateGroups(t *testing.T) {
|
||||
store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir())
|
||||
t.Cleanup(cleanup)
|
||||
require.NoError(t, err)
|
||||
|
||||
accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
|
||||
|
||||
groups := []*types.Group{
|
||||
{
|
||||
ID: "group-1",
|
||||
AccountID: accountID,
|
||||
Issued: "api",
|
||||
Peers: []string{},
|
||||
Resources: []types.Resource{},
|
||||
GroupPeers: []types.GroupPeer{},
|
||||
},
|
||||
{
|
||||
ID: "group-2",
|
||||
AccountID: accountID,
|
||||
Issued: "integration",
|
||||
Peers: []string{},
|
||||
Resources: []types.Resource{},
|
||||
GroupPeers: []types.GroupPeer{},
|
||||
},
|
||||
}
|
||||
err = store.CreateGroups(context.Background(), accountID, groups)
|
||||
require.NoError(t, err)
|
||||
|
||||
groups[1].Peers = []string{}
|
||||
err = store.UpdateGroups(context.Background(), accountID, groups)
|
||||
require.NoError(t, err)
|
||||
|
||||
group, err := store.GetGroupByID(context.Background(), LockingStrengthNone, accountID, groups[1].ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, groups[1], group)
|
||||
}
|
||||
|
||||
func TestSqlStore_DeleteGroup(t *testing.T) {
|
||||
store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir())
|
||||
t.Cleanup(cleanup)
|
||||
require.NoError(t, err)
|
||||
|
||||
accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
groupID string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "delete existing group",
|
||||
groupID: "cfefqs706sqkneg59g4g",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "delete non-existing group",
|
||||
groupID: "non-existing-group-id",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "delete with empty group ID",
|
||||
groupID: "",
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := store.DeleteGroup(context.Background(), accountID, tt.groupID)
|
||||
if tt.expectError {
|
||||
require.Error(t, err)
|
||||
sErr, ok := status.FromError(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, sErr.Type(), status.NotFound)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
|
||||
group, err := store.GetGroupByID(context.Background(), LockingStrengthNone, accountID, tt.groupID)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, group)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSqlStore_DeleteGroups(t *testing.T) {
|
||||
store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir())
|
||||
t.Cleanup(cleanup)
|
||||
require.NoError(t, err)
|
||||
|
||||
accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
groupIDs []string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "delete multiple existing groups",
|
||||
groupIDs: []string{"cfefqs706sqkneg59g4g", "cfefqs706sqkneg59g3g"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "delete non-existing groups",
|
||||
groupIDs: []string{"non-existing-id-1", "non-existing-id-2"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "delete with empty group IDs list",
|
||||
groupIDs: []string{},
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := store.DeleteGroups(context.Background(), accountID, tt.groupIDs)
|
||||
if tt.expectError {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, groupID := range tt.groupIDs {
|
||||
group, err := store.GetGroupByID(context.Background(), LockingStrengthNone, accountID, groupID)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, group)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSqlStore_AddAndRemoveResourceFromGroup(t *testing.T) {
|
||||
store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
|
||||
resourceId := "ctc4nci7qv9061u6ilfg"
|
||||
groupID := "cs1tnh0hhcjnqoiuebeg"
|
||||
|
||||
res := &types.Resource{
|
||||
ID: resourceId,
|
||||
Type: "host",
|
||||
}
|
||||
err = store.AddResourceToGroup(context.Background(), accountID, groupID, res)
|
||||
require.NoError(t, err)
|
||||
|
||||
group, err := store.GetGroupByID(context.Background(), LockingStrengthNone, accountID, groupID)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, group.Resources, *res)
|
||||
|
||||
groups, err := store.GetResourceGroups(context.Background(), LockingStrengthNone, accountID, resourceId)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, groups, 1)
|
||||
|
||||
err = store.RemoveResourceFromGroup(context.Background(), accountID, groupID, res.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
group, err = store.GetGroupByID(context.Background(), LockingStrengthNone, accountID, groupID)
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, group.Resources, *res)
|
||||
}
|
||||
|
||||
func TestSqlStore_SaveGroups_LargeBatch(t *testing.T) {
|
||||
store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir())
|
||||
t.Cleanup(cleanup)
|
||||
require.NoError(t, err)
|
||||
|
||||
accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
|
||||
|
||||
accountGroups, err := store.GetAccountGroups(context.Background(), LockingStrengthNone, accountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, accountGroups, 3)
|
||||
|
||||
groupsToSave := make([]*types.Group, 0)
|
||||
|
||||
for i := 1; i <= 8000; i++ {
|
||||
groupsToSave = append(groupsToSave, &types.Group{
|
||||
ID: fmt.Sprintf("%d", i),
|
||||
AccountID: accountID,
|
||||
Name: fmt.Sprintf("group-%d", i),
|
||||
})
|
||||
}
|
||||
|
||||
err = store.CreateGroups(context.Background(), accountID, groupsToSave)
|
||||
require.NoError(t, err)
|
||||
|
||||
accountGroups, err = store.GetAccountGroups(context.Background(), LockingStrengthNone, accountID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 8003, len(accountGroups))
|
||||
}
|
||||
@@ -57,11 +57,11 @@ func (s *SqlStore) ListUsers(ctx context.Context) ([]*types.User, error) {
|
||||
// txDeferFKConstraints defers foreign key constraint checks for the duration of the transaction.
|
||||
// MySQL is already handled by s.transaction (SET FOREIGN_KEY_CHECKS = 0).
|
||||
func (s *SqlStore) txDeferFKConstraints(tx *gorm.DB) error {
|
||||
if s.storeEngine == types.SqliteStoreEngine {
|
||||
if s.conn.Engine() == types.SqliteStoreEngine {
|
||||
return tx.Exec("PRAGMA defer_foreign_keys = ON").Error
|
||||
}
|
||||
|
||||
if s.storeEngine != types.PostgresStoreEngine {
|
||||
if s.conn.Engine() != types.PostgresStoreEngine {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ func (s *SqlStore) txDeferFKConstraints(tx *gorm.DB) error {
|
||||
// txRestoreFKConstraints reverts FK constraints back to NOT DEFERRABLE after the
|
||||
// deferred updates are done but before the transaction commits.
|
||||
func (s *SqlStore) txRestoreFKConstraints(tx *gorm.DB) error {
|
||||
if s.storeEngine != types.PostgresStoreEngine {
|
||||
if s.conn.Engine() != types.PostgresStoreEngine {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@ func (s *SqlStore) UpdateUserID(ctx context.Context, accountID, oldUserID, newUs
|
||||
}
|
||||
|
||||
log.Info("Updating user ID in the store")
|
||||
err := s.transaction(func(tx *gorm.DB) error {
|
||||
err := s.transaction(ctx, func(tx *gorm.DB) error {
|
||||
if err := s.txDeferFKConstraints(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -161,7 +161,7 @@ func (s *SqlStore) UpdateUserID(ctx context.Context, accountID, oldUserID, newUs
|
||||
}
|
||||
|
||||
log.Info("Restoring FK constraints")
|
||||
err = s.transaction(func(tx *gorm.DB) error {
|
||||
err = s.transaction(ctx, func(tx *gorm.DB) error {
|
||||
if err := s.txRestoreFKConstraints(tx); err != nil {
|
||||
return fmt.Errorf("restore FK constraints: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type installation struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
InstallationIDValue string
|
||||
}
|
||||
|
||||
func (s *SqlStore) SaveInstallationID(_ context.Context, ID string) error {
|
||||
installation := installation{InstallationIDValue: ID}
|
||||
installation.ID = uint(s.installationPK)
|
||||
|
||||
return s.db.Clauses(clause.OnConflict{UpdateAll: true}).Create(&installation).Error
|
||||
}
|
||||
|
||||
func (s *SqlStore) GetInstallationID() string {
|
||||
var installation installation
|
||||
|
||||
if result := s.db.Take(&installation, idQueryCondition, s.installationPK); result.Error != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return installation.InstallationIDValue
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
// SaveJob persists a job in DB
|
||||
func (s *SqlStore) CreatePeerJob(ctx context.Context, job *types.Job) error {
|
||||
result := s.db.Create(job)
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to create job in store: %s", result.Error)
|
||||
return status.Errorf(status.Internal, "failed to create job in store")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SqlStore) CompletePeerJob(ctx context.Context, job *types.Job) error {
|
||||
result := s.db.
|
||||
Model(&types.Job{}).
|
||||
Where(idQueryCondition, job.ID).
|
||||
Updates(job)
|
||||
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to update job in store: %s", result.Error)
|
||||
return status.Errorf(status.Internal, "failed to update job in store")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// job was pending for too long and has been cancelled
|
||||
func (s *SqlStore) MarkPendingJobsAsFailed(ctx context.Context, accountID, peerID, jobID, reason string) error {
|
||||
now := time.Now().UTC()
|
||||
result := s.db.
|
||||
Model(&types.Job{}).
|
||||
Where(accountAndPeerIDQueryCondition+" AND id = ?"+" AND status = ?", accountID, peerID, jobID, types.JobStatusPending).
|
||||
Updates(types.Job{
|
||||
Status: types.JobStatusFailed,
|
||||
FailedReason: reason,
|
||||
CompletedAt: &now,
|
||||
})
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to mark pending jobs as Failed job in store: %s", result.Error)
|
||||
return status.Errorf(status.Internal, "failed to mark pending job as Failed in store")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// job was pending for too long and has been cancelled
|
||||
func (s *SqlStore) MarkAllPendingJobsAsFailed(ctx context.Context, accountID, peerID, reason string) error {
|
||||
now := time.Now().UTC()
|
||||
result := s.db.
|
||||
Model(&types.Job{}).
|
||||
Where(accountAndPeerIDQueryCondition+" AND status = ?", accountID, peerID, types.JobStatusPending).
|
||||
Updates(types.Job{
|
||||
Status: types.JobStatusFailed,
|
||||
FailedReason: reason,
|
||||
CompletedAt: &now,
|
||||
})
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to mark pending jobs as Failed job in store: %s", result.Error)
|
||||
return status.Errorf(status.Internal, "failed to mark pending job as Failed in store")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetJobByID fetches job by ID
|
||||
func (s *SqlStore) GetPeerJobByID(ctx context.Context, accountID, jobID string) (*types.Job, error) {
|
||||
var job types.Job
|
||||
err := s.db.
|
||||
Where(accountAndIDQueryCondition, accountID, jobID).
|
||||
First(&job).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, status.Errorf(status.NotFound, "job %s not found", jobID)
|
||||
}
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to fetch job from store: %s", err)
|
||||
return nil, err
|
||||
}
|
||||
return &job, nil
|
||||
}
|
||||
|
||||
// get all jobs
|
||||
func (s *SqlStore) GetPeerJobs(ctx context.Context, accountID, peerID string) ([]*types.Job, error) {
|
||||
var jobs []*types.Job
|
||||
err := s.db.
|
||||
Where(accountAndPeerIDQueryCondition, accountID, peerID).
|
||||
Order("created_at DESC").
|
||||
Find(&jobs).Error
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to fetch jobs from store: %s", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return jobs, nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user