mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-18 20:59:07 +02:00
Merge branch 'main' into embedded-vnc
This commit is contained in:
@@ -33,6 +33,7 @@ import (
|
||||
nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
|
||||
"github.com/netbirdio/netbird/management/server/account"
|
||||
"github.com/netbirdio/netbird/management/server/activity"
|
||||
"github.com/netbirdio/netbird/management/server/affectedpeers"
|
||||
nbcache "github.com/netbirdio/netbird/management/server/cache"
|
||||
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
||||
"github.com/netbirdio/netbird/management/server/geolocation"
|
||||
@@ -1626,6 +1627,8 @@ func (am *DefaultAccountManager) SyncUserJWTGroups(ctx context.Context, userAuth
|
||||
var removeOldGroups []string
|
||||
var hasChanges bool
|
||||
var user *types.User
|
||||
var change affectedpeers.Change
|
||||
var snap *affectedpeers.Snapshot
|
||||
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
|
||||
user, err = transaction.GetUserByUserID(ctx, store.LockingStrengthNone, userAuth.UserId)
|
||||
if err != nil {
|
||||
@@ -1664,14 +1667,25 @@ func (am *DefaultAccountManager) SyncUserJWTGroups(ctx context.Context, userAuth
|
||||
return fmt.Errorf("error saving user: %w", err)
|
||||
}
|
||||
|
||||
allGroupChanges := slices.Concat(addNewGroups, removeOldGroups)
|
||||
// The user's auto-groups changed, so the SSH rules authorizing them ship a new
|
||||
// group -> user mapping even when no peer moves between groups.
|
||||
change.UserGroupIDs = allGroupChanges
|
||||
|
||||
// The user's peers are the changed entity in every scenario the sync can
|
||||
// produce — group membership, IPv6 assignment, SSH mappings — so they refresh
|
||||
// together with every peer they can connect to, like on a regular peer update.
|
||||
userPeers, err := transaction.GetUserPeers(ctx, store.LockingStrengthNone, userAuth.AccountId, userAuth.UserId)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting user peers: %w", err)
|
||||
}
|
||||
for _, peer := range userPeers {
|
||||
change.ChangedPeerIDs = append(change.ChangedPeerIDs, peer.ID)
|
||||
}
|
||||
|
||||
// Propagate changes to peers if group propagation is enabled
|
||||
if settings.GroupsPropagationEnabled {
|
||||
peers, err := transaction.GetUserPeers(ctx, store.LockingStrengthNone, userAuth.AccountId, userAuth.UserId)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting user peers: %w", err)
|
||||
}
|
||||
|
||||
for _, peer := range peers {
|
||||
for _, peer := range userPeers {
|
||||
for _, g := range addNewGroups {
|
||||
if err := transaction.AddPeerToGroup(ctx, userAuth.AccountId, peer.ID, g); err != nil {
|
||||
return fmt.Errorf("error adding peer %s to group %s: %w", peer.ID, g, err)
|
||||
@@ -1684,7 +1698,8 @@ func (am *DefaultAccountManager) SyncUserJWTGroups(ctx context.Context, userAuth
|
||||
}
|
||||
}
|
||||
|
||||
allGroupChanges := slices.Concat(addNewGroups, removeOldGroups)
|
||||
change.LinkGroups = allGroupChanges
|
||||
|
||||
if err = am.reconcileIPv6ForGroupChanges(ctx, transaction, userAuth.AccountId, allGroupChanges); err != nil {
|
||||
return fmt.Errorf("reconcile IPv6 for group changes: %w", err)
|
||||
}
|
||||
@@ -1694,6 +1709,10 @@ func (am *DefaultAccountManager) SyncUserJWTGroups(ctx context.Context, userAuth
|
||||
}
|
||||
}
|
||||
|
||||
if snap, err = affectedpeers.Load(ctx, transaction, userAuth.AccountId, change); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
@@ -1730,20 +1749,17 @@ func (am *DefaultAccountManager) SyncUserJWTGroups(ctx context.Context, userAuth
|
||||
}
|
||||
}
|
||||
|
||||
removedGroupAffectsPeers, err := areGroupChangesAffectPeers(ctx, am.Store, userAuth.AccountId, removeOldGroups)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newGroupsAffectsPeers, err := areGroupChangesAffectPeers(ctx, am.Store, userAuth.AccountId, addNewGroups)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if removedGroupAffectsPeers || newGroupsAffectsPeers {
|
||||
log.WithContext(ctx).Tracef("user %s: JWT group membership changed, updating account peers", userAuth.UserId)
|
||||
am.BufferUpdateAccountPeers(ctx, userAuth.AccountId, types.UpdateReason{Resource: types.UpdateResourceUser, Operation: types.UpdateOperationUpdate})
|
||||
}
|
||||
log.WithContext(ctx).Tracef("user %s: JWT group membership changed, updating affected peers", userAuth.UserId)
|
||||
bgCtx := context.WithoutCancel(ctx)
|
||||
go func() {
|
||||
affectedPeerIDs := snap.Expand(bgCtx, userAuth.AccountId, change)
|
||||
if len(affectedPeerIDs) == 0 {
|
||||
return
|
||||
}
|
||||
if err := am.networkMapController.BufferUpdateAffectedPeers(bgCtx, userAuth.AccountId, affectedPeerIDs, types.UpdateReason{Resource: types.UpdateResourceUser, Operation: types.UpdateOperationUpdate}); err != nil {
|
||||
log.WithContext(bgCtx).Errorf("failed to update affected peers after JWT group sync for account %s: %v", userAuth.AccountId, err)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -2426,30 +2442,24 @@ func (am *DefaultAccountManager) reconcileIPv6ForGroupChanges(ctx context.Contex
|
||||
return fmt.Errorf("get account settings: %w", err)
|
||||
}
|
||||
|
||||
if len(settings.IPv6EnabledGroups) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
enabledSet := make(map[string]struct{}, len(settings.IPv6EnabledGroups))
|
||||
for _, gid := range settings.IPv6EnabledGroups {
|
||||
enabledSet[gid] = struct{}{}
|
||||
}
|
||||
|
||||
affected := false
|
||||
for _, gid := range groupIDs {
|
||||
if _, ok := enabledSet[gid]; ok {
|
||||
affected = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !affected {
|
||||
if !ipv6ReconcileNeeded(settings, groupIDs) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return am.updatePeerIPv6Addresses(ctx, transaction, accountID, settings)
|
||||
}
|
||||
|
||||
// ipv6ReconcileNeeded reports whether changes to the given groups trigger an IPv6
|
||||
// reconciliation.
|
||||
func ipv6ReconcileNeeded(settings *types.Settings, groupIDs []string) bool {
|
||||
for _, groupID := range groupIDs {
|
||||
if slices.Contains(settings.IPv6EnabledGroups, groupID) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (am *DefaultAccountManager) ensureIPv6Subnet(ctx context.Context, transaction store.Store, accountID string, settings *types.Settings, network *types.Network) error {
|
||||
if settings.NetworkRangeV6.IsValid() {
|
||||
network.NetV6 = net.IPNet{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package account
|
||||
|
||||
//go:generate go run github.com/golang/mock/mockgen -package account -destination=manager_mock.go -source=./manager.go -build_flags=-mod=mod
|
||||
//go:generate go tool mockgen -package account -destination=manager_mock.go -source=./manager.go -build_flags=-mod=mod
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,8 @@ import (
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
)
|
||||
|
||||
//go:generate go tool mockgen -package=account -source=./request_buffer.go -destination=request_buffer_mock.go
|
||||
|
||||
type RequestBuffer interface {
|
||||
GetAccountWithBackpressure(ctx context.Context, accountID string) (*types.Account, error)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: ./request_buffer.go
|
||||
//
|
||||
// Generated by this command:
|
||||
//
|
||||
// mockgen -package=account -source=./request_buffer.go -destination=request_buffer_mock.go
|
||||
//
|
||||
|
||||
// Package account is a generated GoMock package.
|
||||
package account
|
||||
|
||||
import (
|
||||
context "context"
|
||||
reflect "reflect"
|
||||
|
||||
types "github.com/netbirdio/netbird/management/server/types"
|
||||
gomock "go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
// MockRequestBuffer is a mock of RequestBuffer interface.
|
||||
type MockRequestBuffer struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockRequestBufferMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockRequestBufferMockRecorder is the mock recorder for MockRequestBuffer.
|
||||
type MockRequestBufferMockRecorder struct {
|
||||
mock *MockRequestBuffer
|
||||
}
|
||||
|
||||
// NewMockRequestBuffer creates a new mock instance.
|
||||
func NewMockRequestBuffer(ctrl *gomock.Controller) *MockRequestBuffer {
|
||||
mock := &MockRequestBuffer{ctrl: ctrl}
|
||||
mock.recorder = &MockRequestBufferMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockRequestBuffer) EXPECT() *MockRequestBufferMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// GetAccountWithBackpressure mocks base method.
|
||||
func (m *MockRequestBuffer) GetAccountWithBackpressure(ctx context.Context, accountID string) (*types.Account, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetAccountWithBackpressure", ctx, accountID)
|
||||
ret0, _ := ret[0].(*types.Account)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetAccountWithBackpressure indicates an expected call of GetAccountWithBackpressure.
|
||||
func (mr *MockRequestBufferMockRecorder) GetAccountWithBackpressure(ctx, accountID any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountWithBackpressure", reflect.TypeOf((*MockRequestBuffer)(nil).GetAccountWithBackpressure), ctx, accountID)
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/prometheus/client_golang/prometheus/push"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -1757,6 +1757,7 @@ func TestAccount_Copy(t *testing.T) {
|
||||
AccountID: "account1",
|
||||
},
|
||||
},
|
||||
PostureValidation: map[string]map[string]bool{"1": {"1": true}},
|
||||
}
|
||||
err := hasNilField(account)
|
||||
if err != nil {
|
||||
|
||||
@@ -281,6 +281,9 @@ const (
|
||||
// AccountMetricsPushDisabled indicates that a user disabled metrics push for the account
|
||||
AccountMetricsPushDisabled Activity = 141
|
||||
|
||||
// AgentNetworkSettingsDeleted indicates that a user deleted the Agent Network account settings, releasing the endpoint
|
||||
AgentNetworkSettingsDeleted Activity = 142
|
||||
|
||||
AccountDeleted Activity = 99999
|
||||
)
|
||||
|
||||
@@ -453,6 +456,7 @@ var activityMap = map[Activity]Code{
|
||||
AgentNetworkBudgetRuleDeleted: {"Agent Network budget rule deleted", "agent_network.budget_rule.delete"},
|
||||
|
||||
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"},
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/affectedpeers"
|
||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/shared/auth"
|
||||
)
|
||||
|
||||
// A user's auto-group change refreshes the destinations of the SSH rules authorizing
|
||||
// that group — they carry the group -> user mapping — even though no peer moved
|
||||
// between groups.
|
||||
func TestAffectedPeers_UserGroupChange_RefreshesSSHAuthorizedDestinations(t *testing.T) {
|
||||
manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{
|
||||
Enabled: true,
|
||||
Rules: []*types.PolicyRule{
|
||||
{
|
||||
Enabled: true,
|
||||
Sources: []string{groupIDs[0]},
|
||||
Destinations: []string{groupIDs[1]},
|
||||
Protocol: types.PolicyRuleProtocolNetbirdSSH,
|
||||
Action: types.PolicyTrafficActionAccept,
|
||||
AuthorizedGroups: map[string][]string{groupIDs[3]: {"root"}},
|
||||
},
|
||||
},
|
||||
}, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
result := resolveAffected(t, s, accountID, affectedpeers.Change{UserGroupIDs: []string{groupIDs[3]}})
|
||||
assert.ElementsMatch(t, []string{peerIDs[1]}, result,
|
||||
"only the SSH rule's destination peers carry the changed group -> user mapping")
|
||||
|
||||
result = resolveAffected(t, s, accountID, affectedpeers.Change{UserGroupIDs: []string{groupIDs[4]}})
|
||||
assert.Empty(t, result, "a group no SSH rule authorizes affects nobody")
|
||||
}
|
||||
|
||||
// Creating, blocking or unblocking a user changes the account's allowed-user set, which
|
||||
// reaches only the destinations of the SSH rules that ship it.
|
||||
func TestAffectedPeers_AllowedUsersChange_RefreshesSSHDestinations(t *testing.T) {
|
||||
manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Ships the allowed-user set: an SSH rule naming no groups and no user.
|
||||
_, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{
|
||||
Enabled: true,
|
||||
Rules: []*types.PolicyRule{{
|
||||
Enabled: true,
|
||||
Sources: []string{groupIDs[0]},
|
||||
Destinations: []string{groupIDs[1]},
|
||||
Protocol: types.PolicyRuleProtocolNetbirdSSH,
|
||||
Action: types.PolicyTrafficActionAccept,
|
||||
}},
|
||||
}, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Does not ship it: an SSH rule that authorizes a specific group.
|
||||
_, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{
|
||||
Enabled: true,
|
||||
Rules: []*types.PolicyRule{{
|
||||
Enabled: true,
|
||||
Sources: []string{groupIDs[2]},
|
||||
Destinations: []string{groupIDs[3]},
|
||||
Protocol: types.PolicyRuleProtocolNetbirdSSH,
|
||||
Action: types.PolicyTrafficActionAccept,
|
||||
AuthorizedGroups: map[string][]string{groupIDs[0]: {"root"}},
|
||||
}},
|
||||
}, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
result := resolveAffected(t, s, accountID, affectedpeers.Change{AllowedUsersChanged: true})
|
||||
assert.ElementsMatch(t, []string{peerIDs[1]}, result,
|
||||
"only the destinations of the rule shipping the allowed-user set refresh")
|
||||
}
|
||||
|
||||
// TestAffectedPeers_SyncUserJWTGroups_OnlyAffectedPeersUpdated verifies that a JWT
|
||||
// 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
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
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}}))
|
||||
|
||||
_, 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)
|
||||
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)
|
||||
|
||||
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")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/activity"
|
||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
)
|
||||
|
||||
// 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
|
||||
|
||||
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{
|
||||
Id: targetUserID, AccountID: accountID, Role: types.UserRoleUser,
|
||||
AutoGroups: []string{"ug-linked"},
|
||||
})
|
||||
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",
|
||||
})
|
||||
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)
|
||||
})
|
||||
|
||||
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"}
|
||||
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)
|
||||
|
||||
_, 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{
|
||||
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))
|
||||
|
||||
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",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
peerShouldReceiveUpdate(t, upd2)
|
||||
peerShouldNotReceiveUpdate(t, upd3)
|
||||
})
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"context"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/exp/maps"
|
||||
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||
@@ -83,7 +84,7 @@ func (snap *Snapshot) loadCollections(ctx context.Context, s store.Store, accoun
|
||||
hasGroupOrPeerChange := len(c.ChangedGroupIDs) > 0 || len(c.ChangedPeerIDs) > 0 || len(c.LinkGroups) > 0 || len(c.Resources) > 0
|
||||
hasNetworkObject := len(c.Routers) > 0 || len(c.Resources) > 0 || len(c.Networks) > 0
|
||||
// the resource<->router bridge can fire for any of these
|
||||
needsRoutersResources := hasGroupOrPeerChange || len(c.PostureCheckIDs) > 0 || len(c.Policies) > 0 || hasNetworkObject
|
||||
needsRoutersResources := hasGroupOrPeerChange || len(c.PostureCheckIDs) > 0 || len(c.Policies) > 0 || hasNetworkObject || len(c.UserGroupIDs) > 0 || c.AllowedUsersChanged
|
||||
|
||||
if needsRoutersResources {
|
||||
if err := snap.loadPolicyRoutersResources(ctx, s, accountID); err != nil {
|
||||
@@ -219,6 +220,18 @@ type Change struct {
|
||||
// (correct when the peer's own attributes changed, e.g. IP/status).
|
||||
OutputPeerIDs []string
|
||||
|
||||
// UserGroupIDs are groups whose USER membership changed (a user's auto-groups),
|
||||
// as opposed to their peer membership. Peers ship the group -> user mapping only
|
||||
// for the groups an SSH rule authorizes, so these refresh the destinations of the
|
||||
// SSH rules authorizing them — independently of any peer moving between groups.
|
||||
UserGroupIDs []string
|
||||
|
||||
// AllowedUsersChanged marks a change to the set of users allowed to open SSH
|
||||
// sessions — a user was created, blocked or unblocked. That set is account-wide,
|
||||
// and peers receive it through the SSH rules that name no group or user of their
|
||||
// own, so those rules' destinations refresh.
|
||||
AllowedUsersChanged bool
|
||||
|
||||
// LinkGroups are groups used ONLY to match policies/routes/routers and walk to the
|
||||
// OPPOSITE side — they are never expanded to their own members. Use this when a
|
||||
// peer's group membership changed: pass the peer in ChangedPeerIDs and its
|
||||
@@ -240,6 +253,8 @@ func (c Change) isEmpty() bool {
|
||||
len(c.Resources) == 0 &&
|
||||
len(c.Networks) == 0 &&
|
||||
len(c.PostureCheckIDs) == 0 &&
|
||||
len(c.UserGroupIDs) == 0 &&
|
||||
!c.AllowedUsersChanged &&
|
||||
len(c.DistributionGroupIDs) == 0 &&
|
||||
len(c.RemovedPeersByGroup) == 0 &&
|
||||
len(c.LinkGroups) == 0 &&
|
||||
@@ -359,6 +374,9 @@ func (r *resolver) walk() {
|
||||
r.collectFromProxyServices()
|
||||
}
|
||||
|
||||
r.collectFromSSHAuthorizedGroups()
|
||||
r.collectFromAllowedUsers()
|
||||
|
||||
r.collectFromChangedRoutes(r.change.Routes)
|
||||
r.collectFromChangedRouters(r.change.Routers)
|
||||
r.collectFromChangedResources(r.change.Resources)
|
||||
@@ -811,6 +829,59 @@ func (r *resolver) collectFromNameServers() {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// peer moved between groups.
|
||||
func (r *resolver) collectFromSSHAuthorizedGroups() {
|
||||
if len(r.change.UserGroupIDs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
changed := toSet(r.change.UserGroupIDs)
|
||||
for _, policy := range r.policies() {
|
||||
for _, rule := range policy.Rules {
|
||||
if !rule.Enabled || rule.Protocol != types.PolicyRuleProtocolNetbirdSSH {
|
||||
continue
|
||||
}
|
||||
if !anyInSet(maps.Keys(rule.AuthorizedGroups), changed) {
|
||||
continue
|
||||
}
|
||||
log.WithContext(r.ctx).Tracef("collectFromSSHAuthorizedGroups: rule %s authorizes a changed user group -> folding its destinations", rule.ID)
|
||||
r.foldPolicySideForRule(policy, rule, sideDestination)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// collectFromAllowedUsers folds the destinations of the rules that make a peer carry
|
||||
// the account's allowed-user set, for a change to who is in that set.
|
||||
func (r *resolver) collectFromAllowedUsers() {
|
||||
if !r.change.AllowedUsersChanged {
|
||||
return
|
||||
}
|
||||
|
||||
for _, policy := range r.policies() {
|
||||
for _, rule := range policy.Rules {
|
||||
if !rule.Enabled || !ruleShipsAllowedUsers(rule) {
|
||||
continue
|
||||
}
|
||||
log.WithContext(r.ctx).Tracef("collectFromAllowedUsers: rule %s ships the allowed-user set -> folding its destinations", rule.ID)
|
||||
r.foldPolicySideForRule(policy, rule, sideDestination)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ruleShipsAllowedUsers reports whether a rule makes its destination peers carry the
|
||||
// account's allowed-user set. It mirrors the network map's SSH requirements except for
|
||||
// the destination peer's own SSH flag, which the snapshot does not hold — so it folds a
|
||||
// superset and never misses a peer.
|
||||
func ruleShipsAllowedUsers(rule *types.PolicyRule) bool {
|
||||
if rule.Protocol == types.PolicyRuleProtocolNetbirdSSH {
|
||||
return len(rule.AuthorizedGroups) == 0 && rule.AuthorizedUser == ""
|
||||
}
|
||||
return types.PolicyRuleImpliesLegacySSH(rule)
|
||||
}
|
||||
|
||||
func (r *resolver) collectFromDNSSettings() {
|
||||
if len(r.linkGroups) == 0 || r.snap.dnsSettings == nil {
|
||||
return
|
||||
|
||||
@@ -85,6 +85,8 @@ func TestChangeIsEmpty(t *testing.T) {
|
||||
assert.False(t, Change{Resources: []*resourceTypes.NetworkResource{{ID: "r"}}}.isEmpty())
|
||||
assert.False(t, Change{Networks: []*networkTypes.Network{{ID: "n"}}}.isEmpty())
|
||||
assert.False(t, Change{PostureCheckIDs: []string{"pc"}}.isEmpty())
|
||||
assert.False(t, Change{UserGroupIDs: []string{"g"}}.isEmpty())
|
||||
assert.False(t, Change{AllowedUsersChanged: true}.isEmpty())
|
||||
}
|
||||
|
||||
func TestPolicyReferencesPostureChecks(t *testing.T) {
|
||||
|
||||
@@ -68,7 +68,10 @@ func TestAgentNetwork_BudgetRuleCRUD_RealManager(t *testing.T) {
|
||||
|
||||
// TestAgentNetwork_UpdateSettings_PreservesImmutableAndTogglesCollection is the
|
||||
// GC-1 guard for UpdateSettings: it must apply the collection toggles while
|
||||
// preserving the immutable Cluster/Subdomain pinned at bootstrap.
|
||||
// preserving the immutable Domain/ProxyAddress assigned at bootstrap. The
|
||||
// request echoes the identity fields back — the PUT convention every other
|
||||
// endpoint follows — and a request echoing anything else is rejected outright
|
||||
// rather than quietly ignored.
|
||||
func TestAgentNetwork_UpdateSettings_PreservesImmutableAndTogglesCollection(t *testing.T) {
|
||||
am, _, err := createManager(t)
|
||||
require.NoError(t, err, "createManager must succeed")
|
||||
@@ -84,7 +87,14 @@ func TestAgentNetwork_UpdateSettings_PreservesImmutableAndTogglesCollection(t *t
|
||||
|
||||
mgr := agentnetwork.NewManager(am.Store, permissions.NewManager(am.Store), am, nil)
|
||||
|
||||
// Creating a provider bootstraps the settings row (cluster + subdomain).
|
||||
// Bootstrap is an explicit settings create; providers have no settings
|
||||
// side effects anymore.
|
||||
before, err := mgr.CreateSettings(ctx, adminUserID, agenttypes.DefaultSettings(accountID), clusterAddr, "")
|
||||
require.NoError(t, err, "CreateSettings must bootstrap the row")
|
||||
require.Equal(t, clusterAddr, before.ProxyAddress, "proxy address pinned at bootstrap")
|
||||
require.NotEmpty(t, before.Domain, "endpoint allocated at bootstrap")
|
||||
assert.False(t, before.EnablePromptCollection, "prompt collection defaults off")
|
||||
|
||||
_, err = mgr.CreateProvider(ctx, adminUserID, &agenttypes.Provider{
|
||||
AccountID: accountID,
|
||||
ProviderID: "openai_api",
|
||||
@@ -93,43 +103,64 @@ func TestAgentNetwork_UpdateSettings_PreservesImmutableAndTogglesCollection(t *t
|
||||
APIKey: "sk-test",
|
||||
Enabled: true,
|
||||
Models: []agenttypes.ProviderModel{{ID: "gpt-5.4"}},
|
||||
}, clusterAddr)
|
||||
require.NoError(t, err, "CreateProvider must bootstrap settings")
|
||||
|
||||
before, err := mgr.GetSettings(ctx, accountID, adminUserID)
|
||||
require.NoError(t, err, "GetSettings must succeed after bootstrap")
|
||||
require.Equal(t, clusterAddr, before.Cluster, "cluster pinned at bootstrap")
|
||||
require.NotEmpty(t, before.Subdomain, "subdomain pinned at bootstrap")
|
||||
assert.False(t, before.EnablePromptCollection, "prompt collection defaults off")
|
||||
|
||||
// A cluster different from the one pinned at bootstrap must be rejected
|
||||
// outright — never silently swapped or ignored.
|
||||
_, err = mgr.UpdateSettings(ctx, adminUserID, &agenttypes.Settings{
|
||||
AccountID: accountID,
|
||||
Cluster: "attacker.cluster",
|
||||
EnableLogCollection: true,
|
||||
})
|
||||
require.Error(t, err, "UpdateSettings with a mismatched cluster must fail")
|
||||
require.NoError(t, err, "CreateProvider must succeed")
|
||||
|
||||
// Flipping the toggles works with the pinned cluster echoed back (and
|
||||
// with it omitted); the subdomain is never taken from the request.
|
||||
// Flipping the toggles works when the request echoes the assigned
|
||||
// identity. Retention is echoed too: UpdateSettings takes it verbatim, so
|
||||
// omitting it would zero the account's retention.
|
||||
updated, err := mgr.UpdateSettings(ctx, adminUserID, &agenttypes.Settings{
|
||||
AccountID: accountID,
|
||||
Cluster: clusterAddr,
|
||||
Subdomain: "evil",
|
||||
Domain: before.Domain,
|
||||
ProxyAddress: before.ProxyAddress,
|
||||
EnableLogCollection: true,
|
||||
EnablePromptCollection: true,
|
||||
RedactPii: true,
|
||||
AccessLogRetentionDays: before.AccessLogRetentionDays,
|
||||
})
|
||||
require.NoError(t, err, "UpdateSettings must succeed")
|
||||
assert.Equal(t, before.Cluster, updated.Cluster, "cluster is immutable and must be preserved")
|
||||
assert.Equal(t, before.Subdomain, updated.Subdomain, "subdomain is immutable and must be preserved")
|
||||
assert.Equal(t, before.Domain, updated.Domain, "domain is immutable and must be preserved")
|
||||
assert.Equal(t, before.ProxyAddress, updated.ProxyAddress, "proxy address is immutable and must be preserved")
|
||||
assert.True(t, updated.EnableLogCollection, "log collection toggle must apply")
|
||||
assert.True(t, updated.EnablePromptCollection, "prompt collection toggle must apply")
|
||||
assert.True(t, updated.RedactPii, "redact toggle must apply")
|
||||
assert.Equal(t, before.AccessLogRetentionDays, updated.AccessLogRetentionDays, "echoed retention must survive")
|
||||
|
||||
// Neither identity field can be smuggled into the row: a hand-rolled
|
||||
// Settings value carrying a different endpoint or proxy address is
|
||||
// rejected, not silently ignored.
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
domain string
|
||||
proxyAddress string
|
||||
}{
|
||||
{name: "foreign endpoint", domain: "evil.example.com", proxyAddress: before.ProxyAddress},
|
||||
{name: "foreign proxy address", domain: before.Domain, proxyAddress: "attacker.cluster"},
|
||||
{name: "empty identity echo", domain: "", proxyAddress: ""},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := mgr.UpdateSettings(ctx, adminUserID, &agenttypes.Settings{
|
||||
AccountID: accountID,
|
||||
Domain: tc.domain,
|
||||
ProxyAddress: tc.proxyAddress,
|
||||
EnableLogCollection: false,
|
||||
EnablePromptCollection: false,
|
||||
RedactPii: false,
|
||||
AccessLogRetentionDays: before.AccessLogRetentionDays,
|
||||
})
|
||||
assert.Error(t, err, "a mismatched identity echo must be rejected")
|
||||
assert.ErrorContains(t, err, "immutable", "the rejection must name the immutability rule")
|
||||
})
|
||||
}
|
||||
|
||||
// The rejected updates left the row exactly as the accepted one wrote it.
|
||||
afterRejects, err := mgr.GetSettings(ctx, accountID, adminUserID)
|
||||
require.NoError(t, err, "GetSettings must succeed")
|
||||
assert.True(t, afterRejects.EnablePromptCollection, "a rejected update must not roll back the accepted toggles")
|
||||
|
||||
reloaded, err := am.Store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, before.Cluster, reloaded.Cluster, "persisted cluster unchanged")
|
||||
assert.Equal(t, before.Domain, reloaded.Domain, "persisted domain unchanged")
|
||||
assert.Equal(t, before.ProxyAddress, reloaded.ProxyAddress, "persisted proxy address unchanged")
|
||||
assert.True(t, reloaded.EnablePromptCollection, "persisted prompt collection toggled on")
|
||||
}
|
||||
|
||||
@@ -92,6 +92,14 @@ func TestAgentNetwork_ProviderCRUD_FansOutToProxyAndClientPeers(t *testing.T) {
|
||||
// UpdateAccountPeers, which is the path under test.
|
||||
agentMgr := agentnetwork.NewManager(am.Store, permissions.NewManager(am.Store), am, nil)
|
||||
|
||||
_, err = agentMgr.CreateSettings(ctx, adminUserID, agenttypes.DefaultSettings(accountID), clusterAddr, "")
|
||||
require.NoError(t, err, "CreateSettings must bootstrap the endpoint")
|
||||
// The bootstrap itself reconciles and queues updates on both channels;
|
||||
// drain them so the fan-out assertions below can only be satisfied by the
|
||||
// operation under test, not by this leftover.
|
||||
drain(clientCh)
|
||||
drain(proxyCh)
|
||||
|
||||
provider, err := agentMgr.CreateProvider(ctx, adminUserID, &agenttypes.Provider{
|
||||
AccountID: accountID,
|
||||
ProviderID: "openai_api",
|
||||
@@ -100,7 +108,7 @@ func TestAgentNetwork_ProviderCRUD_FansOutToProxyAndClientPeers(t *testing.T) {
|
||||
APIKey: "sk-test-key",
|
||||
Enabled: true,
|
||||
Models: []agenttypes.ProviderModel{{ID: "gpt-5.4"}},
|
||||
}, clusterAddr)
|
||||
})
|
||||
require.NoError(t, err, "CreateProvider must succeed")
|
||||
|
||||
policy, err := agentMgr.CreatePolicy(ctx, adminUserID, &agenttypes.Policy{
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"net/mail"
|
||||
"testing"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
@@ -13,9 +13,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/gorilla/mux"
|
||||
ugomock "go.uber.org/mock/gomock"
|
||||
"go.uber.org/mock/gomock"
|
||||
"golang.org/x/exp/maps"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map"
|
||||
@@ -106,7 +105,7 @@ func initTestMetaData(t *testing.T, peers ...*nbpeer.Peer) *Handler {
|
||||
},
|
||||
}
|
||||
|
||||
ctrl := ugomock.NewController(t)
|
||||
ctrl := gomock.NewController(t)
|
||||
|
||||
networkMapController := network_map.NewMockController(ctrl)
|
||||
networkMapController.EXPECT().
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
@@ -108,7 +109,7 @@ func (h *AuthCallbackHandler) handleCallback(w http.ResponseWriter, r *http.Requ
|
||||
redirectURL.Scheme = "https"
|
||||
query := redirectURL.Query()
|
||||
query.Set("error", "access_denied")
|
||||
query.Set("error_description", "Service configuration error")
|
||||
query.Set("error_description", sessionTokenErrorDescription(err))
|
||||
redirectURL.RawQuery = query.Encode()
|
||||
http.Redirect(w, r, redirectURL.String(), http.StatusFound)
|
||||
return
|
||||
@@ -124,6 +125,20 @@ func (h *AuthCallbackHandler) handleCallback(w http.ResponseWriter, r *http.Requ
|
||||
http.Redirect(w, r, redirectURL.String(), http.StatusFound)
|
||||
}
|
||||
|
||||
// sessionTokenErrorDescription maps a session token failure to the text the
|
||||
// proxy renders on its access denied page. Account status denials get a message
|
||||
// the user can act on, while everything else stays generic so a lookup or
|
||||
// signing failure does not describe management internals to the browser.
|
||||
func sessionTokenErrorDescription(err error) string {
|
||||
if errors.Is(err, nbgrpc.ErrUserPendingApproval) {
|
||||
return "Your account is pending approval by an administrator"
|
||||
}
|
||||
if errors.Is(err, nbgrpc.ErrUserBlocked) {
|
||||
return "Your account is blocked"
|
||||
}
|
||||
return "Service configuration error"
|
||||
}
|
||||
|
||||
func extractUserIDFromToken(ctx context.Context, provider *oidc.Provider, config nbgrpc.ProxyOIDCConfig, token *oauth2.Token) string {
|
||||
rawIDToken, ok := token.Extra("id_token").(string)
|
||||
if !ok {
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
|
||||
activitymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/activity/manager"
|
||||
nbproxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||
nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
|
||||
@@ -221,6 +222,7 @@ func setupAuthCallbackTest(t *testing.T) *testSetup {
|
||||
)
|
||||
|
||||
proxyService.SetServiceManager(&testServiceManager{store: testStore})
|
||||
proxyService.SetActivityManager(activitymanager.NewManager(testStore))
|
||||
|
||||
handler := NewAuthCallbackHandler(proxyService, nil)
|
||||
|
||||
@@ -360,6 +362,51 @@ func createTestAccountsAndUsers(t *testing.T, ctx context.Context, testStore sto
|
||||
Issued: "api",
|
||||
}
|
||||
require.NoError(t, testStore.SaveUser(ctx, allowedUser))
|
||||
|
||||
// A second tenant, whose users must never be issued a token signed with
|
||||
// the first tenant's service session key.
|
||||
otherAccount := &types.Account{
|
||||
Id: "otherAccountId",
|
||||
Domain: "other.com",
|
||||
DomainCategory: "private",
|
||||
IsDomainPrimaryAccount: true,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
require.NoError(t, testStore.SaveAccount(ctx, otherAccount))
|
||||
|
||||
otherAccountUser := &types.User{
|
||||
Id: "otherAccountUserId",
|
||||
AccountID: "otherAccountId",
|
||||
Role: types.UserRoleUser,
|
||||
CreatedAt: time.Now(),
|
||||
Issued: "api",
|
||||
}
|
||||
require.NoError(t, testStore.SaveUser(ctx, otherAccountUser))
|
||||
|
||||
// A user awaiting approval is stored as blocked and pending approval, and
|
||||
// carries the same group membership as the approved one.
|
||||
pendingUser := &types.User{
|
||||
Id: "pendingUserId",
|
||||
AccountID: "testAccountId",
|
||||
Role: types.UserRoleUser,
|
||||
AutoGroups: []string{"allowedGroupId"},
|
||||
Blocked: true,
|
||||
PendingApproval: true,
|
||||
CreatedAt: time.Now(),
|
||||
Issued: "api",
|
||||
}
|
||||
require.NoError(t, testStore.SaveUser(ctx, pendingUser))
|
||||
|
||||
blockedUser := &types.User{
|
||||
Id: "blockedUserId",
|
||||
AccountID: "testAccountId",
|
||||
Role: types.UserRoleUser,
|
||||
AutoGroups: []string{"allowedGroupId"},
|
||||
Blocked: true,
|
||||
CreatedAt: time.Now(),
|
||||
Issued: "api",
|
||||
}
|
||||
require.NoError(t, testStore.SaveUser(ctx, blockedUser))
|
||||
}
|
||||
|
||||
// testServiceManager is a minimal implementation for testing.
|
||||
@@ -490,6 +537,113 @@ func TestAuthCallback_UserAllowedToLogin(t *testing.T) {
|
||||
require.Empty(t, parsedLocation.Query().Get("error"), "Should not have error parameter")
|
||||
}
|
||||
|
||||
// TestAuthCallback_UserDeniedByAccountStatus asserts that a user whose account
|
||||
// is pending approval or blocked never receives a session token from the OIDC
|
||||
// callback, and that the redirect carries a description the proxy can render.
|
||||
// TestAuthCallback_RecordsUserLogin drives the real OIDC callback and asserts
|
||||
// the login lands on the user row. That timestamp is what activity accounting
|
||||
// reads, and it is the only signal that can ever count someone who reaches
|
||||
// proxy-protected services from a browser and never opens the dashboard.
|
||||
func TestAuthCallback_RecordsUserLogin(t *testing.T) {
|
||||
setup := setupAuthCallbackTest(t)
|
||||
defer setup.cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
before, err := setup.store.GetUserByUserID(ctx, store.LockingStrengthNone, "allowedUserId")
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, before.LastLogin, "fixture user starts with no login on record")
|
||||
|
||||
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)
|
||||
|
||||
after, err := setup.store.GetUserByUserID(ctx, store.LockingStrengthNone, "allowedUserId")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, after.LastLogin, "a completed proxy SSO login must be recorded on the user")
|
||||
require.WithinDuration(t, time.Now().UTC(), after.LastLogin.UTC(), time.Minute, "login should be stamped at sign-in time")
|
||||
}
|
||||
|
||||
// TestAuthCallback_DeniedUserLoginNotRecorded keeps the write on the granted
|
||||
// path: a refused sign-in is not a login.
|
||||
func TestAuthCallback_DeniedUserLoginNotRecorded(t *testing.T) {
|
||||
setup := setupAuthCallbackTest(t)
|
||||
defer setup.cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
setup.oidcServer.tokenSubject = "blockedUserId"
|
||||
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)
|
||||
|
||||
after, err := setup.store.GetUserByUserID(ctx, store.LockingStrengthNone, "blockedUserId")
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, after.LastLogin, "a denied user must not be recorded as having logged in")
|
||||
}
|
||||
|
||||
func TestAuthCallback_UserDeniedByAccountStatus(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
subject string
|
||||
expectErrorDesc string
|
||||
}{
|
||||
{
|
||||
name: "pending approval",
|
||||
subject: "pendingUserId",
|
||||
expectErrorDesc: "Your account is pending approval by an administrator",
|
||||
},
|
||||
{
|
||||
name: "blocked",
|
||||
subject: "blockedUserId",
|
||||
expectErrorDesc: "Your account is blocked",
|
||||
},
|
||||
{
|
||||
name: "unknown to management",
|
||||
subject: "userMissingFromStoreId",
|
||||
expectErrorDesc: "Service configuration error",
|
||||
},
|
||||
{
|
||||
// The account topology stays out of the browser-visible message.
|
||||
name: "belongs to another account",
|
||||
subject: "otherAccountUserId",
|
||||
expectErrorDesc: "Service configuration error",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
setup := setupAuthCallbackTest(t)
|
||||
defer setup.cleanup()
|
||||
|
||||
setup.oidcServer.tokenSubject = tt.subject
|
||||
|
||||
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)
|
||||
|
||||
parsedLocation, err := url.Parse(rec.Header().Get("Location"))
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Empty(t, parsedLocation.Query().Get("session_token"), "Denied user must not receive a session token")
|
||||
require.Equal(t, "access_denied", parsedLocation.Query().Get("error"))
|
||||
require.Equal(t, tt.expectErrorDesc, parsedLocation.Query().Get("error_description"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthCallback_ProxyNotFound(t *testing.T) {
|
||||
setup := setupAuthCallbackTest(t)
|
||||
defer setup.cleanup()
|
||||
|
||||
@@ -64,6 +64,19 @@ func (h *handler) createSetupKey(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// A one-off key can be used once, and GenerateSetupKey pins its usage limit
|
||||
// at 1 whatever the request says. Silently overriding a caller that asked
|
||||
// for a different number leaves them holding a key that does not do what
|
||||
// they configured, and no way to find out except by using it. Only values
|
||||
// above 1 are refused: usage_limit is a required field with no null, so 0
|
||||
// cannot be told apart from a caller that has nothing to say about it.
|
||||
if types.SetupKeyType(req.Type) == types.SetupKeyOneOff && req.UsageLimit > 1 {
|
||||
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument,
|
||||
"usage_limit %d is not valid for a one-off setup key, which can be used once; use type reusable for a key that can be used more than once",
|
||||
req.UsageLimit), w)
|
||||
return
|
||||
}
|
||||
|
||||
expiresIn := time.Duration(req.ExpiresIn) * time.Second
|
||||
|
||||
if expiresIn < 0 {
|
||||
|
||||
@@ -134,6 +134,40 @@ func TestSetupKeysHandlers(t *testing.T) {
|
||||
expectedBody: true,
|
||||
expectedSetupKey: expectedNewKey,
|
||||
},
|
||||
{
|
||||
// A one-off key is used once. Asking for more used to be accepted
|
||||
// and then quietly reduced to 1.
|
||||
name: "Create One-Off Setup Key With Conflicting Usage Limit",
|
||||
requestType: http.MethodPost,
|
||||
requestPath: "/api/setup-keys",
|
||||
requestBody: bytes.NewBuffer(
|
||||
[]byte(fmt.Sprintf("{\"name\":\"%s\",\"type\":\"one-off\",\"expires_in\":86400,\"usage_limit\":5}", newSetupKeyName))),
|
||||
expectedStatus: http.StatusUnprocessableEntity,
|
||||
expectedBody: false,
|
||||
},
|
||||
{
|
||||
// 0 is what a caller sends when it has nothing to say about the
|
||||
// usage limit, since the field is required and has no null, so it
|
||||
// has to keep working.
|
||||
name: "Create One-Off Setup Key Without Usage Limit",
|
||||
requestType: http.MethodPost,
|
||||
requestPath: "/api/setup-keys",
|
||||
requestBody: bytes.NewBuffer(
|
||||
[]byte(fmt.Sprintf("{\"name\":\"%s\",\"type\":\"one-off\",\"expires_in\":86400,\"usage_limit\":0}", newSetupKeyName))),
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedBody: false,
|
||||
},
|
||||
{
|
||||
// Only one-off keys are constrained; a reusable key means what it
|
||||
// says.
|
||||
name: "Create Reusable Setup Key With Usage Limit",
|
||||
requestType: http.MethodPost,
|
||||
requestPath: "/api/setup-keys",
|
||||
requestBody: bytes.NewBuffer(
|
||||
[]byte(fmt.Sprintf("{\"name\":\"%s\",\"type\":\"reusable\",\"expires_in\":86400,\"usage_limit\":5}", newSetupKeyName))),
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedBody: false,
|
||||
},
|
||||
{
|
||||
name: "Update Setup Key",
|
||||
requestType: http.MethodPut,
|
||||
|
||||
@@ -136,7 +136,10 @@ func Test_SetupKeys_Create(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Create Setup Key as on-off with more than one usage",
|
||||
// The key used to be created anyway, with its usage limit quietly
|
||||
// reduced to 1, so the caller was told a key they had not asked for
|
||||
// was what they asked for.
|
||||
name: "Create Setup Key as one-off with more than one usage",
|
||||
requestType: http.MethodPost,
|
||||
requestPath: "/api/setup-keys",
|
||||
requestBody: &api.CreateSetupKeyRequest{
|
||||
@@ -146,23 +149,7 @@ func Test_SetupKeys_Create(t *testing.T) {
|
||||
Type: "one-off",
|
||||
UsageLimit: 3,
|
||||
},
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedResponse: &api.SetupKey{
|
||||
AutoGroups: []string{},
|
||||
Ephemeral: false,
|
||||
Expires: time.Time{},
|
||||
Id: "",
|
||||
Key: "",
|
||||
LastUsed: time.Time{},
|
||||
Name: testing_tools.NewKeyName,
|
||||
Revoked: false,
|
||||
State: "valid",
|
||||
Type: "one-off",
|
||||
UpdatedAt: time.Now(),
|
||||
UsageLimit: 1,
|
||||
UsedTimes: 0,
|
||||
Valid: true,
|
||||
},
|
||||
expectedStatus: http.StatusUnprocessableEntity,
|
||||
},
|
||||
{
|
||||
name: "Create Setup Key with expiration in the past",
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"go.uber.org/mock/gomock"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"go.uber.org/mock/gomock"
|
||||
pb "github.com/golang/protobuf/proto" //nolint
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// agentNetworkSettingsMigration is a local view of the agent_network_settings
|
||||
// table spanning both the legacy identity columns (cluster, subdomain) and
|
||||
// their replacement (domain, proxy_address), so the migrator can address all
|
||||
// four during the reshape without importing the current model.
|
||||
type agentNetworkSettingsMigration struct {
|
||||
AccountID string `gorm:"primaryKey"`
|
||||
Cluster string
|
||||
Subdomain string
|
||||
Domain string `gorm:"type:varchar(255)"`
|
||||
ProxyAddress string `gorm:"type:varchar(255)"`
|
||||
}
|
||||
|
||||
func (agentNetworkSettingsMigration) TableName() string { return "agent_network_settings" }
|
||||
|
||||
// MigrateAgentNetworkSettingsToDomain reshapes agent_network_settings from the
|
||||
// legacy (cluster, subdomain) identity columns to (domain, proxy_address):
|
||||
// domain becomes `<subdomain>.<cluster>` — the endpoint hostname the old
|
||||
// columns derived — and proxy_address becomes the cluster address, preserving
|
||||
// which proxy serves the account. Runs before AutoMigrate, which then creates
|
||||
// the unique index on the freshly backfilled domain column.
|
||||
//
|
||||
// A legacy row missing either half cannot be given an endpoint; the old
|
||||
// bootstrap always wrote both, so such a row indicates corruption and the
|
||||
// migration fails loudly rather than leaving an empty domain to collide with
|
||||
// the unique index confusingly.
|
||||
//
|
||||
// The transaction is real only on sqlite and postgres, where DDL is
|
||||
// transactional. MySQL implicitly commits around every ALTER TABLE, so there
|
||||
// each step stands alone; what makes an interrupted run resumable on MySQL is
|
||||
// that every step is guarded by the schema state it changes — the entry check
|
||||
// fires while either legacy column remains, the adds skip existing columns,
|
||||
// the backfill and its loud-failure check run only while the legacy cluster
|
||||
// column exists (they provably completed before any drop), and each drop
|
||||
// skips what is already gone.
|
||||
func MigrateAgentNetworkSettingsToDomain(ctx context.Context, db *gorm.DB) error {
|
||||
model := &agentNetworkSettingsMigration{}
|
||||
migrator := db.Migrator()
|
||||
|
||||
if !migrator.HasTable(model) {
|
||||
return nil
|
||||
}
|
||||
hasCluster := migrator.HasColumn(model, "cluster")
|
||||
if !hasCluster && !migrator.HasColumn(model, "subdomain") {
|
||||
// Fresh schema or already migrated — nothing to reshape.
|
||||
return nil
|
||||
}
|
||||
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
txMigrator := tx.Migrator()
|
||||
for _, field := range []string{"Domain", "ProxyAddress"} {
|
||||
if !txMigrator.HasColumn(model, field) {
|
||||
if err := txMigrator.AddColumn(model, field); err != nil {
|
||||
return fmt.Errorf("add %s column to agent_network_settings: %w", field, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hasCluster {
|
||||
concat := "subdomain || '.' || cluster"
|
||||
if tx.Name() == "mysql" {
|
||||
concat = "CONCAT(subdomain, '.', cluster)"
|
||||
}
|
||||
res := tx.Exec(fmt.Sprintf(
|
||||
"UPDATE agent_network_settings SET domain = %s, proxy_address = cluster WHERE (domain IS NULL OR domain = '') AND cluster <> '' AND subdomain <> ''",
|
||||
concat,
|
||||
))
|
||||
if res.Error != nil {
|
||||
return fmt.Errorf("backfill agent_network_settings domain: %w", res.Error)
|
||||
}
|
||||
|
||||
var unmigratable int64
|
||||
if err := tx.Model(model).Where("domain IS NULL OR domain = ''").Count(&unmigratable).Error; err != nil {
|
||||
return fmt.Errorf("count unmigratable agent_network_settings rows: %w", err)
|
||||
}
|
||||
if unmigratable > 0 {
|
||||
return fmt.Errorf(
|
||||
"%d agent_network_settings row(s) have no cluster/subdomain to derive an endpoint from; resolve them manually before upgrading",
|
||||
unmigratable,
|
||||
)
|
||||
}
|
||||
|
||||
if res.RowsAffected > 0 {
|
||||
log.WithContext(ctx).Infof("migrated %d agent_network_settings row(s) to domain/proxy_address", res.RowsAffected)
|
||||
}
|
||||
}
|
||||
|
||||
if txMigrator.HasIndex(model, "idx_agent_network_settings_cluster_subdomain") {
|
||||
if err := txMigrator.DropIndex(model, "idx_agent_network_settings_cluster_subdomain"); err != nil {
|
||||
return fmt.Errorf("drop legacy agent_network_settings index: %w", err)
|
||||
}
|
||||
}
|
||||
for _, field := range []string{"Cluster", "Subdomain"} {
|
||||
if txMigrator.HasColumn(model, field) {
|
||||
if err := txMigrator.DropColumn(model, field); err != nil {
|
||||
return fmt.Errorf("drop legacy agent_network_settings column %s: %w", field, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -736,3 +736,125 @@ func TestFoldCostAggregatesIntoBuckets_SkipsAlreadyMigrated(t *testing.T) {
|
||||
assert.InDelta(t, 0.004, row.OutputCostUSD, 1e-9)
|
||||
assert.InDelta(t, 0.01, row.TotalCostUSD(), 1e-9, "derived total sums the four buckets")
|
||||
}
|
||||
|
||||
// legacyAgentNetworkSettings is the pre-reshape schema: identity carried as
|
||||
// (cluster, subdomain) instead of (domain, proxy_address).
|
||||
type legacyAgentNetworkSettings struct {
|
||||
AccountID string `gorm:"primaryKey"`
|
||||
Cluster string
|
||||
Subdomain string
|
||||
EnableLogCollection bool
|
||||
}
|
||||
|
||||
func (legacyAgentNetworkSettings) TableName() string { return "agent_network_settings" }
|
||||
|
||||
// TestMigrateAgentNetworkSettingsToDomain_BackfillsAndDropsLegacyColumns pins
|
||||
// the reshape: domain becomes `<subdomain>.<cluster>`, proxy_address becomes
|
||||
// the cluster, the legacy columns are dropped, and non-identity fields ride
|
||||
// through untouched.
|
||||
func TestMigrateAgentNetworkSettingsToDomain_BackfillsAndDropsLegacyColumns(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := setupDatabase(t)
|
||||
require.NoError(t, db.Migrator().DropTable(&legacyAgentNetworkSettings{}))
|
||||
require.NoError(t, db.AutoMigrate(&legacyAgentNetworkSettings{}))
|
||||
require.NoError(t, db.Create(&legacyAgentNetworkSettings{
|
||||
AccountID: "acct-1", Cluster: "eu.proxy.netbird.io", Subdomain: "violet", EnableLogCollection: true,
|
||||
}).Error)
|
||||
require.NoError(t, db.Create(&legacyAgentNetworkSettings{
|
||||
AccountID: "acct-2", Cluster: "us.proxy.netbird.io", Subdomain: "violet",
|
||||
}).Error)
|
||||
|
||||
require.NoError(t, migration.MigrateAgentNetworkSettingsToDomain(ctx, db))
|
||||
require.NoError(t, db.AutoMigrate(&agentNetworkTypes.Settings{}),
|
||||
"AutoMigrate must create the domain unique index over the backfilled values")
|
||||
|
||||
var one, two agentNetworkTypes.Settings
|
||||
require.NoError(t, db.First(&one, "account_id = ?", "acct-1").Error)
|
||||
assert.Equal(t, "violet.eu.proxy.netbird.io", one.Domain, "domain must combine subdomain and cluster")
|
||||
assert.Equal(t, "eu.proxy.netbird.io", one.ProxyAddress, "proxy address must carry the cluster")
|
||||
assert.True(t, one.EnableLogCollection, "non-identity fields must ride through")
|
||||
require.NoError(t, db.First(&two, "account_id = ?", "acct-2").Error)
|
||||
assert.Equal(t, "violet.us.proxy.netbird.io", two.Domain,
|
||||
"duplicate labels on different clusters are distinct hostnames and must both survive")
|
||||
|
||||
migrator := db.Migrator()
|
||||
assert.False(t, migrator.HasColumn(&legacyAgentNetworkSettings{}, "cluster"), "legacy cluster column must be dropped")
|
||||
assert.False(t, migrator.HasColumn(&legacyAgentNetworkSettings{}, "subdomain"), "legacy subdomain column must be dropped")
|
||||
}
|
||||
|
||||
// TestMigrateAgentNetworkSettingsToDomain_SkipsAlreadyMigrated proves the
|
||||
// migration is safe to re-run: with no legacy column present it is a no-op.
|
||||
func TestMigrateAgentNetworkSettingsToDomain_SkipsAlreadyMigrated(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := setupDatabase(t)
|
||||
require.NoError(t, db.Migrator().DropTable(&agentNetworkTypes.Settings{}))
|
||||
require.NoError(t, db.AutoMigrate(&agentNetworkTypes.Settings{}))
|
||||
require.NoError(t, db.Create(&agentNetworkTypes.Settings{
|
||||
AccountID: "acct-1", Domain: "gw.example.com", ProxyAddress: "gw.example.com",
|
||||
}).Error)
|
||||
|
||||
require.NoError(t, migration.MigrateAgentNetworkSettingsToDomain(ctx, db),
|
||||
"running against an already-migrated table must be a no-op, not an error")
|
||||
|
||||
var row agentNetworkTypes.Settings
|
||||
require.NoError(t, db.First(&row, "account_id = ?", "acct-1").Error)
|
||||
assert.Equal(t, "gw.example.com", row.Domain, "migrated rows must be untouched")
|
||||
}
|
||||
|
||||
// TestMigrateAgentNetworkSettingsToDomain_FailsOnUnmigratableRow pins the
|
||||
// loud-failure contract: a legacy row missing its identity halves cannot be
|
||||
// given an endpoint, and silently leaving an empty domain would collide with
|
||||
// the unique index confusingly later.
|
||||
func TestMigrateAgentNetworkSettingsToDomain_FailsOnUnmigratableRow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := setupDatabase(t)
|
||||
require.NoError(t, db.Migrator().DropTable(&legacyAgentNetworkSettings{}))
|
||||
require.NoError(t, db.AutoMigrate(&legacyAgentNetworkSettings{}))
|
||||
require.NoError(t, db.Create(&legacyAgentNetworkSettings{
|
||||
AccountID: "acct-broken", Cluster: "", Subdomain: "",
|
||||
}).Error)
|
||||
|
||||
err := migration.MigrateAgentNetworkSettingsToDomain(ctx, db)
|
||||
require.Error(t, err, "a row with no identity to derive an endpoint from must fail the migration")
|
||||
assert.Contains(t, err.Error(), "resolve them manually", "the error must tell the operator what to do")
|
||||
}
|
||||
|
||||
// partialAgentNetworkSettings models the one non-atomic state a MySQL run can
|
||||
// be interrupted in: DDL auto-commits there, so a crash between the two legacy
|
||||
// column drops leaves subdomain behind while cluster (and the completed
|
||||
// backfill) are already committed.
|
||||
type partialAgentNetworkSettings struct {
|
||||
AccountID string `gorm:"primaryKey"`
|
||||
Subdomain string
|
||||
Domain string `gorm:"type:varchar(255)"`
|
||||
ProxyAddress string `gorm:"type:varchar(255)"`
|
||||
}
|
||||
|
||||
func (partialAgentNetworkSettings) TableName() string { return "agent_network_settings" }
|
||||
|
||||
// TestMigrateAgentNetworkSettingsToDomain_ResumesAfterPartialDrop pins MySQL
|
||||
// resumability: a rerun over the interrupted state must remove the leftover
|
||||
// subdomain column without re-running the backfill (the cluster column that
|
||||
// feeds it is gone) and without touching the migrated values.
|
||||
func TestMigrateAgentNetworkSettingsToDomain_ResumesAfterPartialDrop(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := setupDatabase(t)
|
||||
require.NoError(t, db.Migrator().DropTable(&partialAgentNetworkSettings{}))
|
||||
require.NoError(t, db.AutoMigrate(&partialAgentNetworkSettings{}))
|
||||
require.NoError(t, db.Create(&partialAgentNetworkSettings{
|
||||
AccountID: "acct-1", Subdomain: "violet",
|
||||
Domain: "violet.eu.proxy.netbird.io", ProxyAddress: "eu.proxy.netbird.io",
|
||||
}).Error)
|
||||
|
||||
require.NoError(t, migration.MigrateAgentNetworkSettingsToDomain(ctx, db),
|
||||
"a rerun over a partially-dropped schema must resume, not error")
|
||||
|
||||
migrator := db.Migrator()
|
||||
assert.False(t, migrator.HasColumn(&partialAgentNetworkSettings{}, "subdomain"),
|
||||
"the leftover legacy column must be dropped on resume")
|
||||
|
||||
var row agentNetworkTypes.Settings
|
||||
require.NoError(t, db.First(&row, "account_id = ?", "acct-1").Error)
|
||||
assert.Equal(t, "violet.eu.proxy.netbird.io", row.Domain, "migrated values must be untouched")
|
||||
assert.Equal(t, "eu.proxy.netbird.io", row.ProxyAddress, "migrated values must be untouched")
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
reverseproxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/rs/xid"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package permissions
|
||||
|
||||
//go:generate go run github.com/golang/mock/mockgen -package permissions -destination=manager_mock.go -source=./manager.go -build_flags=-mod=mod
|
||||
//go:generate go tool mockgen -package permissions -destination=manager_mock.go -source=./manager.go -build_flags=-mod=mod
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: ./manager.go
|
||||
//
|
||||
// Generated by this command:
|
||||
//
|
||||
// mockgen -package permissions -destination=manager_mock.go -source=./manager.go -build_flags=-mod=mod
|
||||
//
|
||||
|
||||
// Package permissions is a generated GoMock package.
|
||||
package permissions
|
||||
@@ -8,18 +13,19 @@ import (
|
||||
context "context"
|
||||
reflect "reflect"
|
||||
|
||||
gomock "github.com/golang/mock/gomock"
|
||||
account "github.com/netbirdio/netbird/management/server/account"
|
||||
modules "github.com/netbirdio/netbird/management/server/permissions/modules"
|
||||
operations "github.com/netbirdio/netbird/management/server/permissions/operations"
|
||||
roles "github.com/netbirdio/netbird/management/server/permissions/roles"
|
||||
types "github.com/netbirdio/netbird/management/server/types"
|
||||
gomock "go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
// MockManager is a mock of Manager interface.
|
||||
type MockManager struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockManagerMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockManagerMockRecorder is the mock recorder for MockManager.
|
||||
@@ -49,7 +55,7 @@ func (m *MockManager) GetPermissionsByRole(ctx context.Context, role types.UserR
|
||||
}
|
||||
|
||||
// GetPermissionsByRole indicates an expected call of GetPermissionsByRole.
|
||||
func (mr *MockManagerMockRecorder) GetPermissionsByRole(ctx, role interface{}) *gomock.Call {
|
||||
func (mr *MockManagerMockRecorder) GetPermissionsByRole(ctx, role any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPermissionsByRole", reflect.TypeOf((*MockManager)(nil).GetPermissionsByRole), ctx, role)
|
||||
}
|
||||
@@ -61,7 +67,7 @@ func (m *MockManager) SetAccountManager(accountManager account.Manager) {
|
||||
}
|
||||
|
||||
// SetAccountManager indicates an expected call of SetAccountManager.
|
||||
func (mr *MockManagerMockRecorder) SetAccountManager(accountManager interface{}) *gomock.Call {
|
||||
func (mr *MockManagerMockRecorder) SetAccountManager(accountManager any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetAccountManager", reflect.TypeOf((*MockManager)(nil).SetAccountManager), accountManager)
|
||||
}
|
||||
@@ -76,7 +82,7 @@ func (m *MockManager) ValidateAccountAccess(ctx context.Context, accountID strin
|
||||
}
|
||||
|
||||
// ValidateAccountAccess indicates an expected call of ValidateAccountAccess.
|
||||
func (mr *MockManagerMockRecorder) ValidateAccountAccess(ctx, accountID, user, allowOwnerAndAdmin interface{}) *gomock.Call {
|
||||
func (mr *MockManagerMockRecorder) ValidateAccountAccess(ctx, accountID, user, allowOwnerAndAdmin any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidateAccountAccess", reflect.TypeOf((*MockManager)(nil).ValidateAccountAccess), ctx, accountID, user, allowOwnerAndAdmin)
|
||||
}
|
||||
@@ -90,7 +96,7 @@ func (m *MockManager) ValidateRoleModuleAccess(ctx context.Context, accountID st
|
||||
}
|
||||
|
||||
// ValidateRoleModuleAccess indicates an expected call of ValidateRoleModuleAccess.
|
||||
func (mr *MockManagerMockRecorder) ValidateRoleModuleAccess(ctx, accountID, role, module, operation interface{}) *gomock.Call {
|
||||
func (mr *MockManagerMockRecorder) ValidateRoleModuleAccess(ctx, accountID, role, module, operation any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidateRoleModuleAccess", reflect.TypeOf((*MockManager)(nil).ValidateRoleModuleAccess), ctx, accountID, role, module, operation)
|
||||
}
|
||||
@@ -106,7 +112,7 @@ func (m *MockManager) ValidateUserPermissions(ctx context.Context, accountID, us
|
||||
}
|
||||
|
||||
// ValidateUserPermissions indicates an expected call of ValidateUserPermissions.
|
||||
func (mr *MockManagerMockRecorder) ValidateUserPermissions(ctx, accountID, userID, module, operation interface{}) *gomock.Call {
|
||||
func (mr *MockManagerMockRecorder) ValidateUserPermissions(ctx, accountID, userID, module, operation any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidateUserPermissions", reflect.TypeOf((*MockManager)(nil).ValidateUserPermissions), ctx, accountID, userID, module, operation)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/rs/xid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package settings
|
||||
|
||||
//go:generate go run github.com/golang/mock/mockgen -package settings -destination=manager_mock.go -source=./manager.go -build_flags=-mod=mod
|
||||
//go:generate go tool mockgen -package settings -destination=manager_mock.go -source=./manager.go -build_flags=-mod=mod
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: ./manager.go
|
||||
//
|
||||
// Generated by this command:
|
||||
//
|
||||
// mockgen -package settings -destination=manager_mock.go -source=./manager.go -build_flags=-mod=mod
|
||||
//
|
||||
|
||||
// Package settings is a generated GoMock package.
|
||||
package settings
|
||||
@@ -9,15 +14,16 @@ import (
|
||||
netip "net/netip"
|
||||
reflect "reflect"
|
||||
|
||||
gomock "github.com/golang/mock/gomock"
|
||||
extra_settings "github.com/netbirdio/netbird/management/server/integrations/extra_settings"
|
||||
types "github.com/netbirdio/netbird/management/server/types"
|
||||
gomock "go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
// MockManager is a mock of Manager interface.
|
||||
type MockManager struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockManagerMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockManagerMockRecorder is the mock recorder for MockManager.
|
||||
@@ -37,6 +43,22 @@ func (m *MockManager) EXPECT() *MockManagerMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// GetEffectiveNetworkRanges mocks base method.
|
||||
func (m *MockManager) GetEffectiveNetworkRanges(ctx context.Context, accountID string) (netip.Prefix, netip.Prefix, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetEffectiveNetworkRanges", ctx, accountID)
|
||||
ret0, _ := ret[0].(netip.Prefix)
|
||||
ret1, _ := ret[1].(netip.Prefix)
|
||||
ret2, _ := ret[2].(error)
|
||||
return ret0, ret1, ret2
|
||||
}
|
||||
|
||||
// GetEffectiveNetworkRanges indicates an expected call of GetEffectiveNetworkRanges.
|
||||
func (mr *MockManagerMockRecorder) GetEffectiveNetworkRanges(ctx, accountID any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEffectiveNetworkRanges", reflect.TypeOf((*MockManager)(nil).GetEffectiveNetworkRanges), ctx, accountID)
|
||||
}
|
||||
|
||||
// GetExtraSettings mocks base method.
|
||||
func (m *MockManager) GetExtraSettings(ctx context.Context, accountID string) (*types.ExtraSettings, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -47,7 +69,7 @@ func (m *MockManager) GetExtraSettings(ctx context.Context, accountID string) (*
|
||||
}
|
||||
|
||||
// GetExtraSettings indicates an expected call of GetExtraSettings.
|
||||
func (mr *MockManagerMockRecorder) GetExtraSettings(ctx, accountID interface{}) *gomock.Call {
|
||||
func (mr *MockManagerMockRecorder) GetExtraSettings(ctx, accountID any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetExtraSettings", reflect.TypeOf((*MockManager)(nil).GetExtraSettings), ctx, accountID)
|
||||
}
|
||||
@@ -76,7 +98,7 @@ func (m *MockManager) GetSettings(ctx context.Context, accountID, userID string)
|
||||
}
|
||||
|
||||
// GetSettings indicates an expected call of GetSettings.
|
||||
func (mr *MockManagerMockRecorder) GetSettings(ctx, accountID, userID interface{}) *gomock.Call {
|
||||
func (mr *MockManagerMockRecorder) GetSettings(ctx, accountID, userID any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSettings", reflect.TypeOf((*MockManager)(nil).GetSettings), ctx, accountID, userID)
|
||||
}
|
||||
@@ -91,23 +113,7 @@ func (m *MockManager) UpdateExtraSettings(ctx context.Context, accountID, userID
|
||||
}
|
||||
|
||||
// UpdateExtraSettings indicates an expected call of UpdateExtraSettings.
|
||||
func (mr *MockManagerMockRecorder) UpdateExtraSettings(ctx, accountID, userID, extraSettings interface{}) *gomock.Call {
|
||||
func (mr *MockManagerMockRecorder) UpdateExtraSettings(ctx, accountID, userID, extraSettings any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateExtraSettings", reflect.TypeOf((*MockManager)(nil).UpdateExtraSettings), ctx, accountID, userID, extraSettings)
|
||||
}
|
||||
|
||||
// GetEffectiveNetworkRanges mocks base method.
|
||||
func (m *MockManager) GetEffectiveNetworkRanges(ctx context.Context, accountID string) (netip.Prefix, netip.Prefix, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetEffectiveNetworkRanges", ctx, accountID)
|
||||
ret0, _ := ret[0].(netip.Prefix)
|
||||
ret1, _ := ret[1].(netip.Prefix)
|
||||
ret2, _ := ret[2].(error)
|
||||
return ret0, ret1, ret2
|
||||
}
|
||||
|
||||
// GetEffectiveNetworkRanges indicates an expected call of GetEffectiveNetworkRanges.
|
||||
func (mr *MockManagerMockRecorder) GetEffectiveNetworkRanges(ctx, accountID interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEffectiveNetworkRanges", reflect.TypeOf((*MockManager)(nil).GetEffectiveNetworkRanges), ctx, accountID)
|
||||
}
|
||||
|
||||
@@ -599,6 +599,34 @@ func (s *SqlStore) ApproveAccountPeers(ctx context.Context, accountID string) (i
|
||||
return int(result.RowsAffected), nil
|
||||
}
|
||||
|
||||
// RefreshPeerLastSeen updates only peer_status_last_seen. Every other status
|
||||
// column is left untouched: peer_status_connected and
|
||||
// peer_status_session_started_at belong to the sync stream that owns the
|
||||
// session, and a blind write here would corrupt the fencing
|
||||
// MarkPeerConnectedIfNewerSession relies on.
|
||||
//
|
||||
// LastSeen comes from the database clock for the same reason it does there: a
|
||||
// Go-side timestamp is taken before the write and can land after a connect that
|
||||
// used CURRENT_TIMESTAMP, dragging the column backwards.
|
||||
//
|
||||
// staleBefore carries the caller's throttle into the same statement, so
|
||||
// concurrent requests for one peer collapse into a single write instead of
|
||||
// each racing on its own stale read. The column is nullable — Status is an
|
||||
// embedded pointer, so a peer stored without one leaves it NULL — and NULL
|
||||
// loses every comparison, hence the explicit branch for a peer never seen.
|
||||
func (s *SqlStore) RefreshPeerLastSeen(ctx context.Context, accountID, peerID string, staleBefore time.Time) (bool, error) {
|
||||
result := s.db.WithContext(ctx).
|
||||
Model(&nbpeer.Peer{}).
|
||||
Where(accountAndIDQueryCondition, accountID, peerID).
|
||||
Where("(peer_status_last_seen IS NULL OR peer_status_last_seen < ?)", staleBefore).
|
||||
Update("peer_status_last_seen", gorm.Expr("CURRENT_TIMESTAMP"))
|
||||
if result.Error != nil {
|
||||
return false, status.Errorf(status.Internal, "refresh peer last seen: %v", result.Error)
|
||||
}
|
||||
|
||||
return result.RowsAffected > 0, nil
|
||||
}
|
||||
|
||||
// SaveUsers saves the given list of users to the database.
|
||||
func (s *SqlStore) SaveUsers(ctx context.Context, users []*types.User) error {
|
||||
if len(users) == 0 {
|
||||
@@ -6346,6 +6374,30 @@ func (s *SqlStore) CountProxiesByAccountID(ctx context.Context, accountID string
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// HasActiveProxyAtClusterAddress reports whether any proxy — shared or
|
||||
// account-scoped — is currently active at the given cluster address, using
|
||||
// the same connected-within-threshold window as the other active-proxy
|
||||
// queries. Backs the agent-network settings delete guard: settings cannot be
|
||||
// deleted while a proxy declares the endpoint hostname as its address.
|
||||
//
|
||||
// The comparison folds case on both sides: the caller passes a normalized
|
||||
// (lowercase) hostname, but proxies declare their cluster address verbatim
|
||||
// and Connect stores it unchanged, so on case-sensitive collations a proxy
|
||||
// declaring "GW.Example.com" would otherwise slip past the guard. Hostnames
|
||||
// are case-insensitive per RFC 4343; the guard must be too.
|
||||
func (s *SqlStore) HasActiveProxyAtClusterAddress(ctx context.Context, clusterAddress string) (bool, error) {
|
||||
var count int64
|
||||
result := s.db.
|
||||
Model(&proxy.Proxy{}).
|
||||
Where("LOWER(cluster_address) = LOWER(?) AND status = ? AND last_seen > ?", clusterAddress, proxy.StatusConnected, time.Now().Add(-proxyActiveThreshold)).
|
||||
Count(&count)
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to count active proxies at cluster address: %v", result.Error)
|
||||
return false, status.Errorf(status.Internal, "failed to count active proxies at cluster address")
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (s *SqlStore) IsClusterAddressConflicting(ctx context.Context, clusterAddress, accountID string) (bool, error) {
|
||||
var count int64
|
||||
result := s.db.
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
const activityAccountID = "activityAccountId"
|
||||
|
||||
func newActivityTestStore(t *testing.T) Store {
|
||||
t.Helper()
|
||||
|
||||
store, cleanUp, err := NewTestStoreFromSQL(context.Background(), "", t.TempDir())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(cleanUp)
|
||||
|
||||
require.NoError(t, store.SaveAccount(context.Background(), &types.Account{
|
||||
Id: activityAccountID,
|
||||
Domain: "activity.example.com",
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}))
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
func TestRefreshPeerLastSeen(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newActivityTestStore(t)
|
||||
stored := time.Now().UTC().Add(-3 * time.Hour)
|
||||
require.NoError(t, store.AddPeerToAccount(ctx, activityPeer(stored)))
|
||||
|
||||
refreshed, err := store.RefreshPeerLastSeen(ctx, activityAccountID, "activityPeer", time.Now().UTC().Add(-time.Hour))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, refreshed, "a peer seen three hours ago is stale enough to refresh")
|
||||
|
||||
peer, err := store.GetPeerByID(ctx, LockingStrengthNone, activityAccountID, "activityPeer")
|
||||
require.NoError(t, err)
|
||||
assert.WithinDuration(t, time.Now().UTC(), peer.Status.LastSeen.UTC(), time.Minute, "last seen should be stamped at write time")
|
||||
assert.True(t, peer.Status.LastSeen.After(stored), "last seen must move forward")
|
||||
}
|
||||
|
||||
// TestRefreshPeerLastSeenHonoursCutoff covers the throttle the caller relies on:
|
||||
// two concurrent requests both read the same stale peer, but only the statement
|
||||
// that still finds LastSeen behind the cutoff writes.
|
||||
func TestRefreshPeerLastSeenHonoursCutoff(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newActivityTestStore(t)
|
||||
stored := time.Now().UTC().Add(-10 * time.Minute)
|
||||
require.NoError(t, store.AddPeerToAccount(ctx, activityPeer(stored)))
|
||||
|
||||
refreshed, err := store.RefreshPeerLastSeen(ctx, activityAccountID, "activityPeer", time.Now().UTC().Add(-time.Hour))
|
||||
require.NoError(t, err)
|
||||
assert.False(t, refreshed, "a peer seen inside the interval must not be written")
|
||||
|
||||
peer, err := store.GetPeerByID(ctx, LockingStrengthNone, activityAccountID, "activityPeer")
|
||||
require.NoError(t, err)
|
||||
assert.WithinDuration(t, stored, peer.Status.LastSeen.UTC(), time.Second, "last seen must be left where it was")
|
||||
}
|
||||
|
||||
// TestRefreshPeerLastSeenRecordsNeverSeenPeer covers the nullable column. Status
|
||||
// is an embedded pointer, so a peer stored without one leaves last seen NULL,
|
||||
// and NULL loses the cutoff comparison — such a peer would never record its
|
||||
// first activity.
|
||||
func TestRefreshPeerLastSeenRecordsNeverSeenPeer(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newActivityTestStore(t)
|
||||
stored := activityPeer(time.Time{})
|
||||
stored.Status = nil
|
||||
require.NoError(t, store.AddPeerToAccount(ctx, stored))
|
||||
|
||||
refreshed, err := store.RefreshPeerLastSeen(ctx, activityAccountID, "activityPeer", time.Now().UTC().Add(-time.Hour))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, refreshed, "a peer that was never seen must record its first activity")
|
||||
|
||||
peer, err := store.GetPeerByID(ctx, LockingStrengthNone, activityAccountID, "activityPeer")
|
||||
require.NoError(t, err)
|
||||
assert.WithinDuration(t, time.Now().UTC(), peer.Status.LastSeen.UTC(), time.Minute, "last seen should be stamped at write time")
|
||||
}
|
||||
|
||||
// TestRefreshPeerLastSeenLeavesSessionStateAlone pins the column boundary: the
|
||||
// connected flag and the session token belong to the sync stream that owns the
|
||||
// peer's session, and a blind write here would corrupt its fencing. This is why
|
||||
// SavePeerStatus is not reused for an activity bump.
|
||||
func TestRefreshPeerLastSeenLeavesSessionStateAlone(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newActivityTestStore(t)
|
||||
|
||||
stored := activityPeer(time.Date(2026, 3, 1, 9, 0, 0, 0, time.UTC))
|
||||
stored.Status.Connected = true
|
||||
stored.Status.SessionStartedAt = 1234567890
|
||||
require.NoError(t, store.AddPeerToAccount(ctx, stored))
|
||||
|
||||
refreshed, err := store.RefreshPeerLastSeen(ctx, activityAccountID, "activityPeer", time.Now().UTC().Add(-time.Hour))
|
||||
require.NoError(t, err)
|
||||
require.True(t, refreshed, "the peer is stale enough to refresh")
|
||||
|
||||
peer, err := store.GetPeerByID(ctx, LockingStrengthNone, activityAccountID, "activityPeer")
|
||||
require.NoError(t, err)
|
||||
assert.WithinDuration(t, time.Now().UTC(), peer.Status.LastSeen.UTC(), time.Minute, "last seen should move forward")
|
||||
assert.True(t, peer.Status.Connected, "connected flag must survive an activity write")
|
||||
assert.Equal(t, int64(1234567890), peer.Status.SessionStartedAt, "session token must survive an activity write")
|
||||
}
|
||||
|
||||
func activityPeer(lastSeen time.Time) *nbpeer.Peer {
|
||||
return &nbpeer.Peer{
|
||||
ID: "activityPeer",
|
||||
AccountID: activityAccountID,
|
||||
Key: "activityPeerKey",
|
||||
IP: netip.MustParseAddr("100.64.0.9"),
|
||||
Name: "activity-peer",
|
||||
DNSLabel: "activity-peer",
|
||||
Status: &nbpeer.PeerStatus{LastSeen: lastSeen},
|
||||
}
|
||||
}
|
||||
@@ -315,25 +315,65 @@ func (s *SqlStore) GetAllAgentNetworkSettings(ctx context.Context, lockStrength
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
// GetAgentNetworkSettingsByCluster returns every Settings row pinned to
|
||||
// the given proxy cluster. Used by the bootstrap label generator to
|
||||
// build the set of subdomains already taken on a cluster.
|
||||
func (s *SqlStore) GetAgentNetworkSettingsByCluster(ctx context.Context, lockStrength LockingStrength, cluster string) ([]*agentNetworkTypes.Settings, error) {
|
||||
// GetAgentNetworkSettingsByProxyAddress returns every Settings row whose
|
||||
// gateway is served by the proxy declaring the given cluster address. Used by
|
||||
// cluster-scoped synthesis to find the accounts a shared proxy serves.
|
||||
func (s *SqlStore) GetAgentNetworkSettingsByProxyAddress(ctx context.Context, lockStrength LockingStrength, proxyAddress string) ([]*agentNetworkTypes.Settings, error) {
|
||||
tx := s.db
|
||||
if lockStrength != LockingStrengthNone {
|
||||
tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)})
|
||||
}
|
||||
|
||||
var settings []*agentNetworkTypes.Settings
|
||||
result := tx.Find(&settings, "cluster = ?", cluster)
|
||||
result := tx.Find(&settings, "proxy_address = ?", proxyAddress)
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to get agent network settings by cluster from store: %v", result.Error)
|
||||
return nil, status.Errorf(status.Internal, "failed to get agent network settings by cluster from store")
|
||||
log.WithContext(ctx).Errorf("failed to get agent network settings by proxy address from store: %v", result.Error)
|
||||
return nil, status.Errorf(status.Internal, "failed to get agent network settings by proxy address from store")
|
||||
}
|
||||
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
// GetAgentNetworkSettingsByDomain resolves the single Settings row holding the
|
||||
// given endpoint hostname — a point query on the domain unique index. Returns
|
||||
// status.NotFound when no account owns the domain.
|
||||
func (s *SqlStore) GetAgentNetworkSettingsByDomain(ctx context.Context, lockStrength LockingStrength, domain string) (*agentNetworkTypes.Settings, error) {
|
||||
tx := s.db
|
||||
if lockStrength != LockingStrengthNone {
|
||||
tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)})
|
||||
}
|
||||
|
||||
var settings agentNetworkTypes.Settings
|
||||
result := tx.Take(&settings, "domain = ?", domain)
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, status.Errorf(status.NotFound, "agent network settings for domain %s not found", domain)
|
||||
}
|
||||
|
||||
log.WithContext(ctx).Errorf("failed to get agent network settings by domain from store: %v", result.Error)
|
||||
return nil, status.Errorf(status.Internal, "failed to get agent network settings by domain from store")
|
||||
}
|
||||
|
||||
return &settings, nil
|
||||
}
|
||||
|
||||
// CreateAgentNetworkSettings inserts a new settings row.
|
||||
//
|
||||
// Unlike SaveAgentNetworkSettings (an upsert) this is a plain INSERT, and it
|
||||
// returns the driver error unwrapped. Both properties are required by the
|
||||
// bootstrap allocator: an upsert would overwrite whichever row it collided
|
||||
// with, and the allocator classifies the rejection by matching the driver's
|
||||
// message — a unique violation on the account primary key means a concurrent
|
||||
// bootstrap for the same account won, and one on the domain index means the
|
||||
// hostname is taken.
|
||||
func (s *SqlStore) CreateAgentNetworkSettings(ctx context.Context, settings *agentNetworkTypes.Settings) error {
|
||||
if err := s.db.Create(settings).Error; err != nil {
|
||||
log.WithContext(ctx).Debugf("failed to create agent network settings: %v", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveAgentNetworkSettings upserts the per-account Agent Network
|
||||
// settings row.
|
||||
func (s *SqlStore) SaveAgentNetworkSettings(ctx context.Context, settings *agentNetworkTypes.Settings) error {
|
||||
@@ -346,6 +386,25 @@ func (s *SqlStore) SaveAgentNetworkSettings(ctx context.Context, settings *agent
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteAgentNetworkSettings removes the per-account Agent Network settings
|
||||
// row, releasing the account's endpoint. Returns status.NotFound when no row
|
||||
// exists. The guards on the delete (no providers, no proxy actively serving
|
||||
// the endpoint) live in the manager, which runs this inside a transaction
|
||||
// after re-checking them under a row lock.
|
||||
func (s *SqlStore) DeleteAgentNetworkSettings(ctx context.Context, accountID string) error {
|
||||
result := s.db.Delete(&agentNetworkTypes.Settings{}, "account_id = ?", accountID)
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to delete agent network settings from store: %v", result.Error)
|
||||
return status.Errorf(status.Internal, "failed to delete agent network settings from store")
|
||||
}
|
||||
|
||||
if result.RowsAffected == 0 {
|
||||
return status.Errorf(status.NotFound, "agent network settings for account %s not found", accountID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IncrementAgentNetworkConsumption atomically upserts the consumption
|
||||
// row keyed on (account, dim_kind, dim_id, window_seconds, window_start)
|
||||
// and adds the supplied deltas. Concurrent calls from multiple proxy
|
||||
|
||||
@@ -88,9 +88,9 @@ func TestAgentNetworkSettings_RealStore_CollectionTogglesRoundTrip(t *testing.T)
|
||||
|
||||
const accountID = "acc-settings-toggles"
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, &agentNetworkTypes.Settings{
|
||||
AccountID: accountID,
|
||||
Cluster: "eu.proxy.netbird.io",
|
||||
Subdomain: "violet",
|
||||
AccountID: accountID,
|
||||
Domain: "violet.eu.proxy.netbird.io",
|
||||
ProxyAddress: "eu.proxy.netbird.io",
|
||||
}))
|
||||
|
||||
got, err := s.GetAgentNetworkSettings(ctx, LockingStrengthNone, accountID)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package store
|
||||
|
||||
//go:generate go run github.com/golang/mock/mockgen -package store -destination=store_mock.go -source=./store.go -build_flags=-mod=mod
|
||||
//go:generate go tool mockgen -package store -destination=store_mock.go -source=./store.go -build_flags=-mod=mod
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -180,6 +180,14 @@ type Store interface {
|
||||
// Returns true when the update happened, false when this stream lost
|
||||
// the race against a newer session.
|
||||
MarkPeerConnectedIfNewerSession(ctx context.Context, accountID, peerID string, newSessionStartedAt int64) (bool, error)
|
||||
// RefreshPeerLastSeen records that a peer was just seen, stamping the
|
||||
// database clock like the other status writers. Connected and
|
||||
// SessionStartedAt are left alone, so this never interferes with the
|
||||
// session-ownership protocol MarkPeerConnectedIfNewerSession implements.
|
||||
// The write only lands when the stored LastSeen is older than
|
||||
// staleBefore, which keeps a caller's throttle atomic under concurrent
|
||||
// requests for the same peer. Returns true when the update happened.
|
||||
RefreshPeerLastSeen(ctx context.Context, accountID, peerID string, staleBefore time.Time) (bool, error)
|
||||
// MarkPeerDisconnectedIfSameSession sets the peer to disconnected and
|
||||
// resets SessionStartedAt to zero, but only when the stored
|
||||
// SessionStartedAt equals the given sessionStartedAt. LastSeen is
|
||||
@@ -328,6 +336,7 @@ type Store interface {
|
||||
GetProxyByAccountID(ctx context.Context, accountID string) (*proxy.Proxy, error)
|
||||
CountProxiesByAccountID(ctx context.Context, accountID string) (int64, error)
|
||||
IsClusterAddressConflicting(ctx context.Context, clusterAddress, accountID string) (bool, error)
|
||||
HasActiveProxyAtClusterAddress(ctx context.Context, clusterAddress string) (bool, error)
|
||||
DeleteAccountCluster(ctx context.Context, clusterAddress, accountID string) error
|
||||
|
||||
GetCustomDomainsCounts(ctx context.Context) (total int64, validated int64, err error)
|
||||
@@ -360,8 +369,11 @@ type Store interface {
|
||||
DeleteAgentNetworkGuardrail(ctx context.Context, accountID, guardrailID string) error
|
||||
GetAgentNetworkSettings(ctx context.Context, lockStrength LockingStrength, accountID string) (*agentNetworkTypes.Settings, error)
|
||||
GetAllAgentNetworkSettings(ctx context.Context, lockStrength LockingStrength) ([]*agentNetworkTypes.Settings, error)
|
||||
GetAgentNetworkSettingsByCluster(ctx context.Context, lockStrength LockingStrength, cluster string) ([]*agentNetworkTypes.Settings, error)
|
||||
GetAgentNetworkSettingsByProxyAddress(ctx context.Context, lockStrength LockingStrength, proxyAddress string) ([]*agentNetworkTypes.Settings, error)
|
||||
GetAgentNetworkSettingsByDomain(ctx context.Context, lockStrength LockingStrength, domain string) (*agentNetworkTypes.Settings, error)
|
||||
CreateAgentNetworkSettings(ctx context.Context, settings *agentNetworkTypes.Settings) error
|
||||
SaveAgentNetworkSettings(ctx context.Context, settings *agentNetworkTypes.Settings) error
|
||||
DeleteAgentNetworkSettings(ctx context.Context, accountID string) error
|
||||
IncrementAgentNetworkConsumption(ctx context.Context, accountID string, kind agentNetworkTypes.ConsumptionDimension, dimID string, windowSeconds int64, windowStart time.Time, tokensIn, tokensOut int64, costUSD float64) error
|
||||
IncrementAgentNetworkConsumptionBatch(ctx context.Context, accountID string, keys []agentNetworkTypes.ConsumptionKey, tokensIn, tokensOut int64, costUSD float64) error
|
||||
GetAgentNetworkConsumption(ctx context.Context, lockStrength LockingStrength, accountID string, kind agentNetworkTypes.ConsumptionDimension, dimID string, windowSeconds int64, windowStart time.Time) (*agentNetworkTypes.Consumption, error)
|
||||
@@ -608,6 +620,9 @@ func getMigrationsPreAuto(ctx context.Context) []migrationFunc {
|
||||
func(db *gorm.DB) error {
|
||||
return migration.BackfillPublicIDs[posture.Checks](ctx, db)
|
||||
},
|
||||
func(db *gorm.DB) error {
|
||||
return migration.MigrateAgentNetworkSettingsToDomain(ctx, db)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -91,6 +91,8 @@ type Account struct {
|
||||
Onboarding AccountOnboarding `gorm:"foreignKey:AccountID;references:id;constraint:OnDelete:CASCADE"`
|
||||
|
||||
ReverseProxyFreeDomainNonce string
|
||||
|
||||
PostureValidation map[string]map[string]bool `gorm:"-"`
|
||||
}
|
||||
|
||||
// this class is used by gorm only
|
||||
@@ -874,6 +876,7 @@ func (a *Account) Copy() *Account {
|
||||
Services: services,
|
||||
Onboarding: a.Onboarding,
|
||||
Domains: domains,
|
||||
PostureValidation: a.PostureValidation,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/zones"
|
||||
routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
|
||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||
"github.com/netbirdio/netbird/management/server/posture"
|
||||
"github.com/netbirdio/netbird/management/server/telemetry"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
)
|
||||
@@ -110,8 +112,6 @@ func (a *Account) GetPeerNetworkMapComponents(
|
||||
return EmptyNetworkMapComponents(&NetworkMapComponents{
|
||||
PeerID: peerID,
|
||||
Network: a.Network.Copy(),
|
||||
// must include the target peer as it's required on the client
|
||||
Peers: map[string]*ComponentPeer{peerID: peer.ToComponent()},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -506,8 +506,8 @@ func (a *Account) getPeersGroupsPoliciesRoutes(
|
||||
func (a *Account) getPeersFromGroups(ctx context.Context, groups []string, peerID string, sourcePostureChecksIDs []string,
|
||||
validatedPeersMap map[string]struct{}, postureFailedPeers *map[string]map[string]struct{}) ([]string, bool) {
|
||||
peerInGroups := false
|
||||
filteredPeerIDs := make([]string, 0, len(groups))
|
||||
seenPeerIds := make(map[string]struct{}, len(groups))
|
||||
var filteredPeerIDs []string
|
||||
var seenPeerIds map[string]struct{}
|
||||
|
||||
for _, gid := range groups {
|
||||
group := a.GetGroup(gid)
|
||||
@@ -547,6 +547,17 @@ func (a *Account) getPeersFromGroups(ctx context.Context, groups []string, peerI
|
||||
return filteredPeerIDs, peerInGroups
|
||||
}
|
||||
|
||||
if seenPeerIds == nil {
|
||||
totalGroupPeers := 0
|
||||
for _, g := range groups {
|
||||
if grp := a.GetGroup(g); grp != nil {
|
||||
totalGroupPeers += len(grp.Peers)
|
||||
}
|
||||
}
|
||||
filteredPeerIDs = make([]string, 0, totalGroupPeers)
|
||||
seenPeerIds = make(map[string]struct{}, totalGroupPeers)
|
||||
}
|
||||
|
||||
for _, pid := range group.Peers {
|
||||
if _, seen := seenPeerIds[pid]; seen {
|
||||
continue
|
||||
@@ -589,21 +600,109 @@ func (a *Account) validatePostureChecksOnPeerGetFailed(ctx context.Context, sour
|
||||
}
|
||||
|
||||
for _, postureChecksID := range sourcePostureChecksID {
|
||||
if valid, cached := a.cachedPostureCheckResult(postureChecksID, peerID); cached {
|
||||
if !valid {
|
||||
return false, postureChecksID
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
postureChecks := a.GetPostureChecks(postureChecksID)
|
||||
if postureChecks == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, check := range postureChecks.GetChecks() {
|
||||
isValid, _ := check.Check(ctx, *peer)
|
||||
if !isValid {
|
||||
return false, postureChecksID
|
||||
}
|
||||
if !peerPassesPostureChecks(ctx, postureChecks.GetChecks(), peer) {
|
||||
return false, postureChecksID
|
||||
}
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// PrecomputePostureValidation evaluates every posture check referenced by an enabled
|
||||
// policy once against the peers of that policy's source groups and stores the results,
|
||||
// so the per-peer network map calculations that follow look them up instead of
|
||||
// re-evaluating checks for every peer pair. It must be called before the account is
|
||||
// shared across goroutines; lookups not covered by the precomputed results fall back
|
||||
// to direct evaluation.
|
||||
func (a *Account) PrecomputePostureValidation(ctx context.Context) {
|
||||
if len(a.PostureChecks) == 0 {
|
||||
a.PostureValidation = nil
|
||||
return
|
||||
}
|
||||
|
||||
checkPeerIDs := make(map[string]map[string]struct{})
|
||||
for _, policy := range a.Policies {
|
||||
if !policy.Enabled || len(policy.SourcePostureChecks) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
peerIDs := a.getUniquePeerIDsFromGroupsIDs(ctx, policy.SourceGroups())
|
||||
for _, rule := range policy.Rules {
|
||||
if rule.SourceResource.Type == ResourceTypePeer && rule.SourceResource.ID != "" {
|
||||
peerIDs = append(peerIDs, rule.SourceResource.ID)
|
||||
}
|
||||
}
|
||||
|
||||
for _, postureChecksID := range policy.SourcePostureChecks {
|
||||
set := checkPeerIDs[postureChecksID]
|
||||
if set == nil {
|
||||
set = make(map[string]struct{}, len(peerIDs))
|
||||
checkPeerIDs[postureChecksID] = set
|
||||
}
|
||||
for _, pid := range peerIDs {
|
||||
set[pid] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results := make(map[string]map[string]bool, len(checkPeerIDs))
|
||||
for postureChecksID, peerIDs := range checkPeerIDs {
|
||||
results[postureChecksID] = a.evaluatePostureChecksForPeers(ctx, postureChecksID, peerIDs)
|
||||
}
|
||||
a.PostureValidation = results
|
||||
}
|
||||
|
||||
func (a *Account) evaluatePostureChecksForPeers(ctx context.Context, postureChecksID string, peerIDs map[string]struct{}) map[string]bool {
|
||||
postureChecks := a.GetPostureChecks(postureChecksID)
|
||||
if postureChecks == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
checks := postureChecks.GetChecks()
|
||||
results := make(map[string]bool, len(peerIDs))
|
||||
for peerID := range peerIDs {
|
||||
peer, ok := a.Peers[peerID]
|
||||
if !ok || peer == nil {
|
||||
continue
|
||||
}
|
||||
results[peerID] = peerPassesPostureChecks(ctx, checks, peer)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func (a *Account) cachedPostureCheckResult(postureChecksID, peerID string) (bool, bool) {
|
||||
results, ok := a.PostureValidation[postureChecksID]
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
if results == nil {
|
||||
return true, true
|
||||
}
|
||||
valid, found := results[peerID]
|
||||
return valid, found
|
||||
}
|
||||
|
||||
func peerPassesPostureChecks(ctx context.Context, checks []posture.Check, peer *nbpeer.Peer) bool {
|
||||
for _, check := range checks {
|
||||
isValid, _ := check.Check(ctx, *peer)
|
||||
if !isValid {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *Account) getPostureValidPeersSaveFailed(inputPeers []string, postureChecksIDs []string, validatedPeersMap map[string]struct{}, postureFailedPeers *map[string]map[string]struct{}) []string {
|
||||
var dest []string
|
||||
for _, peerID := range inputPeers {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/netbirdio/netbird/dns"
|
||||
"github.com/netbirdio/netbird/shared/management/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestGetPeerNetworkMapComponents_PeerMissingFromAcount(t *testing.T) {
|
||||
account := Account{Network: NewNetwork()}
|
||||
nmapcomponets := account.GetPeerNetworkMapComponents(context.TODO(), "missing-peer", dns.CustomZone{}, nil, nil, nil, nil, nil)
|
||||
|
||||
assert.Equal(t, EmptyNetworkMapComponents(&types.NetworkMapComponents{
|
||||
PeerID: "missing-peer",
|
||||
Network: account.Network,
|
||||
}), nmapcomponets)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package types_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
"github.com/netbirdio/netbird/management/server/posture"
|
||||
)
|
||||
|
||||
func TestPrecomputePostureValidation_MatchesDirectEvaluation(t *testing.T) {
|
||||
account, validatedPeers := scalableTestAccount(60, 5)
|
||||
|
||||
account.PostureChecks = append(account.PostureChecks, &posture.Checks{
|
||||
ID: "posture-check-strict", Name: "Strict version",
|
||||
Checks: posture.ChecksDefinition{
|
||||
NBVersionCheck: &posture.NBVersionCheck{MinVersion: "0.50.0"},
|
||||
},
|
||||
})
|
||||
account.Policies[0].SourcePostureChecks = []string{"posture-check-ver", "posture-check-unknown"}
|
||||
account.Policies[1].SourcePostureChecks = []string{"posture-check-strict"}
|
||||
account.Policies[2].SourcePostureChecks = []string{"posture-check-ver"}
|
||||
account.Policies[2].Enabled = false
|
||||
|
||||
ctx := context.Background()
|
||||
resourcePolicies := account.GetResourcePoliciesMap()
|
||||
routers := account.GetResourceRoutersMap()
|
||||
|
||||
type result struct {
|
||||
peers map[string]struct{}
|
||||
postureFailedPeers map[string]map[string]struct{}
|
||||
}
|
||||
snapshot := func() map[string]result {
|
||||
results := make(map[string]result, len(account.Peers))
|
||||
for peerID := range account.Peers {
|
||||
components := account.GetPeerNetworkMapComponents(ctx, peerID, nbdns.CustomZone{}, nil, validatedPeers, resourcePolicies, routers, nil)
|
||||
require.NotNil(t, components)
|
||||
peerSet := make(map[string]struct{}, len(components.Peers))
|
||||
for id := range components.Peers {
|
||||
peerSet[id] = struct{}{}
|
||||
}
|
||||
results[peerID] = result{peers: peerSet, postureFailedPeers: components.PostureFailedPeers}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
direct := snapshot()
|
||||
account.PrecomputePostureValidation(ctx)
|
||||
memoized := snapshot()
|
||||
|
||||
require.Equal(t, len(direct), len(memoized))
|
||||
for peerID, want := range direct {
|
||||
got := memoized[peerID]
|
||||
assert.Equal(t, want.peers, got.peers, "visible peers changed for %s", peerID)
|
||||
assert.Equal(t, want.postureFailedPeers, got.postureFailedPeers, "posture failed peers changed for %s", peerID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrecomputePostureValidation_NoPostureChecks(t *testing.T) {
|
||||
account, validatedPeers := scalableTestAccount(10, 2)
|
||||
account.PostureChecks = nil
|
||||
|
||||
ctx := context.Background()
|
||||
account.PrecomputePostureValidation(ctx)
|
||||
|
||||
components := account.GetPeerNetworkMapComponents(ctx, "peer-0", nbdns.CustomZone{}, nil, validatedPeers, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap(), nil)
|
||||
require.NotNil(t, components)
|
||||
assert.NotEmpty(t, components.Peers)
|
||||
}
|
||||
@@ -86,6 +86,43 @@ func BenchmarkNetworkMapGeneration_AllPeers(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for range b.N {
|
||||
account.PrecomputePostureValidation(ctx)
|
||||
for _, peerID := range peerIDs {
|
||||
_ = account.GetPeerNetworkMapFromComponents(ctx, peerID, nbdns.CustomZone{}, nil, validatedPeers, resourcePolicies, routers, nil, groupIDToUserIDs)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkNetworkMapGeneration_AllPeersPostureChecks benchmarks the UpdateAccountPeers
|
||||
// hot path with a posture check attached to the account-wide policy, so posture
|
||||
// validation runs for every source peer of every target peer's map.
|
||||
func BenchmarkNetworkMapGeneration_AllPeersPostureChecks(b *testing.B) {
|
||||
skipCIBenchmark(b)
|
||||
scales := []benchmarkScale{
|
||||
{"500peers_20groups", 500, 20},
|
||||
{"1000peers_50groups", 1000, 50},
|
||||
}
|
||||
|
||||
for _, scale := range scales {
|
||||
account, validatedPeers := scalableTestAccount(scale.peers, scale.groups)
|
||||
account.Policies[0].SourcePostureChecks = []string{"posture-check-ver"}
|
||||
ctx := context.Background()
|
||||
|
||||
peerIDs := make([]string, 0, len(account.Peers))
|
||||
for peerID := range account.Peers {
|
||||
peerIDs = append(peerIDs, peerID)
|
||||
}
|
||||
|
||||
b.Run("components/"+scale.name, func(b *testing.B) {
|
||||
resourcePolicies := account.GetResourcePoliciesMap()
|
||||
routers := account.GetResourceRoutersMap()
|
||||
groupIDToUserIDs := account.GetActiveGroupUsers()
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for range b.N {
|
||||
account.PrecomputePostureValidation(ctx)
|
||||
for _, peerID := range peerIDs {
|
||||
_ = account.GetPeerNetworkMapFromComponents(ctx, peerID, nbdns.CustomZone{}, nil, validatedPeers, resourcePolicies, routers, nil, groupIDToUserIDs)
|
||||
}
|
||||
|
||||
+63
-17
@@ -593,7 +593,8 @@ func (am *DefaultAccountManager) SaveOrAddUsers(ctx context.Context, accountID,
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var updateAccountPeers bool
|
||||
var snaps []*affectedpeers.Snapshot
|
||||
var changes []affectedpeers.Change
|
||||
var peersToExpire []*nbpeer.Peer
|
||||
var addUserEvents []func()
|
||||
var usersToSave = make([]*types.User, 0, len(updates))
|
||||
@@ -629,20 +630,25 @@ func (am *DefaultAccountManager) SaveOrAddUsers(ctx context.Context, accountID,
|
||||
}
|
||||
|
||||
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
|
||||
_, updatedUser, userPeersToExpire, userEvents, err := am.processUserUpdate(
|
||||
change, updatedUser, userPeersToExpire, userEvents, err := am.processUserUpdate(
|
||||
ctx, transaction, groupsMap, accountID, initiatorUserID, initiatorUser, update, addIfNotExists, settings,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to process update for user %s: %w", update.Id, err)
|
||||
}
|
||||
|
||||
updateAccountPeers = true
|
||||
|
||||
err = transaction.SaveUser(ctx, updatedUser)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save updated user %s: %w", update.Id, err)
|
||||
}
|
||||
|
||||
snap, err := affectedpeers.Load(ctx, transaction, accountID, change)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
snaps = append(snaps, snap)
|
||||
changes = append(changes, change)
|
||||
usersToSave = append(usersToSave, updatedUser)
|
||||
addUserEvents = append(addUserEvents, userEvents...)
|
||||
peersToExpire = append(peersToExpire, userPeersToExpire...)
|
||||
@@ -683,11 +689,11 @@ func (am *DefaultAccountManager) SaveOrAddUsers(ctx context.Context, accountID,
|
||||
log.WithContext(ctx).Errorf("failed update expired peers: %s", err)
|
||||
return nil, err
|
||||
}
|
||||
} else if updateAccountPeers {
|
||||
} else if len(usersToSave) > 0 {
|
||||
if err = am.Store.IncrementNetworkSerial(ctx, accountID); err != nil {
|
||||
return nil, fmt.Errorf("failed to increment network serial: %w", err)
|
||||
}
|
||||
am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceUser, Operation: types.UpdateOperationUpdate})
|
||||
go am.dispatchAffected(ctx, accountID, snaps, changes)
|
||||
}
|
||||
|
||||
return updatedUsersInfo, globalErr
|
||||
@@ -759,19 +765,21 @@ func (am *DefaultAccountManager) prepareUserUpdateEvents(ctx context.Context, ac
|
||||
}
|
||||
|
||||
func (am *DefaultAccountManager) processUserUpdate(ctx context.Context, transaction store.Store, groupsMap map[string]*types.Group,
|
||||
accountID, initiatorUserId string, initiatorUser, update *types.User, addIfNotExists bool, settings *types.Settings) (bool, *types.User, []*nbpeer.Peer, []func(), error) {
|
||||
accountID, initiatorUserId string, initiatorUser, update *types.User, addIfNotExists bool, settings *types.Settings) (affectedpeers.Change, *types.User, []*nbpeer.Peer, []func(), error) {
|
||||
|
||||
var change affectedpeers.Change
|
||||
|
||||
if update == nil {
|
||||
return false, nil, nil, nil, status.Errorf(status.InvalidArgument, "provided user update is nil")
|
||||
return change, nil, nil, nil, status.Errorf(status.InvalidArgument, "provided user update is nil")
|
||||
}
|
||||
|
||||
oldUser, isNewUser, err := getUserOrCreateIfNotExists(ctx, transaction, accountID, update, addIfNotExists)
|
||||
if err != nil {
|
||||
return false, nil, nil, nil, err
|
||||
return change, nil, nil, nil, err
|
||||
}
|
||||
|
||||
if err := validateUserUpdate(groupsMap, initiatorUser, oldUser, update); err != nil {
|
||||
return false, nil, nil, nil, err
|
||||
return change, nil, nil, nil, err
|
||||
}
|
||||
|
||||
// only auto groups, revoked status, and integration reference can be updated for now
|
||||
@@ -792,13 +800,13 @@ func (am *DefaultAccountManager) processUserUpdate(ctx context.Context, transact
|
||||
var transferredOwnerRole bool
|
||||
result, err := handleOwnerRoleTransfer(ctx, transaction, initiatorUser, update)
|
||||
if err != nil {
|
||||
return false, nil, nil, nil, err
|
||||
return change, nil, nil, nil, err
|
||||
}
|
||||
transferredOwnerRole = result
|
||||
|
||||
userPeers, err := transaction.GetUserPeers(ctx, store.LockingStrengthNone, updatedUser.AccountID, update.Id)
|
||||
if err != nil {
|
||||
return false, nil, nil, nil, err
|
||||
return change, nil, nil, nil, err
|
||||
}
|
||||
|
||||
var peersToExpire []*nbpeer.Peer
|
||||
@@ -807,6 +815,32 @@ func (am *DefaultAccountManager) processUserUpdate(ctx context.Context, transact
|
||||
peersToExpire = userPeers
|
||||
}
|
||||
|
||||
// A user reaches a peer's network map only through the SSH rules: as part of a
|
||||
// group -> user mapping, and as part of the account's allowed-user set. Creating,
|
||||
// blocking or unblocking a user adds it to or removes it from both, so every group
|
||||
// it maps into changes — including the All group that holds every active user.
|
||||
// Otherwise only the auto-groups it joined or left do.
|
||||
if isNewUser || oldUser.IsBlocked() != updatedUser.IsBlocked() {
|
||||
change.AllowedUsersChanged = true
|
||||
change.UserGroupIDs = slices.Concat(oldUser.AutoGroups, updatedUser.AutoGroups, allGroupIDs(groupsMap))
|
||||
} else {
|
||||
change.UserGroupIDs = slices.Concat(
|
||||
util.Difference(oldUser.AutoGroups, updatedUser.AutoGroups),
|
||||
util.Difference(updatedUser.AutoGroups, oldUser.AutoGroups),
|
||||
)
|
||||
}
|
||||
|
||||
// The user's peers are the changed entity in every scenario the update can
|
||||
// produce — group membership, IPv6 assignment, SSH mappings — so they refresh
|
||||
// together with every peer they can connect to, like on a regular peer update.
|
||||
// An update that changes neither the auto-groups nor the active-user set has no
|
||||
// peer-visible effect and refreshes nobody.
|
||||
if len(change.UserGroupIDs) > 0 || change.AllowedUsersChanged {
|
||||
for _, peer := range userPeers {
|
||||
change.ChangedPeerIDs = append(change.ChangedPeerIDs, peer.ID)
|
||||
}
|
||||
}
|
||||
|
||||
var removedGroups, addedGroups []string
|
||||
if update.AutoGroups != nil && settings.GroupsPropagationEnabled {
|
||||
removedGroups = util.Difference(oldUser.AutoGroups, update.AutoGroups)
|
||||
@@ -814,26 +848,38 @@ func (am *DefaultAccountManager) processUserUpdate(ctx context.Context, transact
|
||||
for _, peer := range userPeers {
|
||||
for _, groupID := range removedGroups {
|
||||
if err := transaction.RemovePeerFromGroup(ctx, peer.ID, groupID); err != nil {
|
||||
return false, nil, nil, nil, fmt.Errorf("failed to remove peer %s from group %s: %w", peer.ID, groupID, err)
|
||||
return change, nil, nil, nil, fmt.Errorf("failed to remove peer %s from group %s: %w", peer.ID, groupID, err)
|
||||
}
|
||||
}
|
||||
for _, groupID := range addedGroups {
|
||||
if err := transaction.AddPeerToGroup(ctx, accountID, peer.ID, groupID); err != nil {
|
||||
return false, nil, nil, nil, fmt.Errorf("failed to add peer %s to group %s: %w", peer.ID, groupID, err)
|
||||
return change, nil, nil, nil, fmt.Errorf("failed to add peer %s to group %s: %w", peer.ID, groupID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
allGroupChanges := slices.Concat(removedGroups, addedGroups)
|
||||
change.LinkGroups = allGroupChanges
|
||||
|
||||
if err := am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, allGroupChanges); err != nil {
|
||||
return false, nil, nil, nil, fmt.Errorf("reconcile IPv6 for group changes: %w", err)
|
||||
return change, nil, nil, nil, fmt.Errorf("reconcile IPv6 for group changes: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
updateAccountPeers := len(userPeers) > 0
|
||||
userEventsToAdd := am.prepareUserUpdateEvents(ctx, updatedUser.AccountID, initiatorUserId, oldUser, updatedUser, transferredOwnerRole, isNewUser, removedGroups, addedGroups, transaction)
|
||||
|
||||
return updateAccountPeers, updatedUser, peersToExpire, userEventsToAdd, nil
|
||||
return change, updatedUser, peersToExpire, userEventsToAdd, nil
|
||||
}
|
||||
|
||||
// allGroupIDs returns the ID of the account's All group, which every active user maps
|
||||
// into, as a slice so callers can concatenate it.
|
||||
func allGroupIDs(groupsMap map[string]*types.Group) []string {
|
||||
for _, group := range groupsMap {
|
||||
if group.IsGroupAll() {
|
||||
return []string{group.ID}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getUserOrCreateIfNotExists retrieves the existing user or creates a new one if it doesn't exist.
|
||||
|
||||
Reference in New Issue
Block a user