Merge branch 'main' of github.com:netbirdio/netbird into feat/local-user-totp

This commit is contained in:
jnfrati
2026-05-08 11:15:47 +02:00
233 changed files with 10366 additions and 2875 deletions

View File

@@ -329,6 +329,13 @@ func (am *DefaultAccountManager) UpdateAccountSettings(ctx context.Context, acco
updateAccountPeers = true
}
if ipv6SettingsChanged(oldSettings, newSettings) {
if err = am.updatePeerIPv6Addresses(ctx, transaction, accountID, newSettings); err != nil {
return err
}
updateAccountPeers = true
}
if oldSettings.RoutingPeerDNSResolutionEnabled != newSettings.RoutingPeerDNSResolutionEnabled ||
oldSettings.LazyConnectionEnabled != newSettings.LazyConnectionEnabled ||
oldSettings.DNSDomain != newSettings.DNSDomain ||
@@ -338,7 +345,7 @@ func (am *DefaultAccountManager) UpdateAccountSettings(ctx context.Context, acco
}
if oldSettings.GroupsPropagationEnabled != newSettings.GroupsPropagationEnabled && newSettings.GroupsPropagationEnabled {
groupsUpdated, groupChangesAffectPeers, err = propagateUserGroupMemberships(ctx, transaction, accountID)
groupsUpdated, groupChangesAffectPeers, err = am.propagateUserGroupMemberships(ctx, transaction, accountID)
if err != nil {
return err
}
@@ -396,6 +403,22 @@ func (am *DefaultAccountManager) UpdateAccountSettings(ctx context.Context, acco
}
am.StoreEvent(ctx, userID, accountID, accountID, activity.AccountNetworkRangeUpdated, eventMeta)
}
oldIPv6On := len(oldSettings.IPv6EnabledGroups) > 0
newIPv6On := len(newSettings.IPv6EnabledGroups) > 0
if oldIPv6On != newIPv6On {
if newIPv6On {
am.StoreEvent(ctx, userID, accountID, accountID, activity.AccountIPv6Enabled, nil)
} else {
am.StoreEvent(ctx, userID, accountID, accountID, activity.AccountIPv6Disabled, nil)
}
}
if oldSettings.NetworkRangeV6 != newSettings.NetworkRangeV6 {
eventMeta := map[string]any{
"old_network_range_v6": oldSettings.NetworkRangeV6.String(),
"new_network_range_v6": newSettings.NetworkRangeV6.String(),
}
am.StoreEvent(ctx, userID, accountID, accountID, activity.AccountNetworkRangeUpdated, eventMeta)
}
if reloadReverseProxy {
if err = am.serviceManager.ReloadAllServicesForAccount(ctx, accountID); err != nil {
log.WithContext(ctx).Warnf("failed to reload all services for account %s: %v", accountID, err)
@@ -409,6 +432,17 @@ func (am *DefaultAccountManager) UpdateAccountSettings(ctx context.Context, acco
return newSettings, nil
}
func ipv6SettingsChanged(old, updated *types.Settings) bool {
if old.NetworkRangeV6 != updated.NetworkRangeV6 {
return true
}
oldGroups := slices.Clone(old.IPv6EnabledGroups)
newGroups := slices.Clone(updated.IPv6EnabledGroups)
slices.Sort(oldGroups)
slices.Sort(newGroups)
return !slices.Equal(oldGroups, newGroups)
}
func (am *DefaultAccountManager) validateSettingsUpdate(ctx context.Context, transaction store.Store, newSettings, oldSettings *types.Settings, userID, accountID string) error {
halfYearLimit := 180 * 24 * time.Hour
if newSettings.PeerLoginExpiration > halfYearLimit {
@@ -435,9 +469,38 @@ func (am *DefaultAccountManager) validateSettingsUpdate(ctx context.Context, tra
}
}
if err := validateIPv6EnabledGroups(ctx, transaction, accountID, newSettings.IPv6EnabledGroups); err != nil {
return err
}
return am.integratedPeerValidator.ValidateExtraSettings(ctx, newSettings.Extra, oldSettings.Extra, userID, accountID)
}
// validateIPv6EnabledGroups checks that all referenced IPv6-enabled group IDs exist in the account.
func validateIPv6EnabledGroups(ctx context.Context, transaction store.Store, accountID string, groupIDs []string) error {
if len(groupIDs) == 0 {
return nil
}
groups, err := transaction.GetAccountGroups(ctx, store.LockingStrengthNone, accountID)
if err != nil {
return fmt.Errorf("get groups for IPv6 validation: %w", err)
}
existing := make(map[string]struct{}, len(groups))
for _, g := range groups {
existing[g.ID] = struct{}{}
}
for _, gid := range groupIDs {
if _, ok := existing[gid]; !ok {
return status.Errorf(status.InvalidArgument, "IPv6 enabled group %s does not exist", gid)
}
}
return nil
}
func (am *DefaultAccountManager) handleRoutingPeerDNSResolutionSettings(ctx context.Context, oldSettings, newSettings *types.Settings, userID, accountID string) {
if oldSettings.RoutingPeerDNSResolutionEnabled != newSettings.RoutingPeerDNSResolutionEnabled {
if newSettings.RoutingPeerDNSResolutionEnabled {
@@ -765,37 +828,8 @@ func (am *DefaultAccountManager) DeleteAccount(ctx context.Context, accountID, u
return status.Errorf(status.Internal, "failed to build user infos for account %s: %v", accountID, err)
}
for _, otherUser := range account.Users {
if otherUser.Id == userID {
continue
}
if otherUser.IsServiceUser {
err = am.deleteServiceUser(ctx, accountID, userID, otherUser)
if err != nil {
return err
}
continue
}
userInfo, ok := userInfosMap[otherUser.Id]
if !ok {
return status.Errorf(status.NotFound, "user info not found for user %s", otherUser.Id)
}
_, deleteUserErr := am.deleteRegularUser(ctx, accountID, userID, userInfo)
if deleteUserErr != nil {
return deleteUserErr
}
}
userInfo, ok := userInfosMap[userID]
if ok {
_, err = am.deleteRegularUser(ctx, accountID, userID, userInfo)
if err != nil {
log.WithContext(ctx).Errorf("failed deleting user %s. error: %s", userID, err)
return err
}
if err = am.deleteAccountUsers(ctx, accountID, userID, account.Users, userInfosMap); err != nil {
return err
}
err = am.Store.DeleteAccount(ctx, account)
@@ -813,6 +847,40 @@ func (am *DefaultAccountManager) DeleteAccount(ctx context.Context, accountID, u
return nil
}
func (am *DefaultAccountManager) deleteAccountUsers(ctx context.Context, accountID, initiatorUserID string, users map[string]*types.User, userInfosMap map[string]*types.UserInfo) error {
for _, otherUser := range users {
if otherUser.Id == initiatorUserID {
continue
}
if otherUser.IsServiceUser {
if err := am.deleteServiceUser(ctx, accountID, initiatorUserID, otherUser); err != nil {
return err
}
continue
}
userInfo, ok := userInfosMap[otherUser.Id]
if !ok {
return status.Errorf(status.NotFound, "user info not found for user %s", otherUser.Id)
}
if _, err := am.deleteRegularUser(ctx, accountID, initiatorUserID, userInfo); err != nil {
return err
}
}
userInfo, ok := userInfosMap[initiatorUserID]
if ok {
if _, err := am.deleteRegularUser(ctx, accountID, initiatorUserID, userInfo); err != nil {
log.WithContext(ctx).Errorf("failed deleting user %s. error: %s", initiatorUserID, err)
return err
}
}
return nil
}
// AccountExists checks if an account exists.
func (am *DefaultAccountManager) AccountExists(ctx context.Context, accountID string) (bool, error) {
return am.Store.AccountExists(ctx, store.LockingStrengthNone, accountID)
@@ -1554,6 +1622,11 @@ func (am *DefaultAccountManager) SyncUserJWTGroups(ctx context.Context, userAuth
}
}
allGroupChanges := slices.Concat(addNewGroups, removeOldGroups)
if err = am.reconcileIPv6ForGroupChanges(ctx, transaction, userAuth.AccountId, allGroupChanges); err != nil {
return fmt.Errorf("reconcile IPv6 for group changes: %w", err)
}
if err = transaction.IncrementNetworkSerial(ctx, userAuth.AccountId); err != nil {
return fmt.Errorf("error incrementing network serial: %w", err)
}
@@ -1939,6 +2012,11 @@ func newAccountWithId(ctx context.Context, accountID, userID, domain, email, nam
if err := acc.AddAllGroup(disableDefaultPolicy); err != nil {
log.WithContext(ctx).Errorf("error adding all group to account %s: %v", acc.Id, err)
}
if allGroup, err := acc.GetGroupAll(); err == nil {
acc.Settings.IPv6EnabledGroups = []string{allGroup.ID}
}
return acc
}
@@ -2045,6 +2123,10 @@ func (am *DefaultAccountManager) GetOrCreateAccountByPrivateDomain(ctx context.C
return nil, false, status.Errorf(status.Internal, "failed to add all group to new account by private domain")
}
if allGroup, err := newAccount.GetGroupAll(); err == nil {
newAccount.Settings.IPv6EnabledGroups = []string{allGroup.ID}
}
if err := am.Store.SaveAccount(ctx, newAccount); err != nil {
log.WithContext(ctx).WithFields(log.Fields{
"accountId": newAccount.Id,
@@ -2106,7 +2188,7 @@ func (am *DefaultAccountManager) UpdateToPrimaryAccount(ctx context.Context, acc
// propagateUserGroupMemberships propagates all account users' group memberships to their peers.
// Returns true if any groups were modified, true if those updates affect peers and an error.
func propagateUserGroupMemberships(ctx context.Context, transaction store.Store, accountID string) (groupsUpdated bool, peersAffected bool, err error) {
func (am *DefaultAccountManager) propagateUserGroupMemberships(ctx context.Context, transaction store.Store, accountID string) (groupsUpdated bool, peersAffected bool, err error) {
users, err := transaction.GetAccountUsers(ctx, store.LockingStrengthNone, accountID)
if err != nil {
return false, false, err
@@ -2128,29 +2210,13 @@ func propagateUserGroupMemberships(ctx context.Context, transaction store.Store,
}
}
updatedGroups := []string{}
for _, user := range users {
userPeers, err := transaction.GetUserPeers(ctx, store.LockingStrengthNone, accountID, user.Id)
if err != nil {
return false, false, err
}
updatedGroups, err := propagateAutoGroupsForUsers(ctx, transaction, accountID, users, accountGroupPeers)
if err != nil {
return false, false, err
}
for _, peer := range userPeers {
for _, groupID := range user.AutoGroups {
if _, exists := accountGroupPeers[groupID]; !exists {
// we do not wanna create the groups here
log.WithContext(ctx).Warnf("group %s does not exist for user group propagation", groupID)
continue
}
if _, exists := accountGroupPeers[groupID][peer.ID]; exists {
continue
}
if err := transaction.AddPeerToGroup(ctx, accountID, peer.ID, groupID); err != nil {
return false, false, fmt.Errorf("error adding peer %s to group %s: %w", peer.ID, groupID, err)
}
updatedGroups = append(updatedGroups, groupID)
}
}
if err = am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, updatedGroups); err != nil {
return false, false, fmt.Errorf("reconcile IPv6 for group changes: %w", err)
}
peersAffected, err = areGroupChangesAffectPeers(ctx, transaction, accountID, updatedGroups)
@@ -2161,6 +2227,35 @@ func propagateUserGroupMemberships(ctx context.Context, transaction store.Store,
return len(updatedGroups) > 0, peersAffected, nil
}
// propagateAutoGroupsForUsers adds each user's peers to their AutoGroups where not already present.
// Returns the list of group IDs that were modified.
func propagateAutoGroupsForUsers(ctx context.Context, transaction store.Store, accountID string, users []*types.User, accountGroupPeers map[string]map[string]struct{}) ([]string, error) {
var updatedGroups []string
for _, user := range users {
userPeers, err := transaction.GetUserPeers(ctx, store.LockingStrengthNone, accountID, user.Id)
if err != nil {
return nil, err
}
for _, peer := range userPeers {
for _, groupID := range user.AutoGroups {
if _, exists := accountGroupPeers[groupID]; !exists {
log.WithContext(ctx).Warnf("group %s does not exist for user group propagation", groupID)
continue
}
if _, exists := accountGroupPeers[groupID][peer.ID]; exists {
continue
}
if err := transaction.AddPeerToGroup(ctx, accountID, peer.ID, groupID); err != nil {
return nil, fmt.Errorf("error adding peer %s to group %s: %w", peer.ID, groupID, err)
}
updatedGroups = append(updatedGroups, groupID)
}
}
}
return updatedGroups, nil
}
// reallocateAccountPeerIPs re-allocates all peer IPs when the network range changes
func (am *DefaultAccountManager) reallocateAccountPeerIPs(ctx context.Context, transaction store.Store, accountID string, newNetworkRange netip.Prefix) error {
if !newNetworkRange.IsValid() {
@@ -2182,10 +2277,10 @@ func (am *DefaultAccountManager) reallocateAccountPeerIPs(ctx context.Context, t
return err
}
var takenIPs []net.IP
var takenIPs []netip.Addr
for _, peer := range peers {
newIP, err := types.AllocatePeerIP(newIPNet, takenIPs)
newIP, err := types.AllocatePeerIP(newNetworkRange, takenIPs)
if err != nil {
return status.Errorf(status.Internal, "allocate IP for peer %s: %v", peer.ID, err)
}
@@ -2209,13 +2304,199 @@ func (am *DefaultAccountManager) reallocateAccountPeerIPs(ctx context.Context, t
return nil
}
// updatePeerIPv6Addresses assigns or removes IPv6 addresses for all peers
// based on the current IPv6 settings. When IPv6 is enabled, peers without a
// v6 address get one allocated. When disabled, all v6 addresses are cleared.
// When the v6 range changes, all v6 addresses are reallocated.
func (am *DefaultAccountManager) checkIPv6Collision(ctx context.Context, transaction store.Store, accountID, peerID string, newIPv6 netip.Addr) error {
peers, err := transaction.GetAccountPeers(ctx, store.LockingStrengthShare, accountID, "", "")
if err != nil {
return fmt.Errorf("get peers: %w", err)
}
for _, p := range peers {
if p.ID != peerID && p.IPv6.IsValid() && p.IPv6 == newIPv6 {
return status.Errorf(status.InvalidArgument, "IPv6 %s is already assigned to peer %s", newIPv6, p.Name)
}
}
return nil
}
func (am *DefaultAccountManager) updatePeerIPv6Addresses(ctx context.Context, transaction store.Store, accountID string, settings *types.Settings) error {
peers, err := transaction.GetAccountPeers(ctx, store.LockingStrengthUpdate, accountID, "", "")
if err != nil {
return fmt.Errorf("get peers: %w", err)
}
network, err := transaction.GetAccountNetwork(ctx, store.LockingStrengthUpdate, accountID)
if err != nil {
return fmt.Errorf("get network: %w", err)
}
if err := am.ensureIPv6Subnet(ctx, transaction, accountID, settings, network); err != nil {
return err
}
allowedPeers, err := am.buildIPv6AllowedPeers(ctx, transaction, accountID, settings)
if err != nil {
return err
}
v6Prefix, err := netip.ParsePrefix(network.NetV6.String())
if err != nil {
return fmt.Errorf("parse IPv6 prefix: %w", err)
}
if err := am.assignPeerIPv6Addresses(ctx, transaction, accountID, peers, network, allowedPeers, v6Prefix); err != nil {
return err
}
log.WithContext(ctx).Infof("updated IPv6 addresses for %d peers in account %s (groups=%d)",
len(peers), accountID, len(settings.IPv6EnabledGroups))
return nil
}
// reconcileIPv6ForGroupChanges checks whether the given group IDs overlap with
// the account's IPv6EnabledGroups. If they do, it runs a full IPv6 address
// reconciliation so that peers gaining or losing membership in an IPv6-enabled
// group get their addresses assigned or removed.
func (am *DefaultAccountManager) reconcileIPv6ForGroupChanges(ctx context.Context, transaction store.Store, accountID string, groupIDs []string) error {
settings, err := transaction.GetAccountSettings(ctx, store.LockingStrengthNone, accountID)
if err != nil {
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 {
return nil
}
return am.updatePeerIPv6Addresses(ctx, transaction, accountID, settings)
}
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{
IP: settings.NetworkRangeV6.Masked().Addr().AsSlice(),
Mask: net.CIDRMask(settings.NetworkRangeV6.Bits(), 128),
}
return transaction.UpdateAccountNetworkV6(ctx, accountID, network.NetV6)
}
if network.NetV6.IP == nil {
r := rand.New(rand.NewSource(time.Now().UnixNano()))
network.NetV6 = types.AllocateIPv6Subnet(r)
// Sync settings to match the allocated subnet so SaveAccountSettings persists it.
ones, _ := network.NetV6.Mask.Size()
addr, _ := netip.AddrFromSlice(network.NetV6.IP)
settings.NetworkRangeV6 = netip.PrefixFrom(addr.Unmap(), ones)
return transaction.UpdateAccountNetworkV6(ctx, accountID, network.NetV6)
}
return nil
}
func (am *DefaultAccountManager) assignPeerIPv6Addresses(
ctx context.Context, transaction store.Store, accountID string,
peers []*nbpeer.Peer, network *types.Network,
allowedPeers map[string]struct{}, v6Prefix netip.Prefix,
) error {
takenV6 := make(map[netip.Addr]struct{})
for _, peer := range peers {
if _, ok := allowedPeers[peer.ID]; ok && peer.IPv6.IsValid() && network.NetV6.Contains(peer.IPv6.AsSlice()) {
takenV6[peer.IPv6] = struct{}{}
}
}
for _, peer := range peers {
_, allowed := allowedPeers[peer.ID]
oldIPv6 := peer.IPv6
if !allowed {
peer.IPv6 = netip.Addr{}
} else if !peer.IPv6.IsValid() || !network.NetV6.Contains(peer.IPv6.AsSlice()) {
newIP, err := allocateIPv6WithRetry(v6Prefix, takenV6, peer.ID)
if err != nil {
return err
}
peer.IPv6 = newIP
}
if peer.IPv6 == oldIPv6 {
continue
}
if err := transaction.SavePeer(ctx, accountID, peer); err != nil {
return fmt.Errorf("save peer %s: %w", peer.ID, err)
}
}
return nil
}
func allocateIPv6WithRetry(prefix netip.Prefix, taken map[netip.Addr]struct{}, peerID string) (netip.Addr, error) {
for attempts := 0; attempts < 10; attempts++ {
newIP, err := types.AllocateRandomPeerIPv6(prefix)
if err != nil {
return netip.Addr{}, fmt.Errorf("allocate v6 for peer %s: %w", peerID, err)
}
if _, ok := taken[newIP]; !ok {
taken[newIP] = struct{}{}
return newIP, nil
}
}
return netip.Addr{}, fmt.Errorf("allocate v6 for peer %s: exhausted 10 attempts", peerID)
}
func (am *DefaultAccountManager) buildIPv6AllowedPeers(ctx context.Context, transaction store.Store, accountID string, settings *types.Settings) (map[string]struct{}, error) {
if len(settings.IPv6EnabledGroups) == 0 {
return make(map[string]struct{}), nil
}
groups, err := transaction.GetAccountGroups(ctx, store.LockingStrengthNone, accountID)
if err != nil {
return nil, fmt.Errorf("get groups: %w", err)
}
enabledSet := make(map[string]struct{}, len(settings.IPv6EnabledGroups))
for _, gid := range settings.IPv6EnabledGroups {
enabledSet[gid] = struct{}{}
}
allowedPeers := make(map[string]struct{})
for _, group := range groups {
if _, ok := enabledSet[group.ID]; !ok {
continue
}
for _, peerID := range group.Peers {
allowedPeers[peerID] = struct{}{}
}
}
return allowedPeers, nil
}
func (am *DefaultAccountManager) validateIPForUpdate(account *types.Account, peers []*nbpeer.Peer, peerID string, newIP netip.Addr) error {
if !account.Network.Net.Contains(newIP.AsSlice()) {
return status.Errorf(status.InvalidArgument, "IP %s is not within the account network range %s", newIP.String(), account.Network.Net.String())
}
for _, peer := range peers {
if peer.ID != peerID && peer.IP.Equal(newIP.AsSlice()) {
if peer.ID != peerID && peer.IP == newIP {
return status.Errorf(status.InvalidArgument, "IP %s is already assigned to peer %s", newIP.String(), peer.ID)
}
}
@@ -2262,7 +2543,7 @@ func (am *DefaultAccountManager) updatePeerIPInTransaction(ctx context.Context,
return fmt.Errorf("get peer: %w", err)
}
if existingPeer.IP.Equal(newIP.AsSlice()) {
if existingPeer.IP == newIP {
return nil
}
@@ -2297,7 +2578,7 @@ func (am *DefaultAccountManager) savePeerIPUpdate(ctx context.Context, transacti
eventMeta := peer.EventMeta(dnsDomain)
oldIP := peer.IP.String()
peer.IP = newIP.AsSlice()
peer.IP = newIP
err = transaction.SavePeer(ctx, accountID, peer)
if err != nil {
return fmt.Errorf("save peer: %w", err)
@@ -2310,6 +2591,84 @@ func (am *DefaultAccountManager) savePeerIPUpdate(ctx context.Context, transacti
return nil
}
// UpdatePeerIPv6 updates the IPv6 overlay address of a peer, validating it's
// within the account's v6 network range and not already taken.
func (am *DefaultAccountManager) UpdatePeerIPv6(ctx context.Context, accountID, userID, peerID string, newIPv6 netip.Addr) error {
allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Update)
if err != nil {
return fmt.Errorf("validate user permissions: %w", err)
}
if !allowed {
return status.NewPermissionDeniedError()
}
var updateNetworkMap bool
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
var txErr error
updateNetworkMap, txErr = am.updatePeerIPv6InTransaction(ctx, transaction, accountID, peerID, newIPv6)
return txErr
})
if err != nil {
return err
}
if updateNetworkMap {
if err := am.networkMapController.OnPeersUpdated(ctx, accountID, []string{peerID}); err != nil {
return fmt.Errorf("notify network map controller: %w", err)
}
}
return nil
}
// updatePeerIPv6InTransaction validates and applies an IPv6 address change within a store transaction.
func (am *DefaultAccountManager) updatePeerIPv6InTransaction(ctx context.Context, transaction store.Store, accountID, peerID string, newIPv6 netip.Addr) (bool, error) {
network, err := transaction.GetAccountNetwork(ctx, store.LockingStrengthShare, accountID)
if err != nil {
return false, fmt.Errorf("get network: %w", err)
}
if network.NetV6.IP == nil {
return false, status.Errorf(status.PreconditionFailed, "IPv6 is not configured for this account")
}
if !network.NetV6.Contains(newIPv6.AsSlice()) {
return false, status.Errorf(status.InvalidArgument, "IP %s is not within the account IPv6 range %s", newIPv6, network.NetV6.String())
}
settings, err := transaction.GetAccountSettings(ctx, store.LockingStrengthShare, accountID)
if err != nil {
return false, fmt.Errorf("get settings: %w", err)
}
allowedPeers, err := am.buildIPv6AllowedPeers(ctx, transaction, accountID, settings)
if err != nil {
return false, err
}
if _, ok := allowedPeers[peerID]; !ok {
return false, status.Errorf(status.PreconditionFailed, "peer is not in any IPv6-enabled group")
}
peer, err := transaction.GetPeerByID(ctx, store.LockingStrengthUpdate, accountID, peerID)
if err != nil {
return false, fmt.Errorf("get peer: %w", err)
}
if peer.IPv6.IsValid() && peer.IPv6 == newIPv6 {
return false, nil
}
if err := am.checkIPv6Collision(ctx, transaction, accountID, peerID, newIPv6); err != nil {
return false, err
}
peer.IPv6 = newIPv6
if err := transaction.SavePeer(ctx, accountID, peer); err != nil {
return false, fmt.Errorf("save peer: %w", err)
}
return true, nil
}
func (am *DefaultAccountManager) GetUserIDByPeerKey(ctx context.Context, peerKey string) (string, error) {
return am.Store.GetUserIDByPeerKey(ctx, store.LockingStrengthNone, peerKey)
}

View File

@@ -65,6 +65,7 @@ type Manager interface {
DeletePeer(ctx context.Context, accountID, peerID, userID string) error
UpdatePeer(ctx context.Context, accountID, userID string, p *nbpeer.Peer) (*nbpeer.Peer, error)
UpdatePeerIP(ctx context.Context, accountID, userID, peerID string, newIP netip.Addr) error
UpdatePeerIPv6(ctx context.Context, accountID, userID, peerID string, newIPv6 netip.Addr) error
GetNetworkMap(ctx context.Context, peerID string) (*types.NetworkMap, error)
GetPeerNetwork(ctx context.Context, peerID string) (*types.Network, error)
AddPeer(ctx context.Context, accountID, setupKey, userID string, p *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error)

View File

@@ -1709,6 +1709,18 @@ func (mr *MockManagerMockRecorder) UpdatePeerIP(ctx, accountID, userID, peerID,
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdatePeerIP", reflect.TypeOf((*MockManager)(nil).UpdatePeerIP), ctx, accountID, userID, peerID, newIP)
}
func (m *MockManager) UpdatePeerIPv6(ctx context.Context, accountID, userID, peerID string, newIPv6 netip.Addr) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdatePeerIPv6", ctx, accountID, userID, peerID, newIPv6)
ret0, _ := ret[0].(error)
return ret0
}
func (mr *MockManagerMockRecorder) UpdatePeerIPv6(ctx, accountID, userID, peerID, newIPv6 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdatePeerIPv6", reflect.TypeOf((*MockManager)(nil).UpdatePeerIPv6), ctx, accountID, userID, peerID, newIPv6)
}
// UpdateToPrimaryAccount mocks base method.
func (m *MockManager) UpdateToPrimaryAccount(ctx context.Context, accountId string) error {
m.ctrl.T.Helper()

View File

@@ -160,7 +160,8 @@ func TestAccount_GetPeerNetworkMap(t *testing.T) {
"peer-1": {
ID: peerID1,
Key: "peer-1-key",
IP: net.IP{100, 64, 0, 1},
IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}),
IPv6: netip.MustParseAddr("fd00::6440:1"),
Name: peerID1,
DNSLabel: peerID1,
Status: &nbpeer.PeerStatus{
@@ -174,7 +175,8 @@ func TestAccount_GetPeerNetworkMap(t *testing.T) {
"peer-2": {
ID: peerID2,
Key: "peer-2-key",
IP: net.IP{100, 64, 0, 1},
IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}),
IPv6: netip.MustParseAddr("fd00::6440:1"),
Name: peerID2,
DNSLabel: peerID2,
Status: &nbpeer.PeerStatus{
@@ -198,7 +200,8 @@ func TestAccount_GetPeerNetworkMap(t *testing.T) {
"peer-1": {
ID: peerID1,
Key: "peer-1-key",
IP: net.IP{100, 64, 0, 1},
IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}),
IPv6: netip.MustParseAddr("fd00::6440:1"),
Name: peerID1,
DNSLabel: peerID1,
Status: &nbpeer.PeerStatus{
@@ -213,7 +216,8 @@ func TestAccount_GetPeerNetworkMap(t *testing.T) {
"peer-2": {
ID: peerID2,
Key: "peer-2-key",
IP: net.IP{100, 64, 0, 1},
IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}),
IPv6: netip.MustParseAddr("fd00::6440:1"),
Name: peerID2,
DNSLabel: peerID2,
Status: &nbpeer.PeerStatus{
@@ -237,7 +241,7 @@ func TestAccount_GetPeerNetworkMap(t *testing.T) {
// "peer-1": {
// ID: peerID1,
// Key: "peer-1-key",
// IP: net.IP{100, 64, 0, 1},
// IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}),
// Name: peerID1,
// DNSLabel: peerID1,
// Status: &PeerStatus{
@@ -251,7 +255,7 @@ func TestAccount_GetPeerNetworkMap(t *testing.T) {
// "peer-2": {
// ID: peerID2,
// Key: "peer-2-key",
// IP: net.IP{100, 64, 0, 1},
// IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}),
// Name: peerID2,
// DNSLabel: peerID2,
// Status: &PeerStatus{
@@ -265,7 +269,7 @@ func TestAccount_GetPeerNetworkMap(t *testing.T) {
// "peer-3": {
// ID: peerID3,
// Key: "peer-3-key",
// IP: net.IP{100, 64, 0, 1},
// IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}),
// Name: peerID3,
// DNSLabel: peerID3,
// Status: &PeerStatus{
@@ -288,7 +292,7 @@ func TestAccount_GetPeerNetworkMap(t *testing.T) {
// "peer-1": {
// ID: peerID1,
// Key: "peer-1-key",
// IP: net.IP{100, 64, 0, 1},
// IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}),
// Name: peerID1,
// DNSLabel: peerID1,
// Status: &PeerStatus{
@@ -302,7 +306,7 @@ func TestAccount_GetPeerNetworkMap(t *testing.T) {
// "peer-2": {
// ID: peerID2,
// Key: "peer-2-key",
// IP: net.IP{100, 64, 0, 1},
// IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}),
// Name: peerID2,
// DNSLabel: peerID2,
// Status: &PeerStatus{
@@ -316,7 +320,7 @@ func TestAccount_GetPeerNetworkMap(t *testing.T) {
// "peer-3": {
// ID: peerID3,
// Key: "peer-3-key",
// IP: net.IP{100, 64, 0, 1},
// IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}),
// Name: peerID3,
// DNSLabel: peerID3,
// Status: &PeerStatus{
@@ -339,7 +343,7 @@ func TestAccount_GetPeerNetworkMap(t *testing.T) {
// "peer-1": {
// ID: peerID1,
// Key: "peer-1-key",
// IP: net.IP{100, 64, 0, 1},
// IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}),
// Name: peerID1,
// DNSLabel: peerID1,
// Status: &PeerStatus{
@@ -353,7 +357,7 @@ func TestAccount_GetPeerNetworkMap(t *testing.T) {
// "peer-2": {
// ID: peerID2,
// Key: "peer-2-key",
// IP: net.IP{100, 64, 0, 1},
// IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}),
// Name: peerID2,
// DNSLabel: peerID2,
// Status: &PeerStatus{
@@ -367,7 +371,7 @@ func TestAccount_GetPeerNetworkMap(t *testing.T) {
// "peer-3": {
// ID: peerID3,
// Key: "peer-3-key",
// IP: net.IP{100, 64, 0, 1},
// IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}),
// Name: peerID3,
// DNSLabel: peerID3,
// Status: &PeerStatus{
@@ -1084,7 +1088,7 @@ func TestAccountManager_AddPeer(t *testing.T) {
t.Errorf("expecting just added peer to have key = %s, got %s", expectedPeerKey, peer.Key)
}
if !account.Network.Net.Contains(peer.IP) {
if !account.Network.Net.Contains(peer.IP.AsSlice()) {
t.Errorf("expecting just added peer's IP %s to be in a network range %s", peer.IP.String(), account.Network.Net.String())
}
@@ -1148,7 +1152,7 @@ func TestAccountManager_AddPeerWithUserID(t *testing.T) {
t.Errorf("expecting just added peer to have key = %s, got %s", expectedPeerKey, peer.Key)
}
if !account.Network.Net.Contains(peer.IP) {
if !account.Network.Net.Contains(peer.IP.AsSlice()) {
t.Errorf("expecting just added peer's IP %s to be in a network range %s", peer.IP.String(), account.Network.Net.String())
}
@@ -2788,11 +2792,46 @@ func TestAccount_SetJWTGroups(t *testing.T) {
account := &types.Account{
Id: "accountID",
Peers: map[string]*nbpeer.Peer{
"peer1": {ID: "peer1", Key: "key1", UserID: "user1", IP: net.IP{1, 1, 1, 1}, DNSLabel: "peer1.domain.test"},
"peer2": {ID: "peer2", Key: "key2", UserID: "user1", IP: net.IP{2, 2, 2, 2}, DNSLabel: "peer2.domain.test"},
"peer3": {ID: "peer3", Key: "key3", UserID: "user1", IP: net.IP{3, 3, 3, 3}, DNSLabel: "peer3.domain.test"},
"peer4": {ID: "peer4", Key: "key4", UserID: "user2", IP: net.IP{4, 4, 4, 4}, DNSLabel: "peer4.domain.test"},
"peer5": {ID: "peer5", Key: "key5", UserID: "user2", IP: net.IP{5, 5, 5, 5}, DNSLabel: "peer5.domain.test"},
"peer1": {
ID: "peer1",
Key: "key1",
UserID: "user1",
IP: netip.AddrFrom4([4]byte{1, 1, 1, 1}),
IPv6: netip.MustParseAddr("fd00::1"),
DNSLabel: "peer1.domain.test",
},
"peer2": {
ID: "peer2",
Key: "key2",
UserID: "user1",
IP: netip.AddrFrom4([4]byte{2, 2, 2, 2}),
IPv6: netip.MustParseAddr("fd00::2"),
DNSLabel: "peer2.domain.test",
},
"peer3": {
ID: "peer3",
Key: "key3",
UserID: "user1",
IP: netip.AddrFrom4([4]byte{3, 3, 3, 3}),
IPv6: netip.MustParseAddr("fd00::3"),
DNSLabel: "peer3.domain.test",
},
"peer4": {
ID: "peer4",
Key: "key4",
UserID: "user2",
IP: netip.AddrFrom4([4]byte{4, 4, 4, 4}),
IPv6: netip.MustParseAddr("fd00::4"),
DNSLabel: "peer4.domain.test",
},
"peer5": {
ID: "peer5",
Key: "key5",
UserID: "user2",
IP: netip.AddrFrom4([4]byte{5, 5, 5, 5}),
IPv6: netip.MustParseAddr("fd00::5"),
DNSLabel: "peer5.domain.test",
},
},
Groups: map[string]*types.Group{
"group1": {ID: "group1", Name: "group1", Issued: types.GroupIssuedAPI, Peers: []string{}},
@@ -3549,16 +3588,32 @@ func TestPropagateUserGroupMemberships(t *testing.T) {
account, err := manager.GetOrCreateAccountByUser(ctx, auth.UserAuth{UserId: initiatorId, Domain: domain})
require.NoError(t, err)
peer1 := &nbpeer.Peer{ID: "peer1", AccountID: account.Id, Key: "key1", UserID: initiatorId, IP: net.IP{1, 1, 1, 1}, DNSLabel: "peer1.domain.test"}
peer1 := &nbpeer.Peer{
ID: "peer1",
AccountID: account.Id,
Key: "key1",
UserID: initiatorId,
IP: netip.AddrFrom4([4]byte{1, 1, 1, 1}),
IPv6: netip.MustParseAddr("fd00::1"),
DNSLabel: "peer1.domain.test",
}
err = manager.Store.AddPeerToAccount(ctx, peer1)
require.NoError(t, err)
peer2 := &nbpeer.Peer{ID: "peer2", AccountID: account.Id, Key: "key2", UserID: initiatorId, IP: net.IP{2, 2, 2, 2}, DNSLabel: "peer2.domain.test"}
peer2 := &nbpeer.Peer{
ID: "peer2",
AccountID: account.Id,
Key: "key2",
UserID: initiatorId,
IP: netip.AddrFrom4([4]byte{2, 2, 2, 2}),
IPv6: netip.MustParseAddr("fd00::2"),
DNSLabel: "peer2.domain.test",
}
err = manager.Store.AddPeerToAccount(ctx, peer2)
require.NoError(t, err)
t.Run("should skip propagation when the user has no groups", func(t *testing.T) {
groupsUpdated, groupChangesAffectPeers, err := propagateUserGroupMemberships(ctx, manager.Store, account.Id)
groupsUpdated, groupChangesAffectPeers, err := manager.propagateUserGroupMemberships(ctx, manager.Store, account.Id)
require.NoError(t, err)
assert.False(t, groupsUpdated)
assert.False(t, groupChangesAffectPeers)
@@ -3574,7 +3629,7 @@ func TestPropagateUserGroupMemberships(t *testing.T) {
user.AutoGroups = append(user.AutoGroups, group1.ID)
require.NoError(t, manager.Store.SaveUser(ctx, user))
groupsUpdated, groupChangesAffectPeers, err := propagateUserGroupMemberships(ctx, manager.Store, account.Id)
groupsUpdated, groupChangesAffectPeers, err := manager.propagateUserGroupMemberships(ctx, manager.Store, account.Id)
require.NoError(t, err)
assert.True(t, groupsUpdated)
assert.False(t, groupChangesAffectPeers)
@@ -3612,7 +3667,7 @@ func TestPropagateUserGroupMemberships(t *testing.T) {
}, true)
require.NoError(t, err)
groupsUpdated, groupChangesAffectPeers, err := propagateUserGroupMemberships(ctx, manager.Store, account.Id)
groupsUpdated, groupChangesAffectPeers, err := manager.propagateUserGroupMemberships(ctx, manager.Store, account.Id)
require.NoError(t, err)
assert.True(t, groupsUpdated)
assert.True(t, groupChangesAffectPeers)
@@ -3627,7 +3682,7 @@ func TestPropagateUserGroupMemberships(t *testing.T) {
})
t.Run("should not update membership or account peers when no changes", func(t *testing.T) {
groupsUpdated, groupChangesAffectPeers, err := propagateUserGroupMemberships(ctx, manager.Store, account.Id)
groupsUpdated, groupChangesAffectPeers, err := manager.propagateUserGroupMemberships(ctx, manager.Store, account.Id)
require.NoError(t, err)
assert.False(t, groupsUpdated)
assert.False(t, groupChangesAffectPeers)
@@ -3640,7 +3695,7 @@ func TestPropagateUserGroupMemberships(t *testing.T) {
user.AutoGroups = []string{"group1"}
require.NoError(t, manager.Store.SaveUser(ctx, user))
groupsUpdated, groupChangesAffectPeers, err := propagateUserGroupMemberships(ctx, manager.Store, account.Id)
groupsUpdated, groupChangesAffectPeers, err := manager.propagateUserGroupMemberships(ctx, manager.Store, account.Id)
require.NoError(t, err)
assert.False(t, groupsUpdated)
assert.False(t, groupChangesAffectPeers)
@@ -3754,11 +3809,10 @@ func TestDefaultAccountManager_UpdatePeerIP(t *testing.T) {
account, err := manager.Store.GetAccount(context.Background(), accountID)
require.NoError(t, err, "unable to get account")
newIP, err := types.AllocatePeerIP(account.Network.Net, []net.IP{peer1.IP, peer2.IP})
newIP, err := types.AllocatePeerIP(netip.MustParsePrefix(account.Network.Net.String()), []netip.Addr{peer1.IP, peer2.IP})
require.NoError(t, err, "unable to allocate new IP")
newAddr := netip.MustParseAddr(newIP.String())
err = manager.UpdatePeerIP(context.Background(), accountID, userID, peer1.ID, newAddr)
err = manager.UpdatePeerIP(context.Background(), accountID, userID, peer1.ID, newIP)
require.NoError(t, err, "unable to update peer IP")
updatedPeer, err := manager.GetPeer(context.Background(), accountID, peer1.ID, userID)
@@ -3916,6 +3970,109 @@ func TestDefaultAccountManager_UpdateAccountSettings_NetworkRangeChange(t *testi
}
}
func TestDefaultAccountManager_UpdateAccountSettings_IPv6EnabledGroups(t *testing.T) {
manager, _, account, peer1, peer2, peer3 := setupNetworkMapTest(t)
ctx := context.Background()
accountID := account.Id
// New accounts default to All group in IPv6EnabledGroups, so all 3 peers should have IPv6.
settings, err := manager.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID)
require.NoError(t, err)
require.NotEmpty(t, settings.IPv6EnabledGroups, "new account should have IPv6 enabled for All group")
peers, err := manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "")
require.NoError(t, err)
for _, p := range peers {
assert.True(t, p.IPv6.IsValid(), "peer %s should have IPv6 with All group enabled", p.ID)
}
// Create a group with only peer1 and peer2.
partialGroup := &types.Group{
ID: "ipv6-partial-group",
AccountID: accountID,
Name: "IPv6Partial",
}
err = manager.Store.CreateGroup(ctx, partialGroup)
require.NoError(t, err)
require.NoError(t, manager.Store.AddPeerToGroup(ctx, accountID, peer1.ID, partialGroup.ID))
require.NoError(t, manager.Store.AddPeerToGroup(ctx, accountID, peer2.ID, partialGroup.ID))
// Switch IPv6EnabledGroups to only the partial group.
updatedSettings, err := manager.UpdateAccountSettings(ctx, accountID, userID, &types.Settings{
PeerLoginExpiration: types.DefaultPeerLoginExpiration,
PeerLoginExpirationEnabled: true,
IPv6EnabledGroups: []string{partialGroup.ID},
Extra: &types.ExtraSettings{},
})
require.NoError(t, err)
assert.Equal(t, []string{partialGroup.ID}, updatedSettings.IPv6EnabledGroups)
// peer1 and peer2 should have IPv6; peer3 should not.
peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "")
require.NoError(t, err)
peerMap := make(map[string]*nbpeer.Peer, len(peers))
for _, p := range peers {
peerMap[p.ID] = p
}
assert.True(t, peerMap[peer1.ID].IPv6.IsValid(), "peer1 in partial group should keep IPv6")
assert.True(t, peerMap[peer2.ID].IPv6.IsValid(), "peer2 in partial group should keep IPv6")
assert.False(t, peerMap[peer3.ID].IPv6.IsValid(), "peer3 not in partial group should lose IPv6")
// Clearing all groups disables IPv6 for everyone.
updatedSettings, err = manager.UpdateAccountSettings(ctx, accountID, userID, &types.Settings{
PeerLoginExpiration: types.DefaultPeerLoginExpiration,
PeerLoginExpirationEnabled: true,
IPv6EnabledGroups: []string{},
Extra: &types.ExtraSettings{},
})
require.NoError(t, err)
assert.Empty(t, updatedSettings.IPv6EnabledGroups)
peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "")
require.NoError(t, err)
for _, p := range peers {
assert.False(t, p.IPv6.IsValid(), "peer %s should have no IPv6 when groups cleared", p.ID)
}
// Re-enabling with the partial group should allocate IPv6 only for peer1 and peer2.
_, err = manager.UpdateAccountSettings(ctx, accountID, userID, &types.Settings{
PeerLoginExpiration: types.DefaultPeerLoginExpiration,
PeerLoginExpirationEnabled: true,
IPv6EnabledGroups: []string{partialGroup.ID},
Extra: &types.ExtraSettings{},
})
require.NoError(t, err)
peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "")
require.NoError(t, err)
peerMap = make(map[string]*nbpeer.Peer, len(peers))
for _, p := range peers {
peerMap[p.ID] = p
}
assert.True(t, peerMap[peer1.ID].IPv6.IsValid(), "peer1 should get IPv6 back")
assert.True(t, peerMap[peer2.ID].IPv6.IsValid(), "peer2 should get IPv6 back")
assert.False(t, peerMap[peer3.ID].IPv6.IsValid(), "peer3 still excluded")
// No-op update with the same groups should not cause errors.
_, err = manager.UpdateAccountSettings(ctx, accountID, userID, &types.Settings{
PeerLoginExpiration: types.DefaultPeerLoginExpiration,
PeerLoginExpirationEnabled: true,
IPv6EnabledGroups: []string{partialGroup.ID},
Extra: &types.ExtraSettings{},
})
require.NoError(t, err)
// Setting a nonexistent group ID should fail.
_, err = manager.UpdateAccountSettings(ctx, accountID, userID, &types.Settings{
PeerLoginExpiration: types.DefaultPeerLoginExpiration,
PeerLoginExpirationEnabled: true,
IPv6EnabledGroups: []string{"nonexistent-group-id"},
Extra: &types.ExtraSettings{},
})
require.Error(t, err)
assert.Contains(t, err.Error(), "does not exist")
}
func TestUpdateUserAuthWithSingleMode(t *testing.T) {
t.Run("sets defaults and overrides domain from store", func(t *testing.T) {
ctrl := gomock.NewController(t)

View File

@@ -231,6 +231,10 @@ const (
DomainDeleted Activity = 119
// DomainValidated indicates that a custom domain was validated
DomainValidated Activity = 120
// AccountIPv6Enabled indicates that a user enabled IPv6 overlay for the account
AccountIPv6Enabled Activity = 121
// AccountIPv6Disabled indicates that a user disabled IPv6 overlay for the account
AccountIPv6Disabled Activity = 122
// AccountLocalMfaEnabled indicates that a user enabled TOTP MFA for local users
AccountLocalMfaEnabled Activity = 121
@@ -352,6 +356,9 @@ var activityMap = map[Activity]Code{
AccountAutoUpdateAlwaysEnabled: {"Account auto-update always enabled", "account.setting.auto.update.always.enable"},
AccountAutoUpdateAlwaysDisabled: {"Account auto-update always disabled", "account.setting.auto.update.always.disable"},
AccountIPv6Enabled: {"Account IPv6 overlay enabled", "account.setting.ipv6.enable"},
AccountIPv6Disabled: {"Account IPv6 overlay disabled", "account.setting.ipv6.disable"},
IdentityProviderCreated: {"Identity provider created", "identityprovider.create"},
IdentityProviderUpdated: {"Identity provider updated", "identityprovider.update"},
IdentityProviderDeleted: {"Identity provider deleted", "identityprovider.delete"},

View File

@@ -174,6 +174,10 @@ func (am *DefaultAccountManager) UpdateGroup(ctx context.Context, accountID, use
return err
}
if err = am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, []string{newGroup.ID}); err != nil {
return err
}
return transaction.IncrementNetworkSerial(ctx, accountID)
})
if err != nil {
@@ -278,37 +282,17 @@ func (am *DefaultAccountManager) UpdateGroups(ctx context.Context, accountID, us
var globalErr error
groupIDs := make([]string, 0, len(groups))
for _, newGroup := range groups {
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
if err = validateNewGroup(ctx, transaction, accountID, newGroup); err != nil {
return err
}
newGroup.AccountID = accountID
if err = transaction.UpdateGroup(ctx, newGroup); err != nil {
return err
}
err = transaction.IncrementNetworkSerial(ctx, accountID)
if err != nil {
return err
}
events := am.prepareGroupEvents(ctx, transaction, accountID, userID, newGroup)
eventsToStore = append(eventsToStore, events...)
groupIDs = append(groupIDs, newGroup.ID)
return nil
})
events, err := am.updateSingleGroup(ctx, accountID, userID, newGroup)
if err != nil {
log.WithContext(ctx).Errorf("failed to update group %s: %v", newGroup.ID, err)
if len(groups) == 1 {
return err
}
globalErr = errors.Join(globalErr, err)
// continue updating other groups
continue
}
eventsToStore = append(eventsToStore, events...)
groupIDs = append(groupIDs, newGroup.ID)
}
updateAccountPeers, err = areGroupChangesAffectPeers(ctx, am.Store, accountID, groupIDs)
@@ -327,6 +311,33 @@ func (am *DefaultAccountManager) UpdateGroups(ctx context.Context, accountID, us
return globalErr
}
func (am *DefaultAccountManager) updateSingleGroup(ctx context.Context, accountID, userID string, newGroup *types.Group) ([]func(), error) {
var events []func()
err := am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
if err := validateNewGroup(ctx, transaction, accountID, newGroup); err != nil {
return err
}
newGroup.AccountID = accountID
if err := transaction.UpdateGroup(ctx, newGroup); err != nil {
return err
}
if err := am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, []string{newGroup.ID}); err != nil {
return err
}
if err := transaction.IncrementNetworkSerial(ctx, accountID); err != nil {
return err
}
events = am.prepareGroupEvents(ctx, transaction, accountID, userID, newGroup)
return nil
})
return events, err
}
// prepareGroupEvents prepares a list of event functions to be stored.
func (am *DefaultAccountManager) prepareGroupEvents(ctx context.Context, transaction store.Store, accountID, userID string, newGroup *types.Group) []func() {
var eventsToStore []func()
@@ -458,6 +469,10 @@ func (am *DefaultAccountManager) DeleteGroups(ctx context.Context, accountID, us
return err
}
if err = am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, groupIDsToDelete); err != nil {
return err
}
return transaction.IncrementNetworkSerial(ctx, accountID)
})
if err != nil {
@@ -486,6 +501,10 @@ func (am *DefaultAccountManager) GroupAddPeer(ctx context.Context, accountID, gr
return err
}
if err = am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, []string{groupID}); err != nil {
return err
}
return transaction.IncrementNetworkSerial(ctx, accountID)
})
if err != nil {
@@ -552,6 +571,10 @@ func (am *DefaultAccountManager) GroupDeletePeer(ctx context.Context, accountID,
return err
}
if err = am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, []string{groupID}); err != nil {
return err
}
return transaction.IncrementNetworkSerial(ctx, accountID)
})
if err != nil {

View File

@@ -0,0 +1,125 @@
package server
import (
"context"
"math/rand"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
nbpeer "github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/management/server/types"
)
// TestGroupIPv6Assignment verifies that peers gain or lose IPv6 addresses
// when they are added to or removed from an IPv6-enabled group.
func TestGroupIPv6Assignment(t *testing.T) {
am, _, err := createManager(t)
require.NoError(t, err)
ctx := context.Background()
userID := groupAdminUserID
account, err := createAccount(am, "ipv6-grp-test", userID, "ipv6test.example.com")
require.NoError(t, err)
// Allocate IPv6 subnet for the account
account.Network.NetV6 = types.AllocateIPv6Subnet(rand.New(rand.NewSource(time.Now().UnixNano())))
require.NoError(t, am.Store.SaveAccount(ctx, account))
// Create setup key
setupKey, err := am.CreateSetupKey(ctx, account.Id, "ipv6-key", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false)
require.NoError(t, err)
// Create an IPv6-enabled group
ipv6GroupID := "ipv6-enabled-grp"
err = am.CreateGroup(ctx, account.Id, userID, &types.Group{
ID: ipv6GroupID,
Name: "IPv6 Enabled",
Issued: types.GroupIssuedAPI,
Peers: []string{},
})
require.NoError(t, err)
// Enable IPv6 on that group
settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, account.Id)
require.NoError(t, err)
settings.IPv6EnabledGroups = []string{ipv6GroupID}
require.NoError(t, am.Store.SaveAccountSettings(ctx, account.Id, settings))
// Register a peer (will be in "All" group, not the IPv6 group)
key, err := wgtypes.GeneratePrivateKey()
require.NoError(t, err)
peer, _, _, err := am.AddPeer(ctx, "", setupKey.Key, "", &nbpeer.Peer{
Key: key.PublicKey().String(),
Meta: nbpeer.PeerSystemMeta{Hostname: "ipv6-test-host"},
}, false)
require.NoError(t, err)
assert.False(t, peer.IPv6.IsValid(), "peer should not have IPv6 before joining an IPv6-enabled group")
t.Run("GroupAddPeer assigns IPv6", func(t *testing.T) {
err := am.GroupAddPeer(ctx, account.Id, ipv6GroupID, peer.ID)
require.NoError(t, err)
p, err := am.Store.GetPeerByID(ctx, store.LockingStrengthNone, account.Id, peer.ID)
require.NoError(t, err)
assert.True(t, p.IPv6.IsValid(), "peer should have an IPv6 address after joining the group")
})
t.Run("GroupDeletePeer clears IPv6", func(t *testing.T) {
err := am.GroupDeletePeer(ctx, account.Id, ipv6GroupID, peer.ID)
require.NoError(t, err)
p, err := am.Store.GetPeerByID(ctx, store.LockingStrengthNone, account.Id, peer.ID)
require.NoError(t, err)
assert.False(t, p.IPv6.IsValid(), "peer should not have IPv6 after removal from the group")
})
t.Run("UpdateGroup with peer addition assigns IPv6", func(t *testing.T) {
grp, err := am.Store.GetGroupByID(ctx, store.LockingStrengthNone, account.Id, ipv6GroupID)
require.NoError(t, err)
grp.Peers = append(grp.Peers, peer.ID)
err = am.UpdateGroup(ctx, account.Id, userID, grp)
require.NoError(t, err)
p, err := am.Store.GetPeerByID(ctx, store.LockingStrengthNone, account.Id, peer.ID)
require.NoError(t, err)
assert.True(t, p.IPv6.IsValid(), "peer should have IPv6 after UpdateGroup adds it")
})
t.Run("UpdateGroup with peer removal clears IPv6", func(t *testing.T) {
grp, err := am.Store.GetGroupByID(ctx, store.LockingStrengthNone, account.Id, ipv6GroupID)
require.NoError(t, err)
grp.Peers = []string{}
err = am.UpdateGroup(ctx, account.Id, userID, grp)
require.NoError(t, err)
p, err := am.Store.GetPeerByID(ctx, store.LockingStrengthNone, account.Id, peer.ID)
require.NoError(t, err)
assert.False(t, p.IPv6.IsValid(), "peer should lose IPv6 after UpdateGroup removes it")
})
t.Run("non-IPv6 group changes do not affect IPv6", func(t *testing.T) {
err := am.CreateGroup(ctx, account.Id, userID, &types.Group{
ID: "regular-grp",
Name: "Regular Group",
Issued: types.GroupIssuedAPI,
Peers: []string{},
})
require.NoError(t, err)
err = am.GroupAddPeer(ctx, account.Id, "regular-grp", peer.ID)
require.NoError(t, err)
p, err := am.Store.GetPeerByID(ctx, store.LockingStrengthNone, account.Id, peer.ID)
require.NoError(t, err)
assert.False(t, p.IPv6.IsValid(), "peer should not get IPv6 from a non-IPv6 group")
})
}

View File

@@ -5,7 +5,6 @@ import (
"encoding/binary"
"errors"
"fmt"
"net"
"net/netip"
"strconv"
"sync"
@@ -999,10 +998,10 @@ func Test_AddPeerAndAddToAll(t *testing.T) {
assert.Equal(t, totalPeers, len(account.Peers), "Expected %d peers in account %s, got %d", totalPeers, accountID, len(account.Peers))
}
func uint32ToIP(n uint32) net.IP {
ip := make(net.IP, 4)
binary.BigEndian.PutUint32(ip, n)
return ip
func uint32ToIP(n uint32) netip.Addr {
var b [4]byte
binary.BigEndian.PutUint32(b[:], n)
return netip.AddrFrom4(b)
}
func Test_IncrementNetworkSerial(t *testing.T) {

View File

@@ -4,10 +4,13 @@ import (
"context"
"encoding/json"
"fmt"
"math"
"net/http"
"net/netip"
"time"
log "github.com/sirupsen/logrus"
"github.com/gorilla/mux"
goversion "github.com/hashicorp/go-version"
@@ -29,7 +32,9 @@ const (
// MinNetworkBits is the minimum prefix length for IPv4 network ranges (e.g., /29 gives 8 addresses, /28 gives 16)
MinNetworkBitsIPv4 = 28
// MinNetworkBitsIPv6 is the minimum prefix length for IPv6 network ranges
MinNetworkBitsIPv6 = 120
MinNetworkBitsIPv6 = 120
// MaxNetworkSizeIPv6 is the largest allowed IPv6 prefix (smallest number)
MaxNetworkSizeIPv6 = 48
disableAutoUpdate = "disabled"
autoUpdateLatestVersion = "latest"
)
@@ -76,12 +81,35 @@ func validateMinimumSize(prefix netip.Prefix) error {
if addr.Is4() && prefix.Bits() > MinNetworkBitsIPv4 {
return status.Errorf(status.InvalidArgument, "network range too small: minimum size is /%d for IPv4", MinNetworkBitsIPv4)
}
if addr.Is6() && prefix.Bits() > MinNetworkBitsIPv6 {
return status.Errorf(status.InvalidArgument, "network range too small: minimum size is /%d for IPv6", MinNetworkBitsIPv6)
if addr.Is6() {
if prefix.Bits() > MinNetworkBitsIPv6 {
return status.Errorf(status.InvalidArgument, "network range too small: minimum size is /%d for IPv6", MinNetworkBitsIPv6)
}
if prefix.Bits() < MaxNetworkSizeIPv6 {
return status.Errorf(status.InvalidArgument, "network range too large: maximum size is /%d for IPv6", MaxNetworkSizeIPv6)
}
}
return nil
}
func (h *handler) parseAndValidateNetworkRange(ctx context.Context, accountID, userID, rangeStr string, requireV6 bool) (netip.Prefix, error) {
prefix, err := netip.ParsePrefix(rangeStr)
if err != nil {
return netip.Prefix{}, status.Errorf(status.InvalidArgument, "invalid CIDR format: %v", err)
}
prefix = prefix.Masked()
if requireV6 && !prefix.Addr().Is6() {
return netip.Prefix{}, status.Errorf(status.InvalidArgument, "network range must be an IPv6 address")
}
if !requireV6 && prefix.Addr().Is6() {
return netip.Prefix{}, status.Errorf(status.InvalidArgument, "network range must be an IPv4 address")
}
if err := h.validateNetworkRange(ctx, accountID, userID, prefix); err != nil {
return netip.Prefix{}, err
}
return prefix, nil
}
func (h *handler) validateNetworkRange(ctx context.Context, accountID, userID string, networkRange netip.Prefix) error {
if !networkRange.IsValid() {
return nil
@@ -117,9 +145,12 @@ func (h *handler) validateCapacity(ctx context.Context, accountID, userID string
}
func calculateMaxHosts(prefix netip.Prefix) int64 {
availableAddresses := prefix.Addr().BitLen() - prefix.Bits()
maxHosts := int64(1) << availableAddresses
hostBits := prefix.Addr().BitLen() - prefix.Bits()
if hostBits >= 63 {
return math.MaxInt64
}
maxHosts := int64(1) << hostBits
if prefix.Addr().Is4() {
maxHosts -= 2 // network and broadcast addresses
}
@@ -164,6 +195,24 @@ func (h *handler) getAllAccounts(w http.ResponseWriter, r *http.Request) {
}
resp := toAccountResponse(accountID, settings, meta, onboarding)
// Populate effective network ranges when settings don't have explicit overrides.
if resp.Settings.NetworkRange == nil || resp.Settings.NetworkRangeV6 == nil {
v4, v6, err := h.settingsManager.GetEffectiveNetworkRanges(r.Context(), accountID)
if err != nil {
log.WithContext(r.Context()).Warnf("get effective network ranges: %v", err)
} else {
if resp.Settings.NetworkRange == nil && v4.IsValid() {
s := v4.String()
resp.Settings.NetworkRange = &s
}
if resp.Settings.NetworkRangeV6 == nil && v6.IsValid() {
s := v6.String()
resp.Settings.NetworkRangeV6 = &s
}
}
}
util.WriteJSONObject(r.Context(), w, []*api.Account{resp})
}
@@ -231,6 +280,9 @@ func (h *handler) updateAccountRequestSettings(req api.PutApiAccountsAccountIdJS
if req.Settings.LocalMfaEnabled != nil {
returnSettings.LocalMfaEnabled = *req.Settings.LocalMfaEnabled
}
if req.Settings.Ipv6EnabledGroups != nil {
returnSettings.IPv6EnabledGroups = *req.Settings.Ipv6EnabledGroups
}
return returnSettings, nil
}
@@ -265,18 +317,23 @@ func (h *handler) updateAccount(w http.ResponseWriter, r *http.Request) {
return
}
if req.Settings.NetworkRange != nil && *req.Settings.NetworkRange != "" {
prefix, err := netip.ParsePrefix(*req.Settings.NetworkRange)
prefix, err := h.parseAndValidateNetworkRange(r.Context(), accountID, userID, *req.Settings.NetworkRange, false)
if err != nil {
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "invalid CIDR format: %v", err), w)
return
}
if err := h.validateNetworkRange(r.Context(), accountID, userID, prefix); err != nil {
util.WriteError(r.Context(), err, w)
return
}
settings.NetworkRange = prefix
}
if req.Settings.NetworkRangeV6 != nil && *req.Settings.NetworkRangeV6 != "" {
prefix, err := h.parseAndValidateNetworkRange(r.Context(), accountID, userID, *req.Settings.NetworkRangeV6, true)
if err != nil {
util.WriteError(r.Context(), err, w)
return
}
settings.NetworkRangeV6 = prefix
}
var onboarding *types.AccountOnboarding
if req.Onboarding != nil {
onboarding = &types.AccountOnboarding{
@@ -355,6 +412,7 @@ func toAccountResponse(accountID string, settings *types.Settings, meta *types.A
DnsDomain: &settings.DNSDomain,
AutoUpdateVersion: &settings.AutoUpdateVersion,
AutoUpdateAlways: &settings.AutoUpdateAlways,
Ipv6EnabledGroups: &settings.IPv6EnabledGroups,
EmbeddedIdpEnabled: &settings.EmbeddedIdpEnabled,
LocalAuthDisabled: &settings.LocalAuthDisabled,
LocalMfaEnabled: &settings.LocalMfaEnabled,
@@ -364,6 +422,10 @@ func toAccountResponse(accountID string, settings *types.Settings, meta *types.A
networkRangeStr := settings.NetworkRange.String()
apiSettings.NetworkRange = &networkRangeStr
}
if settings.NetworkRangeV6.IsValid() {
networkRangeV6Str := settings.NetworkRangeV6.String()
apiSettings.NetworkRangeV6 = &networkRangeV6Str
}
apiOnboarding := api.AccountOnboarding{
OnboardingFlowPending: onboarding.OnboardingFlowPending,

View File

@@ -5,8 +5,10 @@ import (
"context"
"encoding/json"
"io"
"math"
"net/http"
"net/http/httptest"
"net/netip"
"testing"
"time"
@@ -31,6 +33,10 @@ func initAccountsTestData(t *testing.T, account *types.Account) *handler {
GetSettings(gomock.Any(), account.Id, "test_user").
Return(account.Settings, nil).
AnyTimes()
settingsMockManager.EXPECT().
GetEffectiveNetworkRanges(gomock.Any(), account.Id).
Return(netip.Prefix{}, netip.Prefix{}, nil).
AnyTimes()
return &handler{
accountManager: &mock_server.MockAccountManager{
@@ -342,3 +348,27 @@ func TestAccounts_AccountsHandler(t *testing.T) {
})
}
}
func TestCalculateMaxHosts(t *testing.T) {
tests := []struct {
name string
prefix string
min int64
}{
{"v4 /24", "100.64.0.0/24", 254},
{"v4 /16", "100.64.0.0/16", 65534},
{"v4 /28", "100.64.0.0/28", 14},
{"v6 /64", "fd00::/64", math.MaxInt64},
{"v6 /120", "fd00::/120", 256},
{"v6 /112", "fd00::/112", 65536},
{"v6 /48", "fd00::/48", math.MaxInt64},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
prefix := netip.MustParsePrefix(tt.prefix)
got := calculateMaxHosts(prefix)
assert.Equal(t, tt.min, got)
})
}
}

View File

@@ -3,7 +3,10 @@ package dns
import (
"encoding/json"
"fmt"
"net"
"net/http"
"strconv"
"strings"
"github.com/gorilla/mux"
log "github.com/sirupsen/logrus"
@@ -201,7 +204,11 @@ func (h *nameserversHandler) getNameserverGroup(w http.ResponseWriter, r *http.R
func toServerNSList(apiNSList []api.Nameserver) ([]nbdns.NameServer, error) {
var nsList []nbdns.NameServer
for _, apiNS := range apiNSList {
parsed, err := nbdns.ParseNameServerURL(fmt.Sprintf("%s://%s:%d", apiNS.NsType, apiNS.Ip, apiNS.Port))
host, err := unwrapBracketedHost(apiNS.Ip)
if err != nil {
return nil, err
}
parsed, err := nbdns.ParseNameServerURL(fmt.Sprintf("%s://%s", apiNS.NsType, net.JoinHostPort(host, strconv.Itoa(apiNS.Port))))
if err != nil {
return nil, err
}
@@ -211,6 +218,18 @@ func toServerNSList(apiNSList []api.Nameserver) ([]nbdns.NameServer, error) {
return nsList, nil
}
// unwrapBracketedHost returns ip with surrounding brackets stripped, rejecting
// inputs with mismatched brackets.
func unwrapBracketedHost(ip string) (string, error) {
if !strings.ContainsAny(ip, "[]") {
return ip, nil
}
if !strings.HasPrefix(ip, "[") || !strings.HasSuffix(ip, "]") {
return "", fmt.Errorf("malformed bracketed address: %s", ip)
}
return ip[1 : len(ip)-1], nil
}
func toNameserverGroupResponse(serverNSGroup *nbdns.NameServerGroup) *api.NameserverGroup {
var nsList []api.Nameserver
for _, ns := range serverNSGroup.NameServers {

View File

@@ -233,3 +233,37 @@ func TestNameserversHandlers(t *testing.T) {
})
}
}
func TestToServerNSList_IPv6(t *testing.T) {
tests := []struct {
name string
input []api.Nameserver
expectIP netip.Addr
}{
{
name: "IPv4",
input: []api.Nameserver{
{Ip: "1.1.1.1", NsType: "udp", Port: 53},
},
expectIP: netip.MustParseAddr("1.1.1.1"),
},
{
name: "IPv6",
input: []api.Nameserver{
{Ip: "2001:4860:4860::8888", NsType: "udp", Port: 53},
},
expectIP: netip.MustParseAddr("2001:4860:4860::8888"),
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result, err := toServerNSList(tc.input)
assert.NoError(t, err)
if assert.Len(t, result, 1) {
assert.Equal(t, tc.expectIP, result[0].IP)
assert.Equal(t, 53, result[0].Port)
}
})
}
}

View File

@@ -7,8 +7,8 @@ import (
"errors"
"fmt"
"io"
"net"
"net/http"
"net/netip"
"net/http/httptest"
"strings"
"testing"
@@ -29,8 +29,8 @@ import (
)
var TestPeers = map[string]*nbpeer.Peer{
"A": {Key: "A", ID: "peer-A-ID", IP: net.ParseIP("100.100.100.100")},
"B": {Key: "B", ID: "peer-B-ID", IP: net.ParseIP("200.200.200.200")},
"A": {Key: "A", ID: "peer-A-ID", IP: netip.MustParseAddr("100.100.100.100")},
"B": {Key: "B", ID: "peer-B-ID", IP: netip.MustParseAddr("200.200.200.200")},
}
func initGroupTestData(initGroups ...*types.Group) *handler {

View File

@@ -220,6 +220,18 @@ func (h *Handler) updatePeer(ctx context.Context, accountID, userID, peerID stri
}
}
if req.Ipv6 != nil {
v6Addr, err := parseIPv6(req.Ipv6)
if err != nil {
util.WriteError(ctx, status.Errorf(status.InvalidArgument, "%v", err), w)
return
}
if err = h.accountManager.UpdatePeerIPv6(ctx, accountID, userID, peerID, v6Addr); err != nil {
util.WriteError(ctx, err, w)
return
}
}
peer, err := h.accountManager.UpdatePeer(ctx, accountID, userID, update)
if err != nil {
util.WriteError(ctx, err, w)
@@ -355,6 +367,21 @@ func (h *Handler) setApprovalRequiredFlag(respBody []*api.PeerBatch, validPeersM
}
}
func parseIPv6(s *string) (netip.Addr, error) {
if s == nil {
return netip.Addr{}, fmt.Errorf("IPv6 address is nil")
}
addr, err := netip.ParseAddr(*s)
if err != nil {
return netip.Addr{}, fmt.Errorf("invalid IPv6 address %s: %w", *s, err)
}
addr = addr.Unmap()
if !addr.Is6() {
return netip.Addr{}, fmt.Errorf("address %s is not IPv6", *s)
}
return addr, nil
}
// GetAccessiblePeers returns a list of all peers that the specified peer can connect to within the network.
func (h *Handler) GetAccessiblePeers(w http.ResponseWriter, r *http.Request) {
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
@@ -529,6 +556,7 @@ func peerToAccessiblePeer(peer *nbpeer.Peer, dnsDomain string) api.AccessiblePee
GeonameId: int(peer.Location.GeoNameID),
Id: peer.ID,
Ip: peer.IP.String(),
Ipv6: peerIPv6String(peer),
LastSeen: peer.Status.LastSeen,
Name: peer.Name,
Os: peer.Meta.OS,
@@ -547,6 +575,7 @@ func toSinglePeerResponse(peer *nbpeer.Peer, groupsInfo []api.GroupMinimum, dnsD
Id: peer.ID,
Name: peer.Name,
Ip: peer.IP.String(),
Ipv6: peerIPv6String(peer),
ConnectionIp: peer.Location.ConnectionIP.String(),
Connected: peer.Status.Connected,
LastSeen: peer.Status.LastSeen,
@@ -601,6 +630,7 @@ func toPeerListItemResponse(peer *nbpeer.Peer, groupsInfo []api.GroupMinimum, dn
Id: peer.ID,
Name: peer.Name,
Ip: peer.IP.String(),
Ipv6: peerIPv6String(peer),
ConnectionIp: peer.Location.ConnectionIP.String(),
Connected: peer.Status.Connected,
LastSeen: peer.Status.LastSeen,
@@ -677,3 +707,11 @@ func fqdnList(extraLabels []string, dnsDomain string) []string {
}
return fqdnList
}
func peerIPv6String(peer *nbpeer.Peer) *string {
if !peer.IPv6.IsValid() {
return nil
}
s := peer.IPv6.String()
return &s
}

View File

@@ -146,7 +146,7 @@ func initTestMetaData(t *testing.T, peers ...*nbpeer.Peer) *Handler {
UpdatePeerIPFunc: func(_ context.Context, accountID, userID, peerID string, newIP netip.Addr) error {
for _, peer := range peers {
if peer.ID == peerID {
peer.IP = net.IP(newIP.AsSlice())
peer.IP = newIP
return nil
}
}
@@ -228,7 +228,8 @@ func TestGetPeers(t *testing.T) {
peer := &nbpeer.Peer{
ID: testPeerID,
Key: "key",
IP: net.ParseIP("100.64.0.1"),
IP: netip.MustParseAddr("100.64.0.1"),
IPv6: netip.MustParseAddr("fd00::1"),
Status: &nbpeer.PeerStatus{Connected: true},
Name: "PeerName",
LoginExpirationEnabled: false,
@@ -368,7 +369,8 @@ func TestGetAccessiblePeers(t *testing.T) {
peer1 := &nbpeer.Peer{
ID: "peer1",
Key: "key1",
IP: net.ParseIP("100.64.0.1"),
IP: netip.MustParseAddr("100.64.0.1"),
IPv6: netip.MustParseAddr("fd00:1234::1"),
Status: &nbpeer.PeerStatus{Connected: true},
Name: "peer1",
LoginExpirationEnabled: false,
@@ -378,7 +380,8 @@ func TestGetAccessiblePeers(t *testing.T) {
peer2 := &nbpeer.Peer{
ID: "peer2",
Key: "key2",
IP: net.ParseIP("100.64.0.2"),
IP: netip.MustParseAddr("100.64.0.2"),
IPv6: netip.MustParseAddr("fd00:1234::2"),
Status: &nbpeer.PeerStatus{Connected: true},
Name: "peer2",
LoginExpirationEnabled: false,
@@ -388,7 +391,8 @@ func TestGetAccessiblePeers(t *testing.T) {
peer3 := &nbpeer.Peer{
ID: "peer3",
Key: "key3",
IP: net.ParseIP("100.64.0.3"),
IP: netip.MustParseAddr("100.64.0.3"),
IPv6: netip.MustParseAddr("fd00:1234::3"),
Status: &nbpeer.PeerStatus{Connected: true},
Name: "peer3",
LoginExpirationEnabled: false,
@@ -532,7 +536,8 @@ func TestPeersHandlerUpdatePeerIP(t *testing.T) {
testPeer := &nbpeer.Peer{
ID: testPeerID,
Key: "key",
IP: net.ParseIP("100.64.0.1"),
IP: netip.MustParseAddr("100.64.0.1"),
IPv6: netip.MustParseAddr("fd00::1"),
Status: &nbpeer.PeerStatus{Connected: false, LastSeen: time.Now()},
Name: "test-host@netbird.io",
LoginExpirationEnabled: false,

View File

@@ -7,11 +7,8 @@ import (
"github.com/gorilla/mux"
"github.com/netbirdio/netbird/management/server/account"
nbcontext "github.com/netbirdio/netbird/management/server/context"
"github.com/netbirdio/netbird/management/server/geolocation"
"github.com/netbirdio/netbird/management/server/permissions"
"github.com/netbirdio/netbird/management/server/permissions/modules"
"github.com/netbirdio/netbird/management/server/permissions/operations"
"github.com/netbirdio/netbird/shared/management/http/api"
"github.com/netbirdio/netbird/shared/management/http/util"
"github.com/netbirdio/netbird/shared/management/status"
@@ -45,11 +42,6 @@ func newGeolocationsHandlerHandler(accountManager account.Manager, geolocationMa
// getAllCountries retrieves a list of all countries
func (l *geolocationsHandler) getAllCountries(w http.ResponseWriter, r *http.Request) {
if err := l.authenticateUser(r); err != nil {
util.WriteError(r.Context(), err, w)
return
}
if l.geolocationManager == nil {
// TODO: update error message to include geo db self hosted doc link when ready
util.WriteError(r.Context(), status.Errorf(status.PreconditionFailed, "Geo location database is not initialized"), w)
@@ -71,11 +63,6 @@ func (l *geolocationsHandler) getAllCountries(w http.ResponseWriter, r *http.Req
// getCitiesByCountry retrieves a list of cities based on the given country code
func (l *geolocationsHandler) getCitiesByCountry(w http.ResponseWriter, r *http.Request) {
if err := l.authenticateUser(r); err != nil {
util.WriteError(r.Context(), err, w)
return
}
vars := mux.Vars(r)
countryCode := vars["country"]
if !countryCodeRegex.MatchString(countryCode) {
@@ -102,27 +89,6 @@ func (l *geolocationsHandler) getCitiesByCountry(w http.ResponseWriter, r *http.
util.WriteJSONObject(r.Context(), w, cities)
}
func (l *geolocationsHandler) authenticateUser(r *http.Request) error {
ctx := r.Context()
userAuth, err := nbcontext.GetUserAuthFromContext(ctx)
if err != nil {
return err
}
accountID, userID := userAuth.AccountId, userAuth.UserId
allowed, err := l.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operations.Read)
if err != nil {
return status.NewPermissionValidationError(err)
}
if !allowed {
return status.NewPermissionDeniedError()
}
return nil
}
func toCountryResponse(country geolocation.Country) api.Country {
return api.Country{
CountryName: country.CountryName,

View File

@@ -5,9 +5,9 @@ import (
"context"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"net/netip"
"os"
"strconv"
"testing"
@@ -133,7 +133,7 @@ func PopulateTestData(b *testing.B, am account.Manager, peers, groups, users, se
ID: fmt.Sprintf("oldpeer-%d", i),
DNSLabel: fmt.Sprintf("oldpeer-%d", i),
Key: peerKey.PublicKey().String(),
IP: net.ParseIP(fmt.Sprintf("100.64.%d.%d", i/256, i%256)),
IP: netip.MustParseAddr(fmt.Sprintf("100.64.%d.%d", i/256, i%256)),
Status: &nbpeer.PeerStatus{LastSeen: time.Now().UTC(), Connected: true},
UserID: TestUserId,
}

View File

@@ -63,6 +63,7 @@ type MockAccountManager struct {
UpdatePeerMetaFunc func(ctx context.Context, peerID string, meta nbpeer.PeerSystemMeta) error
UpdatePeerFunc func(ctx context.Context, accountID, userID string, peer *nbpeer.Peer) (*nbpeer.Peer, error)
UpdatePeerIPFunc func(ctx context.Context, accountID, userID, peerID string, newIP netip.Addr) error
UpdatePeerIPv6Func func(ctx context.Context, accountID, userID, peerID string, newIPv6 netip.Addr) error
CreateRouteFunc func(ctx context.Context, accountID string, prefix netip.Prefix, networkType route.NetworkType, domains domain.List, peer string, peerGroups []string, description string, netID route.NetID, masquerade bool, metric int, groups, accessControlGroupIDs []string, enabled bool, userID string, keepRoute bool, isSelected bool) (*route.Route, error)
GetRouteFunc func(ctx context.Context, accountID string, routeID route.ID, userID string) (*route.Route, error)
SaveRouteFunc func(ctx context.Context, accountID string, userID string, route *route.Route) error
@@ -539,6 +540,13 @@ func (am *MockAccountManager) UpdatePeerIP(ctx context.Context, accountID, userI
return status.Errorf(codes.Unimplemented, "method UpdatePeerIP is not implemented")
}
func (am *MockAccountManager) UpdatePeerIPv6(ctx context.Context, accountID, userID, peerID string, newIPv6 netip.Addr) error {
if am.UpdatePeerIPv6Func != nil {
return am.UpdatePeerIPv6Func(ctx, accountID, userID, peerID, newIPv6)
}
return status.Errorf(codes.Unimplemented, "method UpdatePeerIPv6 is not implemented")
}
// CreateRoute mock implementation of CreateRoute from server.AccountManager interface
func (am *MockAccountManager) CreateRoute(ctx context.Context, accountID string, prefix netip.Prefix, networkType route.NetworkType, domains domain.List, peerID string, peerGroupIDs []string, description string, netID route.NetID, masquerade bool, metric int, groups, accessControlGroupID []string, enabled bool, userID string, keepRoute bool, isSelected bool) (*route.Route, error) {
if am.CreateRouteFunc != nil {

View File

@@ -6,6 +6,7 @@ import (
b64 "encoding/base64"
"fmt"
"net"
"net/netip"
"slices"
"strings"
"time"
@@ -521,6 +522,27 @@ func (am *DefaultAccountManager) GetPeerNetwork(ctx context.Context, peerID stri
return account.Network.Copy(), err
}
// peerWillHaveIPv6 checks whether the peer's future group memberships
// (auto-groups + allGroupID) overlap with IPv6EnabledGroups.
func peerWillHaveIPv6(settings *types.Settings, groupsToAdd []string, allGroupID string) bool {
enabledSet := make(map[string]struct{}, len(settings.IPv6EnabledGroups))
for _, gid := range settings.IPv6EnabledGroups {
enabledSet[gid] = struct{}{}
}
if allGroupID != "" {
if _, ok := enabledSet[allGroupID]; ok {
return true
}
}
for _, gid := range groupsToAdd {
if _, ok := enabledSet[gid]; ok {
return true
}
}
return false
}
type peerAddAuthConfig struct {
AccountID string
SetupKeyID string
@@ -715,8 +737,11 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe
maxAttempts := 10
for attempt := 1; attempt <= maxAttempts; attempt++ {
var freeIP net.IP
freeIP, err = types.AllocateRandomPeerIP(network.Net)
netPrefix, err := netip.ParsePrefix(network.Net.String())
if err != nil {
return nil, nil, nil, fmt.Errorf("parse network prefix: %w", err)
}
freeIP, err := types.AllocateRandomPeerIP(netPrefix)
if err != nil {
return nil, nil, nil, fmt.Errorf("failed to get free IP: %w", err)
}
@@ -736,6 +761,29 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe
newPeer.DNSLabel = freeLabel
newPeer.IP = freeIP
if len(settings.IPv6EnabledGroups) > 0 && network.NetV6.IP != nil {
var allGroupID string
if !peer.ProxyMeta.Embedded {
allGroup, err := am.Store.GetGroupByName(ctx, store.LockingStrengthNone, accountID, "All")
if err != nil {
log.WithContext(ctx).Debugf("get All group for IPv6 allocation: %v", err)
} else {
allGroupID = allGroup.ID
}
}
if peerWillHaveIPv6(settings, peerAddConfig.GroupsToAdd, allGroupID) {
v6Prefix, err := netip.ParsePrefix(network.NetV6.String())
if err != nil {
return nil, nil, nil, fmt.Errorf("parse IPv6 prefix: %w", err)
}
freeIPv6, err := types.AllocateRandomPeerIPv6(v6Prefix)
if err != nil {
return nil, nil, nil, fmt.Errorf("allocate peer IPv6: %w", err)
}
newPeer.IPv6 = freeIPv6
}
}
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
err = transaction.AddPeerToAccount(ctx, newPeer)
if err != nil {
@@ -805,10 +853,6 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe
return nil, nil, nil, fmt.Errorf("failed to add peer to database: %w", err)
}
if err != nil {
return nil, nil, nil, fmt.Errorf("failed to add peer to database after %d attempts: %w", maxAttempts, err)
}
if newPeer == nil {
return nil, nil, nil, fmt.Errorf("new peer is nil")
}
@@ -834,21 +878,24 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe
return p, nmap, pc, err
}
func getPeerIPDNSLabel(ip net.IP, peerHostName string) (string, error) {
ip = ip.To4()
func getPeerIPDNSLabel(ip netip.Addr, peerHostName string) (string, error) {
if !ip.Is4() {
return "", fmt.Errorf("DNS label generation requires an IPv4 address, got %s", ip)
}
b := ip.As4()
dnsName, err := nbdns.GetParsedDomainLabel(peerHostName)
if err != nil {
return "", fmt.Errorf("failed to parse peer host name %s: %w", peerHostName, err)
}
return fmt.Sprintf("%s-%d-%d", dnsName, ip[2], ip[3]), nil
return fmt.Sprintf("%s-%d-%d", dnsName, b[2], b[3]), nil
}
// SyncPeer checks whether peer is eligible for receiving NetworkMap (authenticated) and returns its NetworkMap if eligible
func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSync, accountID string) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) {
var peer *nbpeer.Peer
var updated, versionChanged bool
var updated, versionChanged, ipv6CapabilityChanged bool
var err error
var postureChecks []*posture.Checks
var peerGroupIDs []string
@@ -884,7 +931,9 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy
return err
}
oldHasIPv6Cap := peer.HasCapability(nbpeer.PeerCapabilityIPv6Overlay)
updated, versionChanged = peer.UpdateMetaIfNew(sync.Meta)
ipv6CapabilityChanged = oldHasIPv6Cap != peer.HasCapability(nbpeer.PeerCapabilityIPv6Overlay)
if updated {
am.metrics.AccountManagerMetrics().CountPeerMetUpdate()
log.WithContext(ctx).Tracef("peer %s metadata updated", peer.ID)
@@ -908,7 +957,7 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy
return nil, nil, nil, 0, err
}
if isStatusChanged || sync.UpdateAccountPeers || (updated && (len(postureChecks) > 0 || versionChanged)) {
if isStatusChanged || sync.UpdateAccountPeers || ipv6CapabilityChanged || (updated && (len(postureChecks) > 0 || versionChanged)) {
err = am.networkMapController.OnPeersUpdated(ctx, accountID, []string{peer.ID})
if err != nil {
return nil, nil, nil, 0, fmt.Errorf("notify network map controller of peer update: %w", err)
@@ -958,6 +1007,7 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer
var peer *nbpeer.Peer
var updateRemotePeers bool
var isPeerUpdated bool
var ipv6CapabilityChanged bool
var postureChecks []*posture.Checks
var peerGroupIDs []string
@@ -997,7 +1047,9 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer
return err
}
oldHasIPv6Cap := peer.HasCapability(nbpeer.PeerCapabilityIPv6Overlay)
isPeerUpdated, _ = peer.UpdateMetaIfNew(login.Meta)
ipv6CapabilityChanged = oldHasIPv6Cap != peer.HasCapability(nbpeer.PeerCapabilityIPv6Overlay)
if isPeerUpdated {
am.metrics.AccountManagerMetrics().CountPeerMetUpdate()
shouldStorePeer = true
@@ -1035,7 +1087,7 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer
return nil, nil, nil, err
}
if updateRemotePeers || isStatusChanged || (isPeerUpdated && len(postureChecks) > 0) {
if updateRemotePeers || isStatusChanged || ipv6CapabilityChanged || (isPeerUpdated && len(postureChecks) > 0) {
err = am.networkMapController.OnPeersUpdated(ctx, accountID, []string{peer.ID})
if err != nil {
return nil, nil, nil, fmt.Errorf("notify network map controller of peer update: %w", err)

View File

@@ -11,6 +11,12 @@ import (
"github.com/netbirdio/netbird/shared/management/http/api"
)
// Peer capability constants mirror the proto enum values.
const (
PeerCapabilitySourcePrefixes int32 = 1
PeerCapabilityIPv6Overlay int32 = 2
)
// Peer represents a machine connected to the network.
// The Peer is a WireGuard peer identified by a public key
type Peer struct {
@@ -21,7 +27,9 @@ type Peer struct {
// WireGuard public key
Key string // uniqueness index (check migrations)
// IP address of the Peer
IP net.IP `gorm:"serializer:json"` // uniqueness index per accountID (check migrations)
IP netip.Addr `gorm:"serializer:json"` // uniqueness index per accountID (check migrations)
// IPv6 overlay address of the Peer, zero value if IPv6 is not enabled for the account.
IPv6 netip.Addr `gorm:"serializer:json"`
// Meta is a Peer system meta data
Meta PeerSystemMeta `gorm:"embedded;embeddedPrefix:meta_"`
// ProxyMeta is metadata related to proxy peers
@@ -115,6 +123,7 @@ type Flags struct {
DisableFirewall bool
BlockLANAccess bool
BlockInbound bool
DisableIPv6 bool
LazyConnectionEnabled bool
}
@@ -138,6 +147,7 @@ type PeerSystemMeta struct { //nolint:revive
Environment Environment `gorm:"serializer:json"`
Flags Flags `gorm:"serializer:json"`
Files []File `gorm:"serializer:json"`
Capabilities []int32 `gorm:"serializer:json"`
}
func (p PeerSystemMeta) isEqual(other PeerSystemMeta) bool {
@@ -182,7 +192,8 @@ func (p PeerSystemMeta) isEqual(other PeerSystemMeta) bool {
p.SystemManufacturer == other.SystemManufacturer &&
p.Environment.Cloud == other.Environment.Cloud &&
p.Environment.Platform == other.Environment.Platform &&
p.Flags.isEqual(other.Flags)
p.Flags.isEqual(other.Flags) &&
capabilitiesEqual(p.Capabilities, other.Capabilities)
}
func (p PeerSystemMeta) isEmpty() bool {
@@ -210,6 +221,37 @@ func (p *Peer) AddedWithSSOLogin() bool {
return p.UserID != ""
}
// HasCapability reports whether the peer has the given capability.
func (p *Peer) HasCapability(capability int32) bool {
return slices.Contains(p.Meta.Capabilities, capability)
}
// SupportsIPv6 reports whether the peer supports IPv6 overlay.
func (p *Peer) SupportsIPv6() bool {
return !p.Meta.Flags.DisableIPv6 && p.HasCapability(PeerCapabilityIPv6Overlay)
}
// SupportsSourcePrefixes reports whether the peer reads SourcePrefixes.
func (p *Peer) SupportsSourcePrefixes() bool {
return p.HasCapability(PeerCapabilitySourcePrefixes)
}
func capabilitiesEqual(a, b []int32) bool {
if len(a) != len(b) {
return false
}
set := make(map[int32]struct{}, len(a))
for _, c := range a {
set[c] = struct{}{}
}
for _, c := range b {
if _, ok := set[c]; !ok {
return false
}
}
return true
}
// Copy copies Peer object
func (p *Peer) Copy() *Peer {
peerStatus := p.Status
@@ -221,6 +263,7 @@ func (p *Peer) Copy() *Peer {
AccountID: p.AccountID,
Key: p.Key,
IP: p.IP,
IPv6: p.IPv6,
Meta: p.Meta,
Name: p.Name,
DNSLabel: p.DNSLabel,
@@ -323,9 +366,13 @@ func (p *Peer) FQDN(dnsDomain string) string {
// EventMeta returns activity event meta related to the peer
func (p *Peer) EventMeta(dnsDomain string) map[string]any {
return map[string]any{"name": p.Name, "fqdn": p.FQDN(dnsDomain), "ip": p.IP, "created_at": p.CreatedAt,
meta := map[string]any{"name": p.Name, "fqdn": p.FQDN(dnsDomain), "ip": p.IP, "created_at": p.CreatedAt,
"location_city_name": p.Location.CityName, "location_country_code": p.Location.CountryCode,
"location_geo_name_id": p.Location.GeoNameID, "location_connection_ip": p.Location.ConnectionIP}
if p.IPv6.IsValid() {
meta["ipv6"] = p.IPv6.String()
}
return meta
}
// Copy PeerStatus
@@ -369,5 +416,6 @@ func (f Flags) isEqual(other Flags) bool {
f.DisableFirewall == other.DisableFirewall &&
f.BlockLANAccess == other.BlockLANAccess &&
f.BlockInbound == other.BlockInbound &&
f.LazyConnectionEnabled == other.LazyConnectionEnabled
f.LazyConnectionEnabled == other.LazyConnectionEnabled &&
f.DisableIPv6 == other.DisableIPv6
}

View File

@@ -5,6 +5,7 @@ import (
"net/netip"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -141,3 +142,25 @@ func TestFlags_IsEqual(t *testing.T) {
})
}
}
func TestPeerCapabilities(t *testing.T) {
tests := []struct {
name string
capabilities []int32
ipv6 bool
srcPrefixes bool
}{
{"no capabilities", nil, false, false},
{"only source prefixes", []int32{PeerCapabilitySourcePrefixes}, false, true},
{"only ipv6", []int32{PeerCapabilityIPv6Overlay}, true, false},
{"both", []int32{PeerCapabilitySourcePrefixes, PeerCapabilityIPv6Overlay}, true, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := &Peer{Meta: PeerSystemMeta{Capabilities: tt.capabilities}}
assert.Equal(t, tt.ipv6, p.SupportsIPv6())
assert.Equal(t, tt.srcPrefixes, p.SupportsSourcePrefixes())
})
}
}

View File

@@ -754,7 +754,8 @@ func setupTestAccountManager(b testing.TB, peers int, groups int) (*DefaultAccou
ID: fmt.Sprintf("peer-%d", i),
DNSLabel: fmt.Sprintf("peer-%d", i),
Key: peerKey.PublicKey().String(),
IP: net.ParseIP(fmt.Sprintf("100.64.%d.%d", i/256, i%256)),
IP: netip.MustParseAddr(fmt.Sprintf("100.64.%d.%d", i/256, i%256)),
IPv6: netip.MustParseAddr(fmt.Sprintf("fd00::%d", i+1)),
Status: &nbpeer.PeerStatus{LastSeen: time.Now().UTC(), Connected: true},
UserID: regularUser,
}
@@ -783,7 +784,15 @@ func setupTestAccountManager(b testing.TB, peers int, groups int) (*DefaultAccou
account.Networks = append(account.Networks, network)
ips := account.GetTakenIPs()
peerIP, err := types.AllocatePeerIP(account.Network.Net, ips)
peerIP, err := types.AllocatePeerIP(netip.MustParsePrefix(account.Network.Net.String()), ips)
if err != nil {
return nil, nil, "", "", err
}
v6Prefix, err := netip.ParsePrefix(account.Network.NetV6.String())
if err != nil {
return nil, nil, "", "", err
}
peerIPv6, err := types.AllocateRandomPeerIPv6(v6Prefix)
if err != nil {
return nil, nil, "", "", err
}
@@ -794,6 +803,7 @@ func setupTestAccountManager(b testing.TB, peers int, groups int) (*DefaultAccou
DNSLabel: fmt.Sprintf("peer-nr-%d", len(account.Peers)+1),
Key: peerKey.PublicKey().String(),
IP: peerIP,
IPv6: peerIPv6,
Status: &nbpeer.PeerStatus{LastSeen: time.Now().UTC(), Connected: true},
UserID: regularUser,
Meta: nbpeer.PeerSystemMeta{
@@ -1068,7 +1078,8 @@ func TestToSyncResponse(t *testing.T) {
},
}
peer := &nbpeer.Peer{
IP: net.ParseIP("192.168.1.1"),
IP: netip.MustParseAddr("192.168.1.1"),
IPv6: netip.MustParseAddr("fd00::1"),
SSHEnabled: true,
Key: "peer-key",
DNSLabel: "peer1",
@@ -1079,9 +1090,21 @@ func TestToSyncResponse(t *testing.T) {
Signature: "turn-pass",
}
networkMap := &types.NetworkMap{
Network: &types.Network{Net: *ipnet, Serial: 1000},
Peers: []*nbpeer.Peer{{IP: net.ParseIP("192.168.1.2"), Key: "peer2-key", DNSLabel: "peer2", SSHEnabled: true, SSHKey: "peer2-ssh-key"}},
OfflinePeers: []*nbpeer.Peer{{IP: net.ParseIP("192.168.1.3"), Key: "peer3-key", DNSLabel: "peer3", SSHEnabled: true, SSHKey: "peer3-ssh-key"}},
Network: &types.Network{Net: *ipnet, Serial: 1000},
Peers: []*nbpeer.Peer{{
IP: netip.MustParseAddr("192.168.1.2"),
IPv6: netip.MustParseAddr("fd00::2"),
Key: "peer2-key",
DNSLabel: "peer2",
SSHEnabled: true,
SSHKey: "peer2-ssh-key"}},
OfflinePeers: []*nbpeer.Peer{{
IP: netip.MustParseAddr("192.168.1.3"),
IPv6: netip.MustParseAddr("fd00::3"),
Key: "peer3-key",
DNSLabel: "peer3",
SSHEnabled: true,
SSHKey: "peer3-ssh-key"}},
Routes: []*nbroute.Route{
{
ID: "route1",
@@ -1228,6 +1251,7 @@ func TestToSyncResponse(t *testing.T) {
assert.Equal(t, int64(53), response.NetworkMap.DNSConfig.NameServerGroups[0].NameServers[0].GetPort())
// assert network map Firewall
assert.Equal(t, 1, len(response.NetworkMap.FirewallRules))
//nolint:staticcheck // testing backward-compatible field
assert.Equal(t, "192.168.1.2", response.NetworkMap.FirewallRules[0].PeerIP)
assert.Equal(t, proto.RuleDirection_IN, response.NetworkMap.FirewallRules[0].Direction)
assert.Equal(t, proto.RuleAction_ACCEPT, response.NetworkMap.FirewallRules[0].Action)
@@ -1290,7 +1314,8 @@ func Test_RegisterPeerByUser(t *testing.T) {
ID: xid.New().String(),
AccountID: existingAccountID,
Key: "newPeerKey",
IP: net.IP{123, 123, 123, 123},
IP: netip.AddrFrom4([4]byte{123, 123, 123, 123}),
IPv6: netip.MustParseAddr("fd00::7b:7b:7b:7b"),
Meta: nbpeer.PeerSystemMeta{
Hostname: "newPeer",
GoOS: "linux",
@@ -1378,7 +1403,8 @@ func Test_RegisterPeerBySetupKey(t *testing.T) {
newPeerTemplate := &nbpeer.Peer{
AccountID: existingAccountID,
UserID: "",
IP: net.IP{123, 123, 123, 123},
IP: netip.AddrFrom4([4]byte{123, 123, 123, 123}),
IPv6: netip.MustParseAddr("fd00::7b:7b:7b:7b"),
Meta: nbpeer.PeerSystemMeta{
Hostname: "newPeer",
GoOS: "linux",
@@ -1539,7 +1565,8 @@ func Test_RegisterPeerRollbackOnFailure(t *testing.T) {
AccountID: existingAccountID,
Key: "newPeerKey",
UserID: "",
IP: net.IP{123, 123, 123, 123},
IP: netip.AddrFrom4([4]byte{123, 123, 123, 123}),
IPv6: netip.MustParseAddr("fd00::7b:7b:7b:7b"),
Meta: nbpeer.PeerSystemMeta{
Hostname: "newPeer",
GoOS: "linux",
@@ -1624,7 +1651,8 @@ func Test_LoginPeer(t *testing.T) {
newPeerTemplate := &nbpeer.Peer{
AccountID: existingAccountID,
UserID: "",
IP: net.IP{123, 123, 123, 123},
IP: netip.AddrFrom4([4]byte{123, 123, 123, 123}),
IPv6: netip.MustParseAddr("fd00::7b:7b:7b:7b"),
Meta: nbpeer.PeerSystemMeta{
Hostname: "newPeer",
GoOS: "linux",
@@ -2126,14 +2154,16 @@ func Test_DeletePeer(t *testing.T) {
ID: "peer1",
AccountID: accountID,
Key: "key1",
IP: net.IP{1, 1, 1, 1},
IP: netip.AddrFrom4([4]byte{1, 1, 1, 1}),
IPv6: netip.MustParseAddr("fd00::1"),
DNSLabel: "peer1.test",
},
"peer2": {
ID: "peer2",
AccountID: accountID,
Key: "key2",
IP: net.IP{2, 2, 2, 2},
IP: netip.AddrFrom4([4]byte{2, 2, 2, 2}),
IPv6: netip.MustParseAddr("fd00::2"),
DNSLabel: "peer2.test",
},
}
@@ -2730,6 +2760,67 @@ func TestProcessPeerAddAuth(t *testing.T) {
})
}
func TestPeerWillHaveIPv6(t *testing.T) {
settings := &types.Settings{
IPv6EnabledGroups: []string{"all-group-id", "group-a"},
}
assert.True(t, peerWillHaveIPv6(settings, nil, "all-group-id"), "peer in All group should get IPv6")
assert.True(t, peerWillHaveIPv6(settings, []string{"group-a"}, ""), "peer with matching auto-group should get IPv6")
assert.False(t, peerWillHaveIPv6(settings, []string{"group-b"}, "other-all"), "peer with no matching groups should not get IPv6")
assert.False(t, peerWillHaveIPv6(settings, nil, ""), "embedded peer with no groups should not get IPv6")
emptySettings := &types.Settings{IPv6EnabledGroups: []string{}}
assert.False(t, peerWillHaveIPv6(emptySettings, []string{"group-a"}, "all-group-id"), "no IPv6 groups means no IPv6")
}
// TestSyncPeer_IPv6CapabilityChangePropagates ensures that when a peer reports
// a new IPv6 overlay capability via SyncPeer (e.g. after a client upgrade or
// flipping --disable-ipv6) without bumping its WtVersion, other account peers
// receive a fresh network map so their AAAA records for it become unstale.
func TestSyncPeer_IPv6CapabilityChangePropagates(t *testing.T) {
manager, updateManager, _, peer1, peer2, _ := setupNetworkMapTest(t)
updMsg := updateManager.CreateChannel(context.Background(), peer1.ID)
t.Cleanup(func() {
updateManager.CloseChannel(context.Background(), peer1.ID)
})
// Drain any initial updates from setup.
drain := func() {
for {
select {
case <-updMsg:
case <-time.After(200 * time.Millisecond):
return
}
}
}
drain()
t.Run("no propagation when capabilities are unchanged", func(t *testing.T) {
_, _, _, _, err := manager.SyncPeer(context.Background(), types.PeerSync{
WireGuardPubKey: peer2.Key,
Meta: peer2.Meta,
}, peer2.AccountID)
require.NoError(t, err)
peerShouldNotReceiveUpdate(t, updMsg)
})
t.Run("propagation when IPv6 capability is added", func(t *testing.T) {
newMeta := peer2.Meta
newMeta.Capabilities = append([]int32{}, peer2.Meta.Capabilities...)
newMeta.Capabilities = append(newMeta.Capabilities, nbpeer.PeerCapabilityIPv6Overlay)
_, _, _, _, err := manager.SyncPeer(context.Background(), types.PeerSync{
WireGuardPubKey: peer2.Key,
Meta: newMeta,
}, peer2.AccountID)
require.NoError(t, err)
peerShouldReceiveUpdate(t, updMsg)
})
}
func TestUpdatePeer_DnsLabelCollisionWithFQDN(t *testing.T) {
manager, _, err := createManager(t)
require.NoError(t, err, "unable to create account manager")

View File

@@ -3,7 +3,7 @@ package server
import (
"context"
"fmt"
"net"
"net/netip"
"testing"
"time"
@@ -20,53 +20,53 @@ func TestAccount_getPeersByPolicy(t *testing.T) {
Peers: map[string]*nbpeer.Peer{
"peerA": {
ID: "peerA",
IP: net.ParseIP("100.65.14.88"),
IP: netip.MustParseAddr("100.65.14.88"),
Status: &nbpeer.PeerStatus{},
},
"peerB": {
ID: "peerB",
IP: net.ParseIP("100.65.80.39"),
IP: netip.MustParseAddr("100.65.80.39"),
Status: &nbpeer.PeerStatus{},
Meta: nbpeer.PeerSystemMeta{WtVersion: "0.48.0"},
},
"peerC": {
ID: "peerC",
IP: net.ParseIP("100.65.254.139"),
IP: netip.MustParseAddr("100.65.254.139"),
Status: &nbpeer.PeerStatus{},
},
"peerD": {
ID: "peerD",
IP: net.ParseIP("100.65.62.5"),
IP: netip.MustParseAddr("100.65.62.5"),
Status: &nbpeer.PeerStatus{},
},
"peerE": {
ID: "peerE",
IP: net.ParseIP("100.65.32.206"),
IP: netip.MustParseAddr("100.65.32.206"),
Status: &nbpeer.PeerStatus{},
},
"peerF": {
ID: "peerF",
IP: net.ParseIP("100.65.250.202"),
IP: netip.MustParseAddr("100.65.250.202"),
Status: &nbpeer.PeerStatus{},
},
"peerG": {
ID: "peerG",
IP: net.ParseIP("100.65.13.186"),
IP: netip.MustParseAddr("100.65.13.186"),
Status: &nbpeer.PeerStatus{},
},
"peerH": {
ID: "peerH",
IP: net.ParseIP("100.65.29.55"),
IP: netip.MustParseAddr("100.65.29.55"),
Status: &nbpeer.PeerStatus{},
},
"peerI": {
ID: "peerI",
IP: net.ParseIP("100.65.31.2"),
IP: netip.MustParseAddr("100.65.31.2"),
Status: &nbpeer.PeerStatus{},
},
"peerK": {
ID: "peerK",
IP: net.ParseIP("100.32.80.1"),
IP: netip.MustParseAddr("100.32.80.1"),
Status: &nbpeer.PeerStatus{},
Meta: nbpeer.PeerSystemMeta{WtVersion: "0.30.0"},
},
@@ -540,17 +540,17 @@ func TestAccount_getPeersByPolicyDirect(t *testing.T) {
Peers: map[string]*nbpeer.Peer{
"peerA": {
ID: "peerA",
IP: net.ParseIP("100.65.14.88"),
IP: netip.MustParseAddr("100.65.14.88"),
Status: &nbpeer.PeerStatus{},
},
"peerB": {
ID: "peerB",
IP: net.ParseIP("100.65.80.39"),
IP: netip.MustParseAddr("100.65.80.39"),
Status: &nbpeer.PeerStatus{},
},
"peerC": {
ID: "peerC",
IP: net.ParseIP("100.65.254.139"),
IP: netip.MustParseAddr("100.65.254.139"),
Status: &nbpeer.PeerStatus{},
},
},
@@ -746,7 +746,7 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) {
Peers: map[string]*nbpeer.Peer{
"peerA": {
ID: "peerA",
IP: net.ParseIP("100.65.14.88"),
IP: netip.MustParseAddr("100.65.14.88"),
Status: &nbpeer.PeerStatus{},
Meta: nbpeer.PeerSystemMeta{
GoOS: "linux",
@@ -756,7 +756,7 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) {
},
"peerB": {
ID: "peerB",
IP: net.ParseIP("100.65.80.39"),
IP: netip.MustParseAddr("100.65.80.39"),
Status: &nbpeer.PeerStatus{},
Meta: nbpeer.PeerSystemMeta{
GoOS: "linux",
@@ -766,7 +766,7 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) {
},
"peerC": {
ID: "peerC",
IP: net.ParseIP("100.65.254.139"),
IP: netip.MustParseAddr("100.65.254.139"),
Status: &nbpeer.PeerStatus{},
Meta: nbpeer.PeerSystemMeta{
GoOS: "linux",
@@ -776,7 +776,7 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) {
},
"peerD": {
ID: "peerD",
IP: net.ParseIP("100.65.62.5"),
IP: netip.MustParseAddr("100.65.62.5"),
Status: &nbpeer.PeerStatus{},
Meta: nbpeer.PeerSystemMeta{
GoOS: "linux",
@@ -786,7 +786,7 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) {
},
"peerE": {
ID: "peerE",
IP: net.ParseIP("100.65.32.206"),
IP: netip.MustParseAddr("100.65.32.206"),
Status: &nbpeer.PeerStatus{},
Meta: nbpeer.PeerSystemMeta{
GoOS: "linux",
@@ -796,7 +796,7 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) {
},
"peerF": {
ID: "peerF",
IP: net.ParseIP("100.65.250.202"),
IP: netip.MustParseAddr("100.65.250.202"),
Status: &nbpeer.PeerStatus{},
Meta: nbpeer.PeerSystemMeta{
GoOS: "linux",
@@ -806,7 +806,7 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) {
},
"peerG": {
ID: "peerG",
IP: net.ParseIP("100.65.13.186"),
IP: netip.MustParseAddr("100.65.13.186"),
Status: &nbpeer.PeerStatus{},
Meta: nbpeer.PeerSystemMeta{
GoOS: "linux",
@@ -816,7 +816,7 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) {
},
"peerH": {
ID: "peerH",
IP: net.ParseIP("100.65.29.55"),
IP: netip.MustParseAddr("100.65.29.55"),
Status: &nbpeer.PeerStatus{},
Meta: nbpeer.PeerSystemMeta{
GoOS: "linux",
@@ -826,7 +826,7 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) {
},
"peerI": {
ID: "peerI",
IP: net.ParseIP("100.65.21.56"),
IP: netip.MustParseAddr("100.65.21.56"),
Status: &nbpeer.PeerStatus{},
Meta: nbpeer.PeerSystemMeta{
GoOS: "windows",

View File

@@ -2,7 +2,6 @@ package server
import (
"context"
"net"
"net/netip"
"testing"
"time"
@@ -1333,14 +1332,24 @@ func initTestRouteAccount(t *testing.T, am *DefaultAccountManager) (*types.Accou
return nil, err
}
v6Prefix, err := netip.ParsePrefix(account.Network.NetV6.String())
if err != nil {
return nil, err
}
ips := account.GetTakenIPs()
peer1IP, err := types.AllocatePeerIP(account.Network.Net, ips)
peer1IP, err := types.AllocatePeerIP(netip.MustParsePrefix(account.Network.Net.String()), ips)
if err != nil {
return nil, err
}
peer1IPv6, err := types.AllocateRandomPeerIPv6(v6Prefix)
if err != nil {
return nil, err
}
peer1 := &nbpeer.Peer{
IP: peer1IP,
IPv6: peer1IPv6,
ID: peer1ID,
Key: peer1Key,
Name: "test-host1@netbird.io",
@@ -1361,13 +1370,18 @@ func initTestRouteAccount(t *testing.T, am *DefaultAccountManager) (*types.Accou
account.Peers[peer1.ID] = peer1
ips = account.GetTakenIPs()
peer2IP, err := types.AllocatePeerIP(account.Network.Net, ips)
peer2IP, err := types.AllocatePeerIP(netip.MustParsePrefix(account.Network.Net.String()), ips)
if err != nil {
return nil, err
}
peer2IPv6, err := types.AllocateRandomPeerIPv6(v6Prefix)
if err != nil {
return nil, err
}
peer2 := &nbpeer.Peer{
IP: peer2IP,
IPv6: peer2IPv6,
ID: peer2ID,
Key: peer2Key,
Name: "test-host2@netbird.io",
@@ -1388,13 +1402,18 @@ func initTestRouteAccount(t *testing.T, am *DefaultAccountManager) (*types.Accou
account.Peers[peer2.ID] = peer2
ips = account.GetTakenIPs()
peer3IP, err := types.AllocatePeerIP(account.Network.Net, ips)
peer3IP, err := types.AllocatePeerIP(netip.MustParsePrefix(account.Network.Net.String()), ips)
if err != nil {
return nil, err
}
peer3IPv6, err := types.AllocateRandomPeerIPv6(v6Prefix)
if err != nil {
return nil, err
}
peer3 := &nbpeer.Peer{
IP: peer3IP,
IPv6: peer3IPv6,
ID: peer3ID,
Key: peer3Key,
Name: "test-host3@netbird.io",
@@ -1415,13 +1434,18 @@ func initTestRouteAccount(t *testing.T, am *DefaultAccountManager) (*types.Accou
account.Peers[peer3.ID] = peer3
ips = account.GetTakenIPs()
peer4IP, err := types.AllocatePeerIP(account.Network.Net, ips)
peer4IP, err := types.AllocatePeerIP(netip.MustParsePrefix(account.Network.Net.String()), ips)
if err != nil {
return nil, err
}
peer4IPv6, err := types.AllocateRandomPeerIPv6(v6Prefix)
if err != nil {
return nil, err
}
peer4 := &nbpeer.Peer{
IP: peer4IP,
IPv6: peer4IPv6,
ID: peer4ID,
Key: peer4Key,
Name: "test-host4@netbird.io",
@@ -1442,13 +1466,18 @@ func initTestRouteAccount(t *testing.T, am *DefaultAccountManager) (*types.Accou
account.Peers[peer4.ID] = peer4
ips = account.GetTakenIPs()
peer5IP, err := types.AllocatePeerIP(account.Network.Net, ips)
peer5IP, err := types.AllocatePeerIP(netip.MustParsePrefix(account.Network.Net.String()), ips)
if err != nil {
return nil, err
}
peer5IPv6, err := types.AllocateRandomPeerIPv6(v6Prefix)
if err != nil {
return nil, err
}
peer5 := &nbpeer.Peer{
IP: peer5IP,
IPv6: peer5IPv6,
ID: peer5ID,
Key: peer5Key,
Name: "test-host5@netbird.io",
@@ -1549,7 +1578,8 @@ func TestAccount_getPeersRoutesFirewall(t *testing.T) {
Peers: map[string]*nbpeer.Peer{
"peerA": {
ID: "peerA",
IP: net.ParseIP("100.65.14.88"),
IP: netip.MustParseAddr("100.65.14.88"),
IPv6: netip.MustParseAddr("fd00::1"),
Status: &nbpeer.PeerStatus{},
Meta: nbpeer.PeerSystemMeta{
GoOS: "linux",
@@ -1557,18 +1587,21 @@ func TestAccount_getPeersRoutesFirewall(t *testing.T) {
},
"peerB": {
ID: "peerB",
IP: net.ParseIP(peerBIp),
IP: netip.MustParseAddr(peerBIp),
IPv6: netip.MustParseAddr("fd00::2"),
Status: &nbpeer.PeerStatus{},
Meta: nbpeer.PeerSystemMeta{},
},
"peerC": {
ID: "peerC",
IP: net.ParseIP(peerCIp),
IP: netip.MustParseAddr(peerCIp),
IPv6: netip.MustParseAddr("fd00::3"),
Status: &nbpeer.PeerStatus{},
},
"peerD": {
ID: "peerD",
IP: net.ParseIP("100.65.62.5"),
IP: netip.MustParseAddr("100.65.62.5"),
IPv6: netip.MustParseAddr("fd00::4"),
Status: &nbpeer.PeerStatus{},
Meta: nbpeer.PeerSystemMeta{
GoOS: "linux",
@@ -1576,7 +1609,8 @@ func TestAccount_getPeersRoutesFirewall(t *testing.T) {
},
"peerE": {
ID: "peerE",
IP: net.ParseIP("100.65.32.206"),
IP: netip.MustParseAddr("100.65.32.206"),
IPv6: netip.MustParseAddr("fd00::5"),
Key: peer1Key,
Status: &nbpeer.PeerStatus{},
Meta: nbpeer.PeerSystemMeta{
@@ -1585,27 +1619,32 @@ func TestAccount_getPeersRoutesFirewall(t *testing.T) {
},
"peerF": {
ID: "peerF",
IP: net.ParseIP("100.65.250.202"),
IP: netip.MustParseAddr("100.65.250.202"),
IPv6: netip.MustParseAddr("fd00::6"),
Status: &nbpeer.PeerStatus{},
},
"peerG": {
ID: "peerG",
IP: net.ParseIP("100.65.13.186"),
IP: netip.MustParseAddr("100.65.13.186"),
IPv6: netip.MustParseAddr("fd00::7"),
Status: &nbpeer.PeerStatus{},
},
"peerH": {
ID: "peerH",
IP: net.ParseIP(peerHIp),
IP: netip.MustParseAddr(peerHIp),
IPv6: netip.MustParseAddr("fd00::8"),
Status: &nbpeer.PeerStatus{},
},
"peerJ": {
ID: "peerJ",
IP: net.ParseIP(peerJIp),
IP: netip.MustParseAddr(peerJIp),
IPv6: netip.MustParseAddr("fd00::a"),
Status: &nbpeer.PeerStatus{},
},
"peerK": {
ID: "peerK",
IP: net.ParseIP(peerKIp),
IP: netip.MustParseAddr(peerKIp),
IPv6: netip.MustParseAddr("fd00::b"),
Status: &nbpeer.PeerStatus{},
},
},
@@ -2129,84 +2168,101 @@ func TestAccount_GetPeerNetworkResourceFirewallRules(t *testing.T) {
Peers: map[string]*nbpeer.Peer{
"peerA": {
ID: "peerA",
IP: net.ParseIP("100.65.14.88"),
IP: netip.MustParseAddr("100.65.14.88"),
IPv6: netip.MustParseAddr("fd00::1"),
Key: "peerA",
Status: &nbpeer.PeerStatus{},
Meta: nbpeer.PeerSystemMeta{
GoOS: "linux",
GoOS: "linux",
Capabilities: []int32{nbpeer.PeerCapabilityIPv6Overlay},
},
},
"peerB": {
ID: "peerB",
IP: net.ParseIP(peerBIp),
IP: netip.MustParseAddr(peerBIp),
IPv6: netip.MustParseAddr("fd00::2"),
Status: &nbpeer.PeerStatus{},
Meta: nbpeer.PeerSystemMeta{},
},
"peerC": {
ID: "peerC",
IP: net.ParseIP(peerCIp),
IP: netip.MustParseAddr(peerCIp),
IPv6: netip.MustParseAddr("fd00::3"),
Status: &nbpeer.PeerStatus{},
},
"peerD": {
ID: "peerD",
IP: net.ParseIP("100.65.62.5"),
IP: netip.MustParseAddr("100.65.62.5"),
IPv6: netip.MustParseAddr("fd00::4"),
Key: "peerD",
Status: &nbpeer.PeerStatus{},
Meta: nbpeer.PeerSystemMeta{
GoOS: "linux",
GoOS: "linux",
Capabilities: []int32{nbpeer.PeerCapabilityIPv6Overlay},
},
},
"peerE": {
ID: "peerE",
IP: net.ParseIP("100.65.32.206"),
IP: netip.MustParseAddr("100.65.32.206"),
IPv6: netip.MustParseAddr("fd00::5"),
Key: "peerE",
Status: &nbpeer.PeerStatus{},
Meta: nbpeer.PeerSystemMeta{
GoOS: "linux",
GoOS: "linux",
Capabilities: []int32{nbpeer.PeerCapabilityIPv6Overlay},
},
},
"peerF": {
ID: "peerF",
IP: net.ParseIP("100.65.250.202"),
IP: netip.MustParseAddr("100.65.250.202"),
IPv6: netip.MustParseAddr("fd00::6"),
Status: &nbpeer.PeerStatus{},
},
"peerG": {
ID: "peerG",
IP: net.ParseIP("100.65.13.186"),
IP: netip.MustParseAddr("100.65.13.186"),
IPv6: netip.MustParseAddr("fd00::7"),
Status: &nbpeer.PeerStatus{},
},
"peerH": {
ID: "peerH",
IP: net.ParseIP(peerHIp),
IP: netip.MustParseAddr(peerHIp),
IPv6: netip.MustParseAddr("fd00::8"),
Status: &nbpeer.PeerStatus{},
},
"peerJ": {
ID: "peerJ",
IP: net.ParseIP(peerJIp),
IP: netip.MustParseAddr(peerJIp),
IPv6: netip.MustParseAddr("fd00::a"),
Status: &nbpeer.PeerStatus{},
},
"peerK": {
ID: "peerK",
IP: net.ParseIP(peerKIp),
IP: netip.MustParseAddr(peerKIp),
IPv6: netip.MustParseAddr("fd00::b"),
Status: &nbpeer.PeerStatus{},
},
"peerL": {
ID: "peerL",
IP: net.ParseIP("100.65.19.186"),
IP: netip.MustParseAddr("100.65.19.186"),
IPv6: netip.MustParseAddr("fd00::d"),
Key: "peerL",
Status: &nbpeer.PeerStatus{},
Meta: nbpeer.PeerSystemMeta{
GoOS: "linux",
GoOS: "linux",
Capabilities: []int32{nbpeer.PeerCapabilityIPv6Overlay},
},
},
"peerM": {
ID: "peerM",
IP: net.ParseIP(peerMIp),
IP: netip.MustParseAddr(peerMIp),
IPv6: netip.MustParseAddr("fd00::e"),
Status: &nbpeer.PeerStatus{},
},
"peerN": {
ID: "peerN",
IP: net.ParseIP("100.65.20.18"),
IP: netip.MustParseAddr("100.65.20.18"),
IPv6: netip.MustParseAddr("fd00::f"),
Key: "peerN",
Status: &nbpeer.PeerStatus{},
Meta: nbpeer.PeerSystemMeta{
@@ -2215,7 +2271,8 @@ func TestAccount_GetPeerNetworkResourceFirewallRules(t *testing.T) {
},
"peerO": {
ID: "peerO",
IP: net.ParseIP(peerOIp),
IP: netip.MustParseAddr(peerOIp),
IPv6: netip.MustParseAddr("fd00::10"),
Status: &nbpeer.PeerStatus{},
},
},

View File

@@ -5,6 +5,7 @@ package settings
import (
"context"
"fmt"
"net/netip"
"github.com/netbirdio/netbird/management/server/activity"
"github.com/netbirdio/netbird/management/server/integrations/extra_settings"
@@ -22,6 +23,9 @@ type Manager interface {
GetSettings(ctx context.Context, accountID string, userID string) (*types.Settings, error)
GetExtraSettings(ctx context.Context, accountID string) (*types.ExtraSettings, error)
UpdateExtraSettings(ctx context.Context, accountID, userID string, extraSettings *types.ExtraSettings) (bool, error)
// GetEffectiveNetworkRanges returns the actual allocated network ranges (v4 and v6).
// This includes auto-allocated ranges even when no custom override was set.
GetEffectiveNetworkRanges(ctx context.Context, accountID string) (v4, v6 netip.Prefix, err error)
}
// IdpConfig holds IdP-related configuration that is set at runtime
@@ -115,3 +119,28 @@ func (m *managerImpl) GetExtraSettings(ctx context.Context, accountID string) (*
func (m *managerImpl) UpdateExtraSettings(ctx context.Context, accountID, userID string, extraSettings *types.ExtraSettings) (bool, error) {
return m.extraSettingsManager.UpdateExtraSettings(ctx, accountID, userID, extraSettings)
}
// GetEffectiveNetworkRanges returns the actual allocated network ranges from the account's network object.
func (m *managerImpl) GetEffectiveNetworkRanges(ctx context.Context, accountID string) (netip.Prefix, netip.Prefix, error) {
network, err := m.store.GetAccountNetwork(ctx, store.LockingStrengthNone, accountID)
if err != nil {
return netip.Prefix{}, netip.Prefix{}, fmt.Errorf("get account network: %w", err)
}
var v4, v6 netip.Prefix
if network.Net.IP != nil {
addr, ok := netip.AddrFromSlice(network.Net.IP)
if ok {
ones, _ := network.Net.Mask.Size()
v4 = netip.PrefixFrom(addr.Unmap(), ones)
}
}
if network.NetV6.IP != nil {
addr, ok := netip.AddrFromSlice(network.NetV6.IP)
if ok {
ones, _ := network.NetV6.Mask.Size()
v6 = netip.PrefixFrom(addr.Unmap(), ones)
}
}
return v4, v6, nil
}

View File

@@ -6,6 +6,7 @@ package settings
import (
context "context"
netip "net/netip"
reflect "reflect"
gomock "github.com/golang/mock/gomock"
@@ -94,3 +95,19 @@ func (mr *MockManagerMockRecorder) UpdateExtraSettings(ctx, accountID, userID, e
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)
}

View File

@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"net"
"net/netip"
"os"
"path/filepath"
"runtime"
@@ -1501,7 +1502,7 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc
SELECT
id, created_by, created_at, domain, domain_category, is_domain_primary_account,
-- Embedded Network
network_identifier, network_net, network_dns, network_serial,
network_identifier, network_net, network_net_v6, network_dns, network_serial,
-- Embedded DNSSettings
dns_settings_disabled_management_groups,
-- Embedded Settings
@@ -1510,7 +1511,7 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc
settings_regular_users_view_blocked, settings_groups_propagation_enabled,
settings_jwt_groups_enabled, settings_jwt_groups_claim_name, settings_jwt_allow_groups,
settings_routing_peer_dns_resolution_enabled, settings_dns_domain, settings_network_range,
settings_lazy_connection_enabled,
settings_network_range_v6, settings_ipv6_enabled_groups, settings_lazy_connection_enabled,
settings_local_mfa_enabled,
-- Embedded ExtraSettings
settings_extra_peer_approval_enabled, settings_extra_user_approval_required,
@@ -1530,6 +1531,8 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc
sRoutingPeerDNSResolutionEnabled sql.NullBool
sDNSDomain sql.NullString
sNetworkRange sql.NullString
sNetworkRangeV6 sql.NullString
sIPv6EnabledGroups sql.NullString
sLazyConnectionEnabled sql.NullBool
sLocalMFAEnabled sql.NullBool
sExtraPeerApprovalEnabled sql.NullBool
@@ -1537,6 +1540,7 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc
sExtraIntegratedValidator sql.NullString
sExtraIntegratedValidatorGroups sql.NullString
networkNet sql.NullString
networkNetV6 sql.NullString
dnsSettingsDisabledGroups sql.NullString
networkIdentifier sql.NullString
networkDns sql.NullString
@@ -1545,14 +1549,14 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc
)
err := s.pool.QueryRow(ctx, accountQuery, accountID).Scan(
&account.Id, &account.CreatedBy, &createdAt, &account.Domain, &account.DomainCategory, &account.IsDomainPrimaryAccount,
&networkIdentifier, &networkNet, &networkDns, &networkSerial,
&networkIdentifier, &networkNet, &networkNetV6, &networkDns, &networkSerial,
&dnsSettingsDisabledGroups,
&sPeerLoginExpirationEnabled, &sPeerLoginExpiration,
&sPeerInactivityExpirationEnabled, &sPeerInactivityExpiration,
&sRegularUsersViewBlocked, &sGroupsPropagationEnabled,
&sJWTGroupsEnabled, &sJWTGroupsClaimName, &sJWTAllowGroups,
&sRoutingPeerDNSResolutionEnabled, &sDNSDomain, &sNetworkRange,
&sLazyConnectionEnabled,
&sNetworkRangeV6, &sIPv6EnabledGroups, &sLazyConnectionEnabled,
&sLocalMFAEnabled,
&sExtraPeerApprovalEnabled, &sExtraUserApprovalRequired,
&sExtraIntegratedValidator, &sExtraIntegratedValidatorGroups,
@@ -1625,6 +1629,15 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc
if sNetworkRange.Valid {
_ = json.Unmarshal([]byte(sNetworkRange.String), &account.Settings.NetworkRange)
}
if networkNetV6.Valid {
_ = json.Unmarshal([]byte(networkNetV6.String), &account.Network.NetV6)
}
if sNetworkRangeV6.Valid {
_ = json.Unmarshal([]byte(sNetworkRangeV6.String), &account.Settings.NetworkRangeV6)
}
if sIPv6EnabledGroups.Valid {
_ = json.Unmarshal([]byte(sIPv6EnabledGroups.String), &account.Settings.IPv6EnabledGroups)
}
if sExtraPeerApprovalEnabled.Valid {
account.Settings.Extra.PeerApprovalEnabled = sExtraPeerApprovalEnabled.Bool
@@ -1706,12 +1719,12 @@ func (s *SqlStore) getSetupKeys(ctx context.Context, accountID string) ([]types.
func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Peer, error) {
const query = `SELECT id, account_id, key, ip, name, dns_label, user_id, ssh_key, ssh_enabled, login_expiration_enabled,
inactivity_expiration_enabled, last_login, created_at, ephemeral, extra_dns_labels, allow_extra_dns_labels, meta_hostname,
meta_go_os, meta_kernel, meta_core, meta_platform, meta_os, meta_os_version, meta_wt_version, meta_ui_version,
inactivity_expiration_enabled, last_login, created_at, ephemeral, extra_dns_labels, allow_extra_dns_labels, meta_hostname,
meta_go_os, meta_kernel, meta_core, meta_platform, meta_os, meta_os_version, meta_wt_version, meta_ui_version,
meta_kernel_version, meta_network_addresses, meta_system_serial_number, meta_system_product_name, meta_system_manufacturer,
meta_environment, meta_flags, meta_files, peer_status_last_seen, peer_status_connected, peer_status_login_expired,
peer_status_requires_approval, location_connection_ip, location_country_code, location_city_name,
location_geo_name_id, proxy_meta_embedded, proxy_meta_cluster FROM peers WHERE account_id = $1`
meta_environment, meta_flags, meta_files, meta_capabilities, peer_status_last_seen, peer_status_connected, peer_status_login_expired,
peer_status_requires_approval, location_connection_ip, location_country_code, location_city_name,
location_geo_name_id, proxy_meta_embedded, proxy_meta_cluster, ipv6 FROM peers WHERE account_id = $1`
rows, err := s.pool.Query(ctx, query, accountID)
if err != nil {
return nil, err
@@ -1725,7 +1738,7 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee
sshEnabled, loginExpirationEnabled, inactivityExpirationEnabled, ephemeral, allowExtraDNSLabels sql.NullBool
peerStatusLastSeen sql.NullTime
peerStatusConnected, peerStatusLoginExpired, peerStatusRequiresApproval, proxyEmbedded sql.NullBool
ip, extraDNS, netAddr, env, flags, files, connIP []byte
ip, extraDNS, netAddr, env, flags, files, capabilities, connIP, ipv6 []byte
metaHostname, metaGoOS, metaKernel, metaCore, metaPlatform sql.NullString
metaOS, metaOSVersion, metaWtVersion, metaUIVersion, metaKernelVersion sql.NullString
metaSystemSerialNumber, metaSystemProductName, metaSystemManufacturer sql.NullString
@@ -1737,9 +1750,9 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee
&loginExpirationEnabled, &inactivityExpirationEnabled, &lastLogin, &createdAt, &ephemeral, &extraDNS,
&allowExtraDNSLabels, &metaHostname, &metaGoOS, &metaKernel, &metaCore, &metaPlatform,
&metaOS, &metaOSVersion, &metaWtVersion, &metaUIVersion, &metaKernelVersion, &netAddr,
&metaSystemSerialNumber, &metaSystemProductName, &metaSystemManufacturer, &env, &flags, &files,
&metaSystemSerialNumber, &metaSystemProductName, &metaSystemManufacturer, &env, &flags, &files, &capabilities,
&peerStatusLastSeen, &peerStatusConnected, &peerStatusLoginExpired, &peerStatusRequiresApproval, &connIP,
&locationCountryCode, &locationCityName, &locationGeoNameID, &proxyEmbedded, &proxyCluster)
&locationCountryCode, &locationCityName, &locationGeoNameID, &proxyEmbedded, &proxyCluster, &ipv6)
if err == nil {
if lastLogin.Valid {
@@ -1832,6 +1845,9 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee
if ip != nil {
_ = json.Unmarshal(ip, &p.IP)
}
if ipv6 != nil {
_ = json.Unmarshal(ipv6, &p.IPv6)
}
if extraDNS != nil {
_ = json.Unmarshal(extraDNS, &p.ExtraDNSLabels)
}
@@ -1847,6 +1863,9 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee
if files != nil {
_ = json.Unmarshal(files, &p.Meta.Files)
}
if capabilities != nil {
_ = json.Unmarshal(capabilities, &p.Meta.Capabilities)
}
if connIP != nil {
_ = json.Unmarshal(connIP, &p.Location.ConnectionIP)
}
@@ -2590,7 +2609,7 @@ func (s *SqlStore) GetAccountIDBySetupKey(ctx context.Context, setupKey string)
return accountID, nil
}
func (s *SqlStore) GetTakenIPs(ctx context.Context, lockStrength LockingStrength, accountID string) ([]net.IP, error) {
func (s *SqlStore) GetTakenIPs(ctx context.Context, lockStrength LockingStrength, accountID string) ([]netip.Addr, error) {
tx := s.db
if lockStrength != LockingStrengthNone {
tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)})
@@ -2598,7 +2617,6 @@ func (s *SqlStore) GetTakenIPs(ctx context.Context, lockStrength LockingStrength
var ipJSONStrings []string
// Fetch the IP addresses as JSON strings
result := tx.Model(&nbpeer.Peer{}).
Where("account_id = ?", accountID).
Pluck("ip", &ipJSONStrings)
@@ -2609,14 +2627,13 @@ func (s *SqlStore) GetTakenIPs(ctx context.Context, lockStrength LockingStrength
return nil, status.Errorf(status.Internal, "issue getting IPs from store: %s", result.Error)
}
// Convert the JSON strings to net.IP objects
ips := make([]net.IP, len(ipJSONStrings))
ips := make([]netip.Addr, len(ipJSONStrings))
for i, ipJSON := range ipJSONStrings {
var ip net.IP
var ip netip.Addr
if err := json.Unmarshal([]byte(ipJSON), &ip); err != nil {
return nil, status.Errorf(status.Internal, "issue parsing IP JSON from store")
}
ips[i] = ip
ips[i] = ip.Unmap()
}
return ips, nil
@@ -3214,7 +3231,7 @@ func (s *SqlStore) GetAccountPeers(ctx context.Context, lockStrength LockingStre
query = query.Where("name LIKE ?", "%"+nameFilter+"%")
}
if ipFilter != "" {
query = query.Where("ip LIKE ?", "%"+ipFilter+"%")
query = query.Where("ip LIKE ? OR ipv6 LIKE ?", "%"+ipFilter+"%", "%"+ipFilter+"%")
}
if err := query.Find(&peers).Error; err != nil {
@@ -4090,9 +4107,10 @@ func (s *SqlStore) SaveAccountSettings(ctx context.Context, accountID string, se
return status.Errorf(status.Internal, "failed to save account settings to store")
}
if result.RowsAffected == 0 {
return status.NewAccountNotFoundError(accountID)
}
// MySQL reports RowsAffected=0 for no-op updates where values don't change,
// unlike SQLite/Postgres which report matched rows. Skip the check since the
// caller (UpdateAccountSettings) already verified the account exists via
// GetAccountSettings with LockingStrengthUpdate.
return nil
}
@@ -4517,11 +4535,15 @@ func (s *SqlStore) GetPeerByIP(ctx context.Context, lockStrength LockingStrength
tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)})
}
column := "ip"
if ip.To4() == nil {
column = "ipv6"
}
jsonValue := fmt.Sprintf(`"%s"`, ip.String())
var peer nbpeer.Peer
result := tx.
Take(&peer, "account_id = ? AND ip = ?", accountID, jsonValue)
Take(&peer, fmt.Sprintf("account_id = ? AND %s = ?", column), accountID, jsonValue)
if result.Error != nil {
// no logging here
return nil, status.Errorf(status.Internal, "failed to get peer from store")
@@ -4643,6 +4665,27 @@ func (s *SqlStore) UpdateAccountNetwork(ctx context.Context, accountID string, i
return nil
}
// UpdateAccountNetworkV6 updates the IPv6 network range for the account.
func (s *SqlStore) UpdateAccountNetworkV6(ctx context.Context, accountID string, ipNet net.IPNet) error {
patch := accountNetworkPatch{
Network: &types.Network{NetV6: ipNet},
}
result := s.db.
Model(&types.Account{}).
Where(idQueryCondition, accountID).
Updates(&patch)
if result.Error != nil {
log.WithContext(ctx).Errorf("failed to update account network v6: %v", result.Error)
return status.Errorf(status.Internal, "update account network v6")
}
if result.RowsAffected == 0 {
return status.NewAccountNotFoundError(accountID)
}
return nil
}
func (s *SqlStore) GetPeersByGroupIDs(ctx context.Context, accountID string, groupIDs []string) ([]*nbpeer.Peer, error) {
if len(groupIDs) == 0 {
return []*nbpeer.Peer{}, nil

View File

@@ -148,7 +148,8 @@ func TestGetAccount_ComprehensiveFieldValidation(t *testing.T) {
AccountID: accountID,
Key: "peer-key-1-AAAA",
Name: "Peer 1",
IP: net.ParseIP("100.64.0.1"),
IP: netip.MustParseAddr("100.64.0.1"),
IPv6: netip.MustParseAddr("fd00::1"),
Meta: nbpeer.PeerSystemMeta{
Hostname: "peer1.example.com",
GoOS: "linux",
@@ -195,7 +196,8 @@ func TestGetAccount_ComprehensiveFieldValidation(t *testing.T) {
AccountID: accountID,
Key: "peer-key-2-BBBB",
Name: "Peer 2",
IP: net.ParseIP("100.64.0.2"),
IP: netip.MustParseAddr("100.64.0.2"),
IPv6: netip.MustParseAddr("fd00::2"),
Meta: nbpeer.PeerSystemMeta{
Hostname: "peer2.example.com",
GoOS: "darwin",
@@ -232,7 +234,8 @@ func TestGetAccount_ComprehensiveFieldValidation(t *testing.T) {
AccountID: accountID,
Key: "peer-key-3-CCCC",
Name: "Peer 3 (Ephemeral)",
IP: net.ParseIP("100.64.0.3"),
IP: netip.MustParseAddr("100.64.0.3"),
IPv6: netip.MustParseAddr("fd00::3"),
Meta: nbpeer.PeerSystemMeta{
Hostname: "peer3.example.com",
GoOS: "windows",
@@ -710,7 +713,7 @@ func TestGetAccount_ComprehensiveFieldValidation(t *testing.T) {
require.True(t, exists, "Peer 1 should exist")
assert.Equal(t, "Peer 1", p1.Name, "Peer 1 name mismatch")
assert.Equal(t, "peer-key-1-AAAA", p1.Key, "Peer 1 key mismatch")
assert.True(t, p1.IP.Equal(net.ParseIP("100.64.0.1")), "Peer 1 IP mismatch")
assert.Equal(t, netip.MustParseAddr("100.64.0.1"), p1.IP, "Peer 1 IP mismatch")
assert.Equal(t, userID1, p1.UserID, "Peer 1 user ID mismatch")
assert.True(t, p1.SSHEnabled, "Peer 1 SSH should be enabled")
assert.Equal(t, "ssh-rsa AAAAB3NzaC1...", p1.SSHKey, "Peer 1 SSH key mismatch")

View File

@@ -94,11 +94,12 @@ func runLargeTest(t *testing.T, store Store) {
for n := 0; n < numPerAccount; n++ {
netIP := randomIPv4()
peerID := fmt.Sprintf("%s-peer-%d", account.Id, n)
addr, _ := netip.AddrFromSlice(netIP)
peer := &nbpeer.Peer{
ID: peerID,
Key: peerID,
IP: netIP,
IP: addr.Unmap(),
Name: peerID,
DNSLabel: peerID,
UserID: "testuser",
@@ -235,7 +236,8 @@ func Test_SaveAccount(t *testing.T) {
account.SetupKeys[setupKey.Key] = setupKey
account.Peers["testpeer"] = &nbpeer.Peer{
Key: "peerkey",
IP: net.IP{127, 0, 0, 1},
IP: netip.AddrFrom4([4]byte{127, 0, 0, 1}),
IPv6: netip.MustParseAddr("fd00::1"),
Meta: nbpeer.PeerSystemMeta{},
Name: "peer name",
Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()},
@@ -249,7 +251,8 @@ func Test_SaveAccount(t *testing.T) {
account2.SetupKeys[setupKey.Key] = setupKey
account2.Peers["testpeer2"] = &nbpeer.Peer{
Key: "peerkey2",
IP: net.IP{127, 0, 0, 2},
IP: netip.AddrFrom4([4]byte{127, 0, 0, 2}),
IPv6: netip.MustParseAddr("fd00::2"),
Meta: nbpeer.PeerSystemMeta{},
Name: "peer name 2",
Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()},
@@ -316,7 +319,8 @@ func TestSqlite_DeleteAccount(t *testing.T) {
account.SetupKeys[setupKey.Key] = setupKey
account.Peers["testpeer"] = &nbpeer.Peer{
Key: "peerkey",
IP: net.IP{127, 0, 0, 1},
IP: netip.AddrFrom4([4]byte{127, 0, 0, 1}),
IPv6: netip.MustParseAddr("fd00::1"),
Meta: nbpeer.PeerSystemMeta{},
Name: "peer name",
Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()},
@@ -499,7 +503,8 @@ func TestSqlStore_SavePeer(t *testing.T) {
peer := &nbpeer.Peer{
Key: "peerkey",
ID: "testpeer",
IP: net.IP{127, 0, 0, 1},
IP: netip.AddrFrom4([4]byte{127, 0, 0, 1}),
IPv6: netip.MustParseAddr("fd00::1"),
Meta: nbpeer.PeerSystemMeta{Hostname: "testingpeer"},
Name: "peer name",
Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()},
@@ -556,7 +561,8 @@ func TestSqlStore_SavePeerStatus(t *testing.T) {
account.Peers["testpeer"] = &nbpeer.Peer{
Key: "peerkey",
ID: "testpeer",
IP: net.IP{127, 0, 0, 1},
IP: netip.AddrFrom4([4]byte{127, 0, 0, 1}),
IPv6: netip.MustParseAddr("fd00::1"),
Meta: nbpeer.PeerSystemMeta{},
Name: "peer name",
Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()},
@@ -784,7 +790,8 @@ func newAccount(store Store, id int) error {
account.SetupKeys[setupKey.Key] = setupKey
account.Peers["p"+str] = &nbpeer.Peer{
Key: "peerkey" + str,
IP: net.IP{127, 0, 0, 1},
IP: netip.AddrFrom4([4]byte{127, 0, 0, 1}),
IPv6: netip.MustParseAddr("fd00::1"),
Meta: nbpeer.PeerSystemMeta{},
Name: "peer name",
Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()},
@@ -823,7 +830,8 @@ func TestPostgresql_SaveAccount(t *testing.T) {
account.SetupKeys[setupKey.Key] = setupKey
account.Peers["testpeer"] = &nbpeer.Peer{
Key: "peerkey",
IP: net.IP{127, 0, 0, 1},
IP: netip.AddrFrom4([4]byte{127, 0, 0, 1}),
IPv6: netip.MustParseAddr("fd00::1"),
Meta: nbpeer.PeerSystemMeta{},
Name: "peer name",
Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()},
@@ -837,7 +845,8 @@ func TestPostgresql_SaveAccount(t *testing.T) {
account2.SetupKeys[setupKey.Key] = setupKey
account2.Peers["testpeer2"] = &nbpeer.Peer{
Key: "peerkey2",
IP: net.IP{127, 0, 0, 2},
IP: netip.AddrFrom4([4]byte{127, 0, 0, 2}),
IPv6: netip.MustParseAddr("fd00::2"),
Meta: nbpeer.PeerSystemMeta{},
Name: "peer name 2",
Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()},
@@ -903,7 +912,8 @@ func TestPostgresql_DeleteAccount(t *testing.T) {
account.SetupKeys[setupKey.Key] = setupKey
account.Peers["testpeer"] = &nbpeer.Peer{
Key: "peerkey",
IP: net.IP{127, 0, 0, 1},
IP: netip.AddrFrom4([4]byte{127, 0, 0, 1}),
IPv6: netip.MustParseAddr("fd00::1"),
Meta: nbpeer.PeerSystemMeta{},
Name: "peer name",
Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()},
@@ -1010,37 +1020,39 @@ func TestSqlite_GetTakenIPs(t *testing.T) {
takenIPs, err := store.GetTakenIPs(context.Background(), LockingStrengthNone, existingAccountID)
require.NoError(t, err)
assert.Equal(t, []net.IP{}, takenIPs)
assert.Equal(t, []netip.Addr{}, takenIPs)
peer1 := &nbpeer.Peer{
ID: "peer1",
AccountID: existingAccountID,
Key: "key1",
DNSLabel: "peer1",
IP: net.IP{1, 1, 1, 1},
IP: netip.AddrFrom4([4]byte{1, 1, 1, 1}),
IPv6: netip.MustParseAddr("fd00::1:1:1:1"),
}
err = store.AddPeerToAccount(context.Background(), peer1)
require.NoError(t, err)
takenIPs, err = store.GetTakenIPs(context.Background(), LockingStrengthNone, existingAccountID)
require.NoError(t, err)
ip1 := net.IP{1, 1, 1, 1}.To16()
assert.Equal(t, []net.IP{ip1}, takenIPs)
ip1 := netip.AddrFrom4([4]byte{1, 1, 1, 1})
assert.Equal(t, []netip.Addr{ip1}, takenIPs)
peer2 := &nbpeer.Peer{
ID: "peer1second",
AccountID: existingAccountID,
Key: "key2",
DNSLabel: "peer1-1",
IP: net.IP{2, 2, 2, 2},
IP: netip.AddrFrom4([4]byte{2, 2, 2, 2}),
IPv6: netip.MustParseAddr("fd00::2:2:2:2"),
}
err = store.AddPeerToAccount(context.Background(), peer2)
require.NoError(t, err)
takenIPs, err = store.GetTakenIPs(context.Background(), LockingStrengthNone, existingAccountID)
require.NoError(t, err)
ip2 := net.IP{2, 2, 2, 2}.To16()
assert.Equal(t, []net.IP{ip1, ip2}, takenIPs)
ip2 := netip.AddrFrom4([4]byte{2, 2, 2, 2})
assert.Equal(t, []netip.Addr{ip1, ip2}, takenIPs)
}
func TestSqlite_GetPeerLabelsInAccount(t *testing.T) {
@@ -1060,7 +1072,8 @@ func TestSqlite_GetPeerLabelsInAccount(t *testing.T) {
AccountID: existingAccountID,
Key: "key1",
DNSLabel: "peer1",
IP: net.IP{1, 1, 1, 1},
IP: netip.AddrFrom4([4]byte{1, 1, 1, 1}),
IPv6: netip.MustParseAddr("fd00::1:1:1:1"),
}
err = store.AddPeerToAccount(context.Background(), peer1)
require.NoError(t, err)
@@ -1074,7 +1087,8 @@ func TestSqlite_GetPeerLabelsInAccount(t *testing.T) {
AccountID: existingAccountID,
Key: "key2",
DNSLabel: "peer1-1",
IP: net.IP{2, 2, 2, 2},
IP: netip.AddrFrom4([4]byte{2, 2, 2, 2}),
IPv6: netip.MustParseAddr("fd00::2:2:2:2"),
}
err = store.AddPeerToAccount(context.Background(), peer2)
require.NoError(t, err)
@@ -1127,7 +1141,8 @@ func Test_AddPeerWithSameIP(t *testing.T) {
ID: "peer1",
AccountID: existingAccountID,
Key: "key1",
IP: net.IP{1, 1, 1, 1},
IP: netip.AddrFrom4([4]byte{1, 1, 1, 1}),
IPv6: netip.MustParseAddr("fd00::1:1:1:1"),
}
err = store.AddPeerToAccount(context.Background(), peer1)
require.NoError(t, err)
@@ -1136,7 +1151,8 @@ func Test_AddPeerWithSameIP(t *testing.T) {
ID: "peer1second",
AccountID: existingAccountID,
Key: "key2",
IP: net.IP{1, 1, 1, 1},
IP: netip.AddrFrom4([4]byte{1, 1, 1, 1}),
IPv6: netip.MustParseAddr("fd00::2:2:2:2"),
}
err = store.AddPeerToAccount(context.Background(), peer2)
require.Error(t, err)
@@ -2640,7 +2656,8 @@ func TestSqlStore_AddPeerToAccount(t *testing.T) {
ID: "peer1",
AccountID: accountID,
Key: "key",
IP: net.IP{1, 1, 1, 1},
IP: netip.AddrFrom4([4]byte{1, 1, 1, 1}),
IPv6: netip.MustParseAddr("fd00::1:1:1:1"),
Meta: nbpeer.PeerSystemMeta{
Hostname: "hostname",
GoOS: "linux",
@@ -3815,10 +3832,10 @@ func BenchmarkGetAccountPeers(b *testing.B) {
}
}
func intToIPv4(n uint32) net.IP {
ip := make(net.IP, 4)
binary.BigEndian.PutUint32(ip, n)
return ip
func intToIPv4(n uint32) netip.Addr {
var b [4]byte
binary.BigEndian.PutUint32(b[:], n)
return netip.AddrFrom4(b)
}
func TestSqlStore_GetPeersByGroupIDs(t *testing.T) {
@@ -3945,7 +3962,8 @@ func TestSqlStore_GetUserIDByPeerKey(t *testing.T) {
Key: peerKey,
AccountID: existingAccountID,
UserID: userID,
IP: net.IP{10, 0, 0, 1},
IP: netip.AddrFrom4([4]byte{10, 0, 0, 1}),
IPv6: netip.MustParseAddr("fd00::a00:1"),
DNSLabel: "test-peer-1",
}
@@ -3982,7 +4000,8 @@ func TestSqlStore_GetUserIDByPeerKey_NoUserID(t *testing.T) {
Key: peerKey,
AccountID: existingAccountID,
UserID: "",
IP: net.IP{10, 0, 0, 1},
IP: netip.AddrFrom4([4]byte{10, 0, 0, 1}),
IPv6: netip.MustParseAddr("fd00::a00:1"),
DNSLabel: "test-peer-1",
}
@@ -4009,7 +4028,8 @@ func TestSqlStore_ApproveAccountPeers(t *testing.T) {
AccountID: accountID,
DNSLabel: "peer1.netbird.cloud",
Key: "peer1-key",
IP: net.ParseIP("100.64.0.1"),
IP: netip.MustParseAddr("100.64.0.1"),
IPv6: netip.MustParseAddr("fd00::1"),
Status: &nbpeer.PeerStatus{
RequiresApproval: true,
LastSeen: time.Now().UTC(),
@@ -4020,7 +4040,8 @@ func TestSqlStore_ApproveAccountPeers(t *testing.T) {
AccountID: accountID,
DNSLabel: "peer2.netbird.cloud",
Key: "peer2-key",
IP: net.ParseIP("100.64.0.2"),
IP: netip.MustParseAddr("100.64.0.2"),
IPv6: netip.MustParseAddr("fd00::2"),
Status: &nbpeer.PeerStatus{
RequiresApproval: true,
LastSeen: time.Now().UTC(),
@@ -4031,7 +4052,8 @@ func TestSqlStore_ApproveAccountPeers(t *testing.T) {
AccountID: accountID,
DNSLabel: "peer3.netbird.cloud",
Key: "peer3-key",
IP: net.ParseIP("100.64.0.3"),
IP: netip.MustParseAddr("100.64.0.3"),
IPv6: netip.MustParseAddr("fd00::3"),
Status: &nbpeer.PeerStatus{
RequiresApproval: false,
LastSeen: time.Now().UTC(),

View File

@@ -344,7 +344,8 @@ func setupBenchmarkDB(b testing.TB) (*SqlStore, func(), string) {
ID: fmt.Sprintf("peer-%d", i),
AccountID: accountID,
Key: fmt.Sprintf("peerkey-%d", i),
IP: net.ParseIP(fmt.Sprintf("100.64.0.%d", i+1)),
IP: netip.MustParseAddr(fmt.Sprintf("100.64.0.%d", i+1)),
IPv6: netip.MustParseAddr(fmt.Sprintf("fd00::%d", i+1)),
Name: fmt.Sprintf("peer-name-%d", i),
Status: &nbpeer.PeerStatus{Connected: i%2 == 0, LastSeen: time.Now()},
})

View File

@@ -185,7 +185,7 @@ type Store interface {
SaveNameServerGroup(ctx context.Context, nameServerGroup *dns.NameServerGroup) error
DeleteNameServerGroup(ctx context.Context, accountID, nameServerGroupID string) error
GetTakenIPs(ctx context.Context, lockStrength LockingStrength, accountId string) ([]net.IP, error)
GetTakenIPs(ctx context.Context, lockStrength LockingStrength, accountId string) ([]netip.Addr, error)
IncrementNetworkSerial(ctx context.Context, accountId string) error
GetAccountNetwork(ctx context.Context, lockStrength LockingStrength, accountId string) (*types.Network, error)
@@ -225,6 +225,7 @@ type Store interface {
IsPrimaryAccount(ctx context.Context, accountID string) (bool, string, error)
MarkAccountPrimary(ctx context.Context, accountID string) error
UpdateAccountNetwork(ctx context.Context, accountID string, ipNet net.IPNet) error
UpdateAccountNetworkV6(ctx context.Context, accountID string, ipNet net.IPNet) error
GetPolicyRulesByResourceID(ctx context.Context, lockStrength LockingStrength, accountID string, peerID string) ([]*types.PolicyRule, error)
// SetFieldEncrypt sets the field encryptor for encrypting sensitive user data.

View File

@@ -7,6 +7,7 @@ package store
import (
context "context"
net "net"
netip "net/netip"
reflect "reflect"
time "time"
@@ -2138,10 +2139,10 @@ func (mr *MockStoreMockRecorder) GetStoreEngine() *gomock.Call {
}
// GetTakenIPs mocks base method.
func (m *MockStore) GetTakenIPs(ctx context.Context, lockStrength LockingStrength, accountId string) ([]net.IP, error) {
func (m *MockStore) GetTakenIPs(ctx context.Context, lockStrength LockingStrength, accountId string) ([]netip.Addr, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetTakenIPs", ctx, lockStrength, accountId)
ret0, _ := ret[0].([]net.IP)
ret0, _ := ret[0].([]netip.Addr)
ret1, _ := ret[1].(error)
return ret0, ret1
}
@@ -2952,6 +2953,20 @@ func (mr *MockStoreMockRecorder) UpdateAccountNetwork(ctx, accountID, ipNet inte
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAccountNetwork", reflect.TypeOf((*MockStore)(nil).UpdateAccountNetwork), ctx, accountID, ipNet)
}
// UpdateAccountNetworkV6 mocks base method.
func (m *MockStore) UpdateAccountNetworkV6(ctx context.Context, accountID string, ipNet net.IPNet) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateAccountNetworkV6", ctx, accountID, ipNet)
ret0, _ := ret[0].(error)
return ret0
}
// UpdateAccountNetworkV6 indicates an expected call of UpdateAccountNetworkV6.
func (mr *MockStoreMockRecorder) UpdateAccountNetworkV6(ctx, accountID, ipNet interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAccountNetworkV6", reflect.TypeOf((*MockStore)(nil).UpdateAccountNetworkV6), ctx, accountID, ipNet)
}
// UpdateCustomDomain mocks base method.
func (m *MockStore) UpdateCustomDomain(ctx context.Context, accountID string, d *domain.Domain) (*domain.Domain, error) {
m.ctrl.T.Helper()

View File

@@ -3,7 +3,6 @@ package types
import (
"context"
"fmt"
"net"
"net/netip"
"slices"
"strconv"
@@ -270,6 +269,8 @@ func (a *Account) GetPeersCustomZone(ctx context.Context, dnsDomain string) nbdn
domainSuffix := "." + dnsDomain
ipv6AllowedPeers := a.peerIPv6AllowedSet()
var sb strings.Builder
for _, peer := range a.Peers {
if peer.DNSLabel == "" {
@@ -281,13 +282,31 @@ func (a *Account) GetPeersCustomZone(ctx context.Context, dnsDomain string) nbdn
sb.WriteString(peer.DNSLabel)
sb.WriteString(domainSuffix)
fqdn := sb.String()
customZone.Records = append(customZone.Records, nbdns.SimpleRecord{
Name: sb.String(),
Name: fqdn,
Type: int(dns.TypeA),
Class: nbdns.DefaultClass,
TTL: defaultTTL,
RData: peer.IP.String(),
})
// Only advertise AAAA for peers that have a valid IPv6, whose client supports it,
// and that belong to an IPv6-enabled group. Old clients don't configure v6 on their
// WireGuard interface, so resolving their AAAA causes connections to hang.
// Capability changes (client upgrade/downgrade, --disable-ipv6 toggle) propagate
// to other peers via SyncPeer/LoginPeer regardless of version change, so AAAA
// records refresh when a peer first reports the IPv6 overlay capability.
_, peerAllowed := ipv6AllowedPeers[peer.ID]
hasIPv6 := peer.IPv6.IsValid() && peer.SupportsIPv6() && peerAllowed
if hasIPv6 {
customZone.Records = append(customZone.Records, nbdns.SimpleRecord{
Name: fqdn,
Type: int(dns.TypeAAAA),
Class: nbdns.DefaultClass,
TTL: defaultTTL,
RData: peer.IPv6.String(),
})
}
sb.Reset()
for _, extraLabel := range peer.ExtraDNSLabels {
@@ -295,13 +314,23 @@ func (a *Account) GetPeersCustomZone(ctx context.Context, dnsDomain string) nbdn
sb.WriteString(extraLabel)
sb.WriteString(domainSuffix)
extraFqdn := sb.String()
customZone.Records = append(customZone.Records, nbdns.SimpleRecord{
Name: sb.String(),
Name: extraFqdn,
Type: int(dns.TypeA),
Class: nbdns.DefaultClass,
TTL: defaultTTL,
RData: peer.IP.String(),
})
if hasIPv6 {
customZone.Records = append(customZone.Records, nbdns.SimpleRecord{
Name: extraFqdn,
Type: int(dns.TypeAAAA),
Class: nbdns.DefaultClass,
TTL: defaultTTL,
RData: peer.IPv6.String(),
})
}
sb.Reset()
}
@@ -569,8 +598,43 @@ func (a *Account) GetPeerGroups(peerID string) LookupMap {
return groupList
}
func (a *Account) GetTakenIPs() []net.IP {
var takenIps []net.IP
// PeerIPv6Allowed reports whether the given peer is in any of the account's IPv6 enabled groups.
// Returns false if IPv6 is disabled or no groups are configured.
func (a *Account) PeerIPv6Allowed(peerID string) bool {
if len(a.Settings.IPv6EnabledGroups) == 0 {
return false
}
for _, groupID := range a.Settings.IPv6EnabledGroups {
group, ok := a.Groups[groupID]
if !ok {
continue
}
if slices.Contains(group.Peers, peerID) {
return true
}
}
return false
}
// peerIPv6AllowedSet returns a set of peer IDs that belong to any IPv6-enabled group.
func (a *Account) peerIPv6AllowedSet() map[string]struct{} {
result := make(map[string]struct{})
for _, groupID := range a.Settings.IPv6EnabledGroups {
group, ok := a.Groups[groupID]
if !ok {
continue
}
for _, peerID := range group.Peers {
result[peerID] = struct{}{}
}
}
return result
}
// GetTakenIPs returns all peer IP addresses currently allocated in the account.
func (a *Account) GetTakenIPs() []netip.Addr {
takenIps := make([]netip.Addr, 0, len(a.Peers))
for _, existingPeer := range a.Peers {
takenIps = append(takenIps, existingPeer.IP)
}
@@ -927,10 +991,17 @@ func (a *Account) connResourcesGenerator(ctx context.Context, targetPeer *nbpeer
if len(rule.Ports) == 0 && len(rule.PortRanges) == 0 {
rules = append(rules, &fr)
continue
} else {
rules = append(rules, expandPortsAndRanges(fr, rule, targetPeer)...)
}
rules = append(rules, expandPortsAndRanges(fr, rule, targetPeer)...)
rules = appendIPv6FirewallRule(rules, rulesExists, peer, targetPeer, rule, firewallRuleContext{
direction: direction,
dirStr: strconv.Itoa(direction),
protocolStr: string(protocol),
actionStr: string(rule.Action),
portsJoined: strings.Join(rule.Ports, ","),
})
}
}, func() ([]*nbpeer.Peer, []*FirewallRule) {
return peers, rules
@@ -1045,7 +1116,7 @@ func (a *Account) GetPostureChecks(postureChecksID string) *posture.Checks {
return nil
}
func (a *Account) getRouteFirewallRules(ctx context.Context, peerID string, policies []*Policy, route *route.Route, validatedPeersMap map[string]struct{}, distributionPeers map[string]struct{}) []*RouteFirewallRule {
func (a *Account) getRouteFirewallRules(ctx context.Context, peerID string, policies []*Policy, route *route.Route, validatedPeersMap map[string]struct{}, distributionPeers map[string]struct{}, includeIPv6 bool) []*RouteFirewallRule {
var fwRules []*RouteFirewallRule
for _, policy := range policies {
if !policy.Enabled {
@@ -1058,7 +1129,7 @@ func (a *Account) getRouteFirewallRules(ctx context.Context, peerID string, poli
}
rulePeers := a.getRulePeers(rule, policy.SourcePostureChecks, peerID, distributionPeers, validatedPeersMap)
rules := generateRouteFirewallRules(ctx, route, rule, rulePeers, FirewallRuleDirectionIN)
rules := generateRouteFirewallRules(ctx, route, rule, rulePeers, FirewallRuleDirectionIN, includeIPv6)
fwRules = append(fwRules, rules...)
}
}
@@ -1140,7 +1211,7 @@ func (a *Account) GetPeerNetworkResourceFirewallRules(ctx context.Context, peer
resourceAppliedPolicies := resourcePolicies[string(route.GetResourceID())]
distributionPeers := getPoliciesSourcePeers(resourceAppliedPolicies, a.Groups)
rules := a.getRouteFirewallRules(ctx, peer.ID, resourceAppliedPolicies, route, validatedPeersMap, distributionPeers)
rules := a.getRouteFirewallRules(ctx, peer.ID, resourceAppliedPolicies, route, validatedPeersMap, distributionPeers, peer.SupportsIPv6() && peer.IPv6.IsValid())
for _, rule := range rules {
if len(rule.SourceRanges) > 0 {
routesFirewallRules = append(routesFirewallRules, rule)
@@ -1595,24 +1666,32 @@ func peerSupportedFirewallFeatures(peerVer string) supportedFeatures {
}
// filterZoneRecordsForPeers filters DNS records to only include peers to connect.
// AAAA records are excluded when the requesting peer lacks IPv6 capability.
func filterZoneRecordsForPeers(peer *nbpeer.Peer, customZone nbdns.CustomZone, peersToConnect, expiredPeers []*nbpeer.Peer) []nbdns.SimpleRecord {
filteredRecords := make([]nbdns.SimpleRecord, 0, len(customZone.Records))
peerIPs := make(map[string]struct{})
peerIPs := make(map[netip.Addr]struct{}, len(peersToConnect)+len(expiredPeers)+2)
includeIPv6 := peer.SupportsIPv6() && peer.IPv6.IsValid()
// Add peer's own IP to include its own DNS records
peerIPs[peer.IP.String()] = struct{}{}
for _, peerToConnect := range peersToConnect {
peerIPs[peerToConnect.IP.String()] = struct{}{}
addPeerIPs := func(p *nbpeer.Peer) {
peerIPs[p.IP] = struct{}{}
if includeIPv6 && p.IPv6.IsValid() {
peerIPs[p.IPv6] = struct{}{}
}
}
for _, expiredPeer := range expiredPeers {
peerIPs[expiredPeer.IP.String()] = struct{}{}
addPeerIPs(peer)
for _, p := range peersToConnect {
addPeerIPs(p)
}
for _, p := range expiredPeers {
addPeerIPs(p)
}
for _, record := range customZone.Records {
if _, exists := peerIPs[record.RData]; exists {
filteredRecords = append(filteredRecords, record)
if addr, err := netip.ParseAddr(record.RData); err == nil {
if _, exists := peerIPs[addr.Unmap()]; exists {
filteredRecords = append(filteredRecords, record)
}
}
}

View File

@@ -115,7 +115,7 @@ func (a *Account) GetPeerNetworkMapComponents(
components.Groups = relevantGroups
components.Policies = relevantPolicies
components.Routes = relevantRoutes
components.AllDNSRecords = filterDNSRecordsByPeers(peersCustomZone.Records, relevantPeers)
components.AllDNSRecords = filterDNSRecordsByPeers(peersCustomZone.Records, relevantPeers, peer.SupportsIPv6() && peer.IPv6.IsValid())
peerGroups := a.GetPeerGroups(peerID)
components.AccountZones = filterPeerAppliedZones(ctx, accountZones, peerGroups)
@@ -539,15 +539,22 @@ func filterPostureFailedPeers(postureFailedPeers *map[string]map[string]struct{}
}
}
func filterDNSRecordsByPeers(records []nbdns.SimpleRecord, peers map[string]*nbpeer.Peer) []nbdns.SimpleRecord {
func filterDNSRecordsByPeers(records []nbdns.SimpleRecord, peers map[string]*nbpeer.Peer, includeIPv6 bool) []nbdns.SimpleRecord {
if len(records) == 0 || len(peers) == 0 {
return nil
}
peerIPs := make(map[string]struct{}, len(peers))
// Include both v4 and v6 addresses so AAAA records (whose RData is an IPv6
// address) are not filtered out when peers have IPv6 assigned. When the
// requesting peer doesn't have IPv6, omit v6 IPs so AAAA records get dropped.
peerIPs := make(map[string]struct{}, len(peers)*2)
for _, peer := range peers {
if peer != nil {
peerIPs[peer.IP.String()] = struct{}{}
if peer == nil {
continue
}
peerIPs[peer.IP.String()] = struct{}{}
if includeIPv6 && peer.IPv6.IsValid() {
peerIPs[peer.IPv6.String()] = struct{}{}
}
}

View File

@@ -3,7 +3,7 @@ package types
import (
"context"
"fmt"
"net"
"net/netip"
"testing"
"github.com/miekg/dns"
@@ -921,7 +921,11 @@ func Test_FilterZoneRecordsForPeers(t *testing.T) {
},
peersToConnect: []*nbpeer.Peer{},
expiredPeers: []*nbpeer.Peer{},
peer: &nbpeer.Peer{ID: "router", IP: net.ParseIP("10.0.0.100")},
peer: &nbpeer.Peer{
ID: "router",
IP: netip.MustParseAddr("10.0.0.100"),
IPv6: netip.MustParseAddr("fd00::a00:64"),
},
expectedRecords: []nbdns.SimpleRecord{
{Name: "router.netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.100"},
},
@@ -948,14 +952,19 @@ func Test_FilterZoneRecordsForPeers(t *testing.T) {
var peers []*nbpeer.Peer
for _, i := range []int{1, 5, 10, 25, 50, 75, 100} {
peers = append(peers, &nbpeer.Peer{
ID: fmt.Sprintf("peer%d", i),
IP: net.ParseIP(fmt.Sprintf("10.0.%d.%d", i/256, i%256)),
ID: fmt.Sprintf("peer%d", i),
IP: netip.MustParseAddr(fmt.Sprintf("10.0.%d.%d", i/256, i%256)),
IPv6: netip.MustParseAddr(fmt.Sprintf("fd00::%d", i)),
})
}
return peers
}(),
expiredPeers: []*nbpeer.Peer{},
peer: &nbpeer.Peer{ID: "router", IP: net.ParseIP("10.0.0.100")},
peer: &nbpeer.Peer{
ID: "router",
IP: netip.MustParseAddr("10.0.0.100"),
IPv6: netip.MustParseAddr("fd00::a00:64"),
},
expectedRecords: func() []nbdns.SimpleRecord {
var records []nbdns.SimpleRecord
for _, i := range []int{1, 5, 10, 25, 50, 75, 100} {
@@ -986,11 +995,27 @@ func Test_FilterZoneRecordsForPeers(t *testing.T) {
},
},
peersToConnect: []*nbpeer.Peer{
{ID: "peer1", IP: net.ParseIP("10.0.0.1"), DNSLabel: "peer1", ExtraDNSLabels: []string{"peer1-alt", "peer1-backup"}},
{ID: "peer2", IP: net.ParseIP("10.0.0.2"), DNSLabel: "peer2", ExtraDNSLabels: []string{"peer2-service"}},
{
ID: "peer1",
IP: netip.MustParseAddr("10.0.0.1"),
IPv6: netip.MustParseAddr("fd00::a00:1"),
DNSLabel: "peer1",
ExtraDNSLabels: []string{"peer1-alt", "peer1-backup"},
},
{
ID: "peer2",
IP: netip.MustParseAddr("10.0.0.2"),
IPv6: netip.MustParseAddr("fd00::a00:2"),
DNSLabel: "peer2",
ExtraDNSLabels: []string{"peer2-service"},
},
},
expiredPeers: []*nbpeer.Peer{},
peer: &nbpeer.Peer{ID: "router", IP: net.ParseIP("10.0.0.100")},
peer: &nbpeer.Peer{
ID: "router",
IP: netip.MustParseAddr("10.0.0.100"),
IPv6: netip.MustParseAddr("fd00::a00:64"),
},
expectedRecords: []nbdns.SimpleRecord{
{Name: "peer1.netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"},
{Name: "peer1-alt.netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"},
@@ -1012,12 +1037,24 @@ func Test_FilterZoneRecordsForPeers(t *testing.T) {
},
},
peersToConnect: []*nbpeer.Peer{
{ID: "peer1", IP: net.ParseIP("10.0.0.1")},
{
ID: "peer1",
IP: netip.MustParseAddr("10.0.0.1"),
IPv6: netip.MustParseAddr("fd00::a00:1"),
},
},
expiredPeers: []*nbpeer.Peer{
{ID: "expired-peer", IP: net.ParseIP("10.0.0.99")},
{
ID: "expired-peer",
IP: netip.MustParseAddr("10.0.0.99"),
IPv6: netip.MustParseAddr("fd00::a00:63"),
},
},
peer: &nbpeer.Peer{
ID: "router",
IP: netip.MustParseAddr("10.0.0.100"),
IPv6: netip.MustParseAddr("fd00::a00:64"),
},
peer: &nbpeer.Peer{ID: "router", IP: net.ParseIP("10.0.0.100")},
expectedRecords: []nbdns.SimpleRecord{
{Name: "peer1.netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"},
{Name: "expired-peer.netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.99"},

View File

@@ -48,16 +48,26 @@ func (r *FirewallRule) Equal(other *FirewallRule) bool {
}
// generateRouteFirewallRules generates a list of firewall rules for a given route.
func generateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule *PolicyRule, groupPeers []*nbpeer.Peer, direction int) []*RouteFirewallRule {
// For static routes, source ranges match the destination family (v4 or v6).
// For dynamic routes (domain-based), separate v4 and v6 rules are generated
// so the routing peer's forwarding chain allows both address families.
func generateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule *PolicyRule, groupPeers []*nbpeer.Peer, direction int, includeIPv6 bool) []*RouteFirewallRule {
rulesExists := make(map[string]struct{})
rules := make([]*RouteFirewallRule, 0)
sourceRanges := make([]string, 0, len(groupPeers))
for _, peer := range groupPeers {
if peer == nil {
continue
}
sourceRanges = append(sourceRanges, fmt.Sprintf(AllowedIPsFormat, peer.IP))
v4Sources, v6Sources := splitPeerSourcesByFamily(groupPeers)
isV6Route := route.Network.Addr().Is6()
// Skip v6 destination routes entirely for peers without IPv6 support
if isV6Route && !includeIPv6 {
return rules
}
// Pick sources matching the destination family
sourceRanges := v4Sources
if isV6Route {
sourceRanges = v6Sources
}
baseRule := RouteFirewallRule{
@@ -71,18 +81,47 @@ func generateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule
IsDynamic: route.IsDynamic(),
}
// generate rule for port range
if len(rule.Ports) == 0 {
rules = append(rules, generateRulesWithPortRanges(baseRule, rule, rulesExists)...)
} else {
rules = append(rules, generateRulesWithPorts(ctx, baseRule, rule, rulesExists)...)
}
// TODO: generate IPv6 rules for dynamic routes
// Generate v6 counterpart for dynamic routes and 0.0.0.0/0 exit node routes.
isDefaultV4 := !isV6Route && route.Network.Bits() == 0
if includeIPv6 && (route.IsDynamic() || isDefaultV4) && len(v6Sources) > 0 {
v6Rule := baseRule
v6Rule.SourceRanges = v6Sources
if isDefaultV4 {
v6Rule.Destination = "::/0"
v6Rule.RouteID = route.ID + "-v6-default"
}
if len(rule.Ports) == 0 {
rules = append(rules, generateRulesWithPortRanges(v6Rule, rule, rulesExists)...)
} else {
rules = append(rules, generateRulesWithPorts(ctx, v6Rule, rule, rulesExists)...)
}
}
return rules
}
// splitPeerSourcesByFamily separates peer IPs into v4 (/32) and v6 (/128) source ranges.
func splitPeerSourcesByFamily(groupPeers []*nbpeer.Peer) (v4, v6 []string) {
v4 = make([]string, 0, len(groupPeers))
v6 = make([]string, 0, len(groupPeers))
for _, peer := range groupPeers {
if peer == nil {
continue
}
v4 = append(v4, fmt.Sprintf(AllowedIPsFormat, peer.IP))
if peer.IPv6.IsValid() {
v6 = append(v6, fmt.Sprintf(AllowedIPsV6Format, peer.IPv6))
}
}
return
}
// generateRulesForPeer generates rules for a given peer based on ports and port ranges.
func generateRulesWithPortRanges(baseRule RouteFirewallRule, rule *PolicyRule, rulesExists map[string]struct{}) []*RouteFirewallRule {
rules := make([]*RouteFirewallRule, 0)

View File

@@ -0,0 +1,197 @@
package types
import (
"context"
"net/netip"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
nbpeer "github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/route"
"github.com/netbirdio/netbird/shared/management/domain"
)
func TestSplitPeerSourcesByFamily(t *testing.T) {
peers := []*nbpeer.Peer{
{
IP: netip.MustParseAddr("100.64.0.1"),
IPv6: netip.MustParseAddr("fd00::1"),
},
{
IP: netip.MustParseAddr("100.64.0.2"),
},
{
IP: netip.MustParseAddr("100.64.0.3"),
IPv6: netip.MustParseAddr("fd00::3"),
},
nil,
}
v4, v6 := splitPeerSourcesByFamily(peers)
assert.Equal(t, []string{"100.64.0.1/32", "100.64.0.2/32", "100.64.0.3/32"}, v4)
assert.Equal(t, []string{"fd00::1/128", "fd00::3/128"}, v6)
}
func TestGenerateRouteFirewallRules_V4Route(t *testing.T) {
peers := []*nbpeer.Peer{
{
IP: netip.MustParseAddr("100.64.0.1"),
IPv6: netip.MustParseAddr("fd00::1"),
},
{
IP: netip.MustParseAddr("100.64.0.2"),
},
}
r := &route.Route{
ID: "route1",
Network: netip.MustParsePrefix("10.0.0.0/24"),
}
rule := &PolicyRule{
PolicyID: "policy1",
ID: "rule1",
Action: PolicyTrafficActionAccept,
Protocol: PolicyRuleProtocolALL,
}
rules := generateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, true)
require.Len(t, rules, 1)
assert.Equal(t, []string{"100.64.0.1/32", "100.64.0.2/32"}, rules[0].SourceRanges, "v4 route should only have v4 sources")
assert.Equal(t, "10.0.0.0/24", rules[0].Destination)
}
func TestGenerateRouteFirewallRules_V6Route(t *testing.T) {
peers := []*nbpeer.Peer{
{
IP: netip.MustParseAddr("100.64.0.1"),
IPv6: netip.MustParseAddr("fd00::1"),
},
{
IP: netip.MustParseAddr("100.64.0.2"),
},
}
r := &route.Route{
ID: "route1",
Network: netip.MustParsePrefix("2001:db8::/32"),
}
rule := &PolicyRule{
PolicyID: "policy1",
ID: "rule1",
Action: PolicyTrafficActionAccept,
Protocol: PolicyRuleProtocolALL,
}
rules := generateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, true)
require.Len(t, rules, 1)
assert.Equal(t, []string{"fd00::1/128"}, rules[0].SourceRanges, "v6 route should only have v6 sources")
}
func TestGenerateRouteFirewallRules_DynamicRoute_DualStack(t *testing.T) {
peers := []*nbpeer.Peer{
{
IP: netip.MustParseAddr("100.64.0.1"),
IPv6: netip.MustParseAddr("fd00::1"),
},
{
IP: netip.MustParseAddr("100.64.0.2"),
},
}
r := &route.Route{
ID: "route1",
NetworkType: route.DomainNetwork,
Domains: domain.List{"example.com"},
}
rule := &PolicyRule{
PolicyID: "policy1",
ID: "rule1",
Action: PolicyTrafficActionAccept,
Protocol: PolicyRuleProtocolALL,
}
rules := generateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, true)
require.Len(t, rules, 2, "dynamic route should produce both v4 and v6 rules")
assert.Equal(t, []string{"100.64.0.1/32", "100.64.0.2/32"}, rules[0].SourceRanges)
assert.Equal(t, []string{"fd00::1/128"}, rules[1].SourceRanges)
assert.Equal(t, rules[0].Domains, rules[1].Domains)
assert.True(t, rules[0].IsDynamic)
assert.True(t, rules[1].IsDynamic)
}
func TestGenerateRouteFirewallRules_DynamicRoute_NoV6Peers(t *testing.T) {
peers := []*nbpeer.Peer{
{IP: netip.MustParseAddr("100.64.0.1")},
{IP: netip.MustParseAddr("100.64.0.2")},
}
r := &route.Route{
ID: "route1",
NetworkType: route.DomainNetwork,
Domains: domain.List{"example.com"},
}
rule := &PolicyRule{
PolicyID: "policy1",
ID: "rule1",
Action: PolicyTrafficActionAccept,
Protocol: PolicyRuleProtocolALL,
}
rules := generateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, true)
require.Len(t, rules, 1, "no v6 peers means only v4 rule")
assert.Equal(t, []string{"100.64.0.1/32", "100.64.0.2/32"}, rules[0].SourceRanges)
}
func TestGenerateRouteFirewallRules_IncludeIPv6False(t *testing.T) {
peers := []*nbpeer.Peer{
{
IP: netip.MustParseAddr("100.64.0.1"),
IPv6: netip.MustParseAddr("fd00::1"),
},
{
IP: netip.MustParseAddr("100.64.0.2"),
IPv6: netip.MustParseAddr("fd00::2"),
},
}
t.Run("v6 route excluded", func(t *testing.T) {
r := &route.Route{
ID: "route1",
Network: netip.MustParsePrefix("2001:db8::/32"),
}
rule := &PolicyRule{
PolicyID: "policy1",
ID: "rule1",
Action: PolicyTrafficActionAccept,
Protocol: PolicyRuleProtocolALL,
}
rules := generateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, false)
assert.Empty(t, rules, "v6 route should produce no rules when includeIPv6 is false")
})
t.Run("dynamic route only v4", func(t *testing.T) {
r := &route.Route{
ID: "route1",
NetworkType: route.DomainNetwork,
Domains: domain.List{"example.com"},
}
rule := &PolicyRule{
PolicyID: "policy1",
ID: "rule1",
Action: PolicyTrafficActionAccept,
Protocol: PolicyRuleProtocolALL,
}
rules := generateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, false)
require.Len(t, rules, 1, "dynamic route with includeIPv6=false should produce only v4 rule")
assert.Equal(t, []string{"100.64.0.1/32", "100.64.0.2/32"}, rules[0].SourceRanges)
})
}

View File

@@ -0,0 +1,156 @@
package types_test
import (
"net/netip"
"testing"
"github.com/miekg/dns"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
nbpeer "github.com/netbirdio/netbird/management/server/peer"
)
func TestNetworkMapComponents_IPv6EndToEnd(t *testing.T) {
account := createComponentTestAccount()
// Make all peers IPv6-capable and assign IPv6 addrs.
v6Caps := []int32{nbpeer.PeerCapabilityIPv6Overlay, nbpeer.PeerCapabilitySourcePrefixes}
account.Peers["peer-src-1"].Meta.Capabilities = v6Caps
account.Peers["peer-src-1"].IPv6 = netip.MustParseAddr("fd00::1")
account.Peers["peer-src-2"].Meta.Capabilities = v6Caps
account.Peers["peer-src-2"].IPv6 = netip.MustParseAddr("fd00::2")
account.Peers["peer-dst-1"].Meta.Capabilities = v6Caps
account.Peers["peer-dst-1"].IPv6 = netip.MustParseAddr("fd00::3")
// Mark group-src and group-dst as IPv6-enabled.
account.Settings.IPv6EnabledGroups = []string{"group-src", "group-dst"}
validated := allPeersValidated(account)
nm := networkMapFromComponents(t, account, "peer-src-1", validated)
require.NotNil(t, nm)
t.Run("v6 AAAA records emitted", func(t *testing.T) {
require.NotEmpty(t, nm.DNSConfig.CustomZones, "expected at least one custom zone")
var hasAAAA bool
var hasA bool
for _, z := range nm.DNSConfig.CustomZones {
for _, r := range z.Records {
if r.Type == int(dns.TypeAAAA) {
hasAAAA = true
}
if r.Type == int(dns.TypeA) {
hasA = true
}
}
}
assert.True(t, hasA, "expected A records")
assert.True(t, hasAAAA, "expected AAAA records for IPv6-enabled peers")
})
t.Run("v6 AllowedIPs would be advertised", func(t *testing.T) {
// nm.Peers contains *nbpeer.Peer; IPv6 should be set on those peers
var foundV6 bool
for _, p := range nm.Peers {
if p.IPv6.IsValid() {
foundV6 = true
}
}
assert.True(t, foundV6, "remote peers should have IPv6 set so AllowedIPs gets v6")
})
t.Run("v6 firewall rules emitted", func(t *testing.T) {
require.NotEmpty(t, nm.FirewallRules, "expected firewall rules")
var hasV4 bool
var hasV6 bool
for _, r := range nm.FirewallRules {
addr, err := netip.ParseAddr(r.PeerIP)
if err != nil {
continue
}
if addr.Is4() {
hasV4 = true
}
if addr.Is6() {
hasV6 = true
}
}
assert.True(t, hasV4, "expected at least one v4 firewall rule (peer IP)")
assert.True(t, hasV6, "expected at least one v6 firewall rule (peer IPv6)")
})
}
// TestNetworkMapComponents_RemotePeerWithoutCapability checks the asymmetric
// case where the target peer is IPv6-capable but a remote peer has an IPv6
// address assigned in the DB without yet reporting the capability flag.
// In that case the remote peer's v6 still appears in AllowedIPs (gated on
// the target peer's capability) but its AAAA record does not (gated on the
// remote peer's own capability).
func TestNetworkMapComponents_RemotePeerWithoutCapability(t *testing.T) {
account := createComponentTestAccount()
v6Caps := []int32{nbpeer.PeerCapabilityIPv6Overlay, nbpeer.PeerCapabilitySourcePrefixes}
// Target is fully capable.
account.Peers["peer-src-1"].Meta.Capabilities = v6Caps
account.Peers["peer-src-1"].IPv6 = netip.MustParseAddr("fd00::1")
// Remote peer has v6 assigned but no capability flag yet (e.g. old client).
account.Peers["peer-dst-1"].IPv6 = netip.MustParseAddr("fd00::3")
account.Settings.IPv6EnabledGroups = []string{"group-src", "group-dst"}
validated := allPeersValidated(account)
nm := networkMapFromComponents(t, account, "peer-src-1", validated)
require.NotNil(t, nm)
t.Run("AllowedIPs include remote v6", func(t *testing.T) {
var dst *nbpeer.Peer
for _, p := range nm.Peers {
if p.ID == "peer-dst-1" {
dst = p
}
}
require.NotNil(t, dst)
assert.True(t, dst.IPv6.IsValid(), "remote peer's v6 should still be present so AllowedIPs gets v6/128 (gated on target peer cap)")
})
t.Run("no AAAA for non-capable remote peer", func(t *testing.T) {
for _, z := range nm.DNSConfig.CustomZones {
for _, r := range z.Records {
if r.Type == int(dns.TypeAAAA) && r.RData == "fd00::3" {
t.Errorf("AAAA record for non-capable remote peer should NOT be emitted, got %+v", r)
}
}
}
})
}
// TestNetworkMapComponents_IPv6Disabled_NoV6Output asserts that a peer that
// does not support IPv6 (e.g. older client without the capability flag) gets
// no v6 firewall rules and no AAAA records, even if other peers have IPv6.
func TestNetworkMapComponents_IPv6Disabled_NoV6Output(t *testing.T) {
account := createComponentTestAccount()
v6Caps := []int32{nbpeer.PeerCapabilityIPv6Overlay}
account.Peers["peer-src-2"].Meta.Capabilities = v6Caps
account.Peers["peer-src-2"].IPv6 = netip.MustParseAddr("fd00::2")
account.Peers["peer-dst-1"].Meta.Capabilities = v6Caps
account.Peers["peer-dst-1"].IPv6 = netip.MustParseAddr("fd00::3")
// peer-src-1 (target) intentionally has no capability and no IPv6.
account.Settings.IPv6EnabledGroups = []string{"group-src", "group-dst"}
validated := allPeersValidated(account)
nm := networkMapFromComponents(t, account, "peer-src-1", validated)
require.NotNil(t, nm)
t.Run("no v6 firewall rules", func(t *testing.T) {
for _, r := range nm.FirewallRules {
addr, err := netip.ParseAddr(r.PeerIP)
if err != nil {
continue
}
assert.False(t, addr.Is6(), "v6 firewall rules should not be emitted for non-IPv6 peer (got %s)", r.PeerIP)
}
})
}

View File

@@ -0,0 +1,234 @@
package types
import (
"net/netip"
"testing"
"github.com/stretchr/testify/assert"
nbpeer "github.com/netbirdio/netbird/management/server/peer"
)
func TestPeerIPv6Allowed(t *testing.T) {
account := &Account{
Groups: map[string]*Group{
"group-all": {ID: "group-all", Name: "All", Peers: []string{"peer1", "peer2", "peer3"}},
"group-devs": {ID: "group-devs", Name: "Devs", Peers: []string{"peer1", "peer2"}},
"group-infra": {ID: "group-infra", Name: "Infra", Peers: []string{"peer2", "peer3"}},
"group-empty": {ID: "group-empty", Name: "Empty", Peers: []string{}},
},
Settings: &Settings{},
}
tests := []struct {
name string
enabledGroups []string
peerID string
expected bool
}{
{
name: "empty groups list disables IPv6 for all",
enabledGroups: []string{},
peerID: "peer1",
expected: false,
},
{
name: "All group enables IPv6 for everyone",
enabledGroups: []string{"group-all"},
peerID: "peer1",
expected: true,
},
{
name: "peer in enabled group gets IPv6",
enabledGroups: []string{"group-devs"},
peerID: "peer1",
expected: true,
},
{
name: "peer not in any enabled group denied IPv6",
enabledGroups: []string{"group-devs"},
peerID: "peer3",
expected: false,
},
{
name: "peer in multiple groups, one enabled",
enabledGroups: []string{"group-infra"},
peerID: "peer2",
expected: true,
},
{
name: "peer in multiple groups, other one enabled",
enabledGroups: []string{"group-devs"},
peerID: "peer2",
expected: true,
},
{
name: "multiple enabled groups, peer in one",
enabledGroups: []string{"group-devs", "group-infra"},
peerID: "peer1",
expected: true,
},
{
name: "multiple enabled groups, peer in both",
enabledGroups: []string{"group-devs", "group-infra"},
peerID: "peer2",
expected: true,
},
{
name: "nonexistent group ID in enabled list",
enabledGroups: []string{"group-deleted"},
peerID: "peer1",
expected: false,
},
{
name: "empty group in enabled list",
enabledGroups: []string{"group-empty"},
peerID: "peer1",
expected: false,
},
{
name: "unknown peer ID",
enabledGroups: []string{"group-all"},
peerID: "peer-unknown",
expected: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
account.Settings.IPv6EnabledGroups = tc.enabledGroups
result := account.PeerIPv6Allowed(tc.peerID)
assert.Equal(t, tc.expected, result)
})
}
}
func TestIPv6RecalculationOnGroupChange(t *testing.T) {
peerWithV6 := func(id string, v6 string) *nbpeer.Peer {
p := &nbpeer.Peer{
ID: id,
IP: netip.MustParseAddr("100.64.0.1"),
}
if v6 != "" {
p.IPv6 = netip.MustParseAddr(v6)
}
return p
}
t.Run("peer loses IPv6 when removed from enabled groups", func(t *testing.T) {
peer := peerWithV6("peer1", "fd00::1")
account := &Account{
Peers: map[string]*nbpeer.Peer{"peer1": peer},
Groups: map[string]*Group{
"group-a": {ID: "group-a", Peers: []string{"peer1"}},
"group-b": {ID: "group-b", Peers: []string{}},
},
Settings: &Settings{
IPv6EnabledGroups: []string{"group-a"},
},
}
assert.True(t, account.PeerIPv6Allowed("peer1"), "peer should be allowed before change")
// Move peer out of enabled group
account.Groups["group-a"].Peers = []string{}
account.Groups["group-b"].Peers = []string{"peer1"}
assert.False(t, account.PeerIPv6Allowed("peer1"), "peer should be denied after group change")
})
t.Run("peer gains IPv6 when added to enabled group", func(t *testing.T) {
peer := peerWithV6("peer1", "")
account := &Account{
Peers: map[string]*nbpeer.Peer{"peer1": peer},
Groups: map[string]*Group{
"group-a": {ID: "group-a", Peers: []string{}},
"group-b": {ID: "group-b", Peers: []string{"peer1"}},
},
Settings: &Settings{
IPv6EnabledGroups: []string{"group-a"},
},
}
assert.False(t, account.PeerIPv6Allowed("peer1"), "peer should be denied before change")
// Add peer to enabled group
account.Groups["group-a"].Peers = []string{"peer1"}
assert.True(t, account.PeerIPv6Allowed("peer1"), "peer should be allowed after joining enabled group")
})
t.Run("peer in two groups, one leaves enabled list", func(t *testing.T) {
peer := peerWithV6("peer1", "fd00::1")
account := &Account{
Peers: map[string]*nbpeer.Peer{"peer1": peer},
Groups: map[string]*Group{
"group-a": {ID: "group-a", Peers: []string{"peer1"}},
"group-b": {ID: "group-b", Peers: []string{"peer1"}},
},
Settings: &Settings{
IPv6EnabledGroups: []string{"group-a", "group-b"},
},
}
assert.True(t, account.PeerIPv6Allowed("peer1"))
// Remove group-a from enabled list, peer still in group-b
account.Settings.IPv6EnabledGroups = []string{"group-b"}
assert.True(t, account.PeerIPv6Allowed("peer1"), "peer should still be allowed via group-b")
})
t.Run("peer in two groups, both leave enabled list", func(t *testing.T) {
peer := peerWithV6("peer1", "fd00::1")
account := &Account{
Peers: map[string]*nbpeer.Peer{"peer1": peer},
Groups: map[string]*Group{
"group-a": {ID: "group-a", Peers: []string{"peer1"}},
"group-b": {ID: "group-b", Peers: []string{"peer1"}},
},
Settings: &Settings{
IPv6EnabledGroups: []string{"group-a", "group-b"},
},
}
assert.True(t, account.PeerIPv6Allowed("peer1"))
// Clear all enabled groups
account.Settings.IPv6EnabledGroups = []string{}
assert.False(t, account.PeerIPv6Allowed("peer1"), "peer should be denied when no groups enabled")
})
t.Run("enabling a group gives only its peers IPv6", func(t *testing.T) {
account := &Account{
Peers: map[string]*nbpeer.Peer{
"peer1": peerWithV6("peer1", ""),
"peer2": peerWithV6("peer2", ""),
"peer3": peerWithV6("peer3", ""),
},
Groups: map[string]*Group{
"group-devs": {ID: "group-devs", Peers: []string{"peer1", "peer2"}},
"group-infra": {ID: "group-infra", Peers: []string{"peer2", "peer3"}},
},
Settings: &Settings{
IPv6EnabledGroups: []string{"group-devs"},
},
}
assert.True(t, account.PeerIPv6Allowed("peer1"), "peer1 in devs")
assert.True(t, account.PeerIPv6Allowed("peer2"), "peer2 in devs")
assert.False(t, account.PeerIPv6Allowed("peer3"), "peer3 not in devs")
// Add infra group
account.Settings.IPv6EnabledGroups = []string{"group-devs", "group-infra"}
assert.True(t, account.PeerIPv6Allowed("peer1"), "peer1 still in devs")
assert.True(t, account.PeerIPv6Allowed("peer2"), "peer2 in both")
assert.True(t, account.PeerIPv6Allowed("peer3"), "peer3 now in infra")
})
}

View File

@@ -2,8 +2,11 @@ package types
import (
"encoding/binary"
"fmt"
"math/rand"
"net"
"net/netip"
"slices"
"sync"
"time"
@@ -27,6 +30,12 @@ const (
// AllowedIPsFormat generates Wireguard AllowedIPs format (e.g. 100.64.30.1/32)
AllowedIPsFormat = "%s/32"
// AllowedIPsV6Format generates AllowedIPs format for v6 (e.g. fd12:3456:7890::1/128)
AllowedIPsV6Format = "%s/128"
// IPv6SubnetSize is the prefix length of per-account IPv6 subnets.
// Each account gets a /64 from its unique /48 ULA prefix.
IPv6SubnetSize = 64
)
type NetworkMap struct {
@@ -111,7 +120,9 @@ func ipToBytes(ip net.IP) []byte {
type Network struct {
Identifier string `json:"id"`
Net net.IPNet `gorm:"serializer:json"`
Dns string
// NetV6 is the IPv6 ULA subnet for this account's overlay. Empty if not yet allocated.
NetV6 net.IPNet `gorm:"serializer:json"`
Dns string
// Serial is an ID that increments by 1 when any change to the network happened (e.g. new peer has been added).
// Used to synchronize state to the client apps.
Serial uint64
@@ -121,20 +132,45 @@ type Network struct {
// NewNetwork creates a new Network initializing it with a Serial=0
// It takes a random /16 subnet from 100.64.0.0/10 (64 different subnets)
// and a random /64 subnet from fd00:4e42::/32 for IPv6.
func NewNetwork() *Network {
n := iplib.NewNet4(net.ParseIP("100.64.0.0"), NetSize)
sub, _ := n.Subnet(SubnetSize)
s := rand.NewSource(time.Now().Unix())
s := rand.NewSource(time.Now().UnixNano())
r := rand.New(s)
intn := r.Intn(len(sub))
return &Network{
Identifier: xid.New().String(),
Net: sub[intn].IPNet,
NetV6: AllocateIPv6Subnet(r),
Dns: "",
Serial: 0}
Serial: 0,
}
}
// AllocateIPv6Subnet generates a random RFC 4193 ULA /64 prefix.
// The format follows RFC 4193 section 3.1: fd + 40-bit Global ID + 16-bit Subnet ID.
// The Global ID and Subnet ID are randomized (simplified from the SHA-1 algorithm
// in section 3.2.2), giving 2^56 possible /64 subnets across all accounts.
func AllocateIPv6Subnet(r *rand.Rand) net.IPNet {
ip := make(net.IP, 16)
ip[0] = 0xfd
// Bytes 1-5: 40-bit random Global ID
ip[1] = byte(r.Intn(256))
ip[2] = byte(r.Intn(256))
ip[3] = byte(r.Intn(256))
ip[4] = byte(r.Intn(256))
ip[5] = byte(r.Intn(256))
// Bytes 6-7: 16-bit random Subnet ID
ip[6] = byte(r.Intn(256))
ip[7] = byte(r.Intn(256))
return net.IPNet{
IP: ip,
Mask: net.CIDRMask(IPv6SubnetSize, 128),
}
}
// IncSerial increments Serial by 1 reflecting that the network state has been changed
@@ -157,19 +193,19 @@ func (n *Network) Copy() *Network {
return &Network{
Identifier: n.Identifier,
Net: n.Net,
NetV6: n.NetV6,
Dns: n.Dns,
Serial: n.Serial,
}
}
// AllocatePeerIP pics an available IP from an net.IPNet.
// This method considers already taken IPs and reuses IPs if there are gaps in takenIps
// E.g. if ipNet=100.30.0.0/16 and takenIps=[100.30.0.1, 100.30.0.4] then the result would be 100.30.0.2 or 100.30.0.3
func AllocatePeerIP(ipNet net.IPNet, takenIps []net.IP) (net.IP, error) {
baseIP := ipToUint32(ipNet.IP.Mask(ipNet.Mask))
ones, bits := ipNet.Mask.Size()
hostBits := bits - ones
// AllocatePeerIP picks an available IP from a netip.Prefix.
// This method considers already taken IPs and reuses IPs if there are gaps in takenIps.
// E.g. if prefix=100.30.0.0/16 and takenIps=[100.30.0.1, 100.30.0.4] then the result would be 100.30.0.2 or 100.30.0.3.
func AllocatePeerIP(prefix netip.Prefix, takenIps []netip.Addr) (netip.Addr, error) {
b := prefix.Masked().Addr().As4()
baseIP := binary.BigEndian.Uint32(b[:])
hostBits := 32 - prefix.Bits()
totalIPs := uint32(1 << hostBits)
taken := make(map[uint32]struct{}, len(takenIps)+1)
@@ -177,7 +213,8 @@ func AllocatePeerIP(ipNet net.IPNet, takenIps []net.IP) (net.IP, error) {
taken[baseIP+totalIPs-1] = struct{}{} // reserve broadcast IP
for _, ip := range takenIps {
taken[ipToUint32(ip)] = struct{}{}
ab := ip.As4()
taken[binary.BigEndian.Uint32(ab[:])] = struct{}{}
}
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
@@ -198,15 +235,14 @@ func AllocatePeerIP(ipNet net.IPNet, takenIps []net.IP) (net.IP, error) {
}
}
return nil, status.Errorf(status.PreconditionFailed, "network %s is out of IPs", ipNet.String())
return netip.Addr{}, status.Errorf(status.PreconditionFailed, "network %s is out of IPs", prefix.String())
}
func AllocateRandomPeerIP(ipNet net.IPNet) (net.IP, error) {
baseIP := ipToUint32(ipNet.IP.Mask(ipNet.Mask))
ones, bits := ipNet.Mask.Size()
hostBits := bits - ones
// AllocateRandomPeerIP picks a random available IP from a netip.Prefix.
func AllocateRandomPeerIP(prefix netip.Prefix) (netip.Addr, error) {
b := prefix.Masked().Addr().As4()
baseIP := binary.BigEndian.Uint32(b[:])
hostBits := 32 - prefix.Bits()
totalIPs := uint32(1 << hostBits)
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
@@ -216,18 +252,75 @@ func AllocateRandomPeerIP(ipNet net.IPNet) (net.IP, error) {
return uint32ToIP(candidate), nil
}
func ipToUint32(ip net.IP) uint32 {
ip = ip.To4()
if len(ip) < 4 {
return 0
// AllocateRandomPeerIPv6 picks a random host address within the given IPv6 prefix.
// Only the host bits (after the prefix length) are randomized.
func AllocateRandomPeerIPv6(prefix netip.Prefix) (netip.Addr, error) {
ones := prefix.Bits()
if ones == 0 || ones > 126 || !prefix.Addr().Is6() {
return netip.Addr{}, fmt.Errorf("invalid IPv6 subnet: %s", prefix.String())
}
return binary.BigEndian.Uint32(ip)
ip := prefix.Addr().As16()
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
// Determine which byte the host bits start in
firstHostByte := ones / 8
// If the prefix doesn't end on a byte boundary, handle the partial byte
partialBits := ones % 8
if partialBits > 0 {
// Keep the network bits in the partial byte, randomize the rest
hostMask := byte(0xff >> partialBits)
ip[firstHostByte] = (ip[firstHostByte] & ^hostMask) | (byte(rng.Intn(256)) & hostMask)
firstHostByte++
}
// Randomize remaining full host bytes
for i := firstHostByte; i < 16; i++ {
ip[i] = byte(rng.Intn(256))
}
// Avoid all-zeros and all-ones host parts by checking only host bits.
if isHostAllZeroOrOnes(ip[:], ones) {
ip = prefix.Masked().Addr().As16()
ip[15] |= 0x01
}
return netip.AddrFrom16(ip).Unmap(), nil
}
func uint32ToIP(n uint32) net.IP {
ip := make(net.IP, 4)
binary.BigEndian.PutUint32(ip, n)
return ip
// isHostAllZeroOrOnes checks whether all host bits (after prefixLen) are zero or all ones.
func isHostAllZeroOrOnes(ip []byte, prefixLen int) bool {
hostStart := prefixLen / 8
partialBits := prefixLen % 8
hostSlice := slices.Clone(ip[hostStart:])
if partialBits > 0 {
hostSlice[0] &= 0xff >> partialBits
}
allZero := !slices.ContainsFunc(hostSlice, func(v byte) bool { return v != 0 })
if allZero {
return true
}
// Build the all-ones mask for host bits
onesMask := make([]byte, len(hostSlice))
for i := range onesMask {
onesMask[i] = 0xff
}
if partialBits > 0 {
onesMask[0] = 0xff >> partialBits
}
return slices.Equal(hostSlice, onesMask)
}
func uint32ToIP(n uint32) netip.Addr {
var b [4]byte
binary.BigEndian.PutUint32(b[:], n)
return netip.AddrFrom4(b)
}
// generateIPs generates a list of all possible IPs of the given network excluding IPs specified in the exclusion list

View File

@@ -1,7 +1,9 @@
package types
import (
"encoding/binary"
"net"
"net/netip"
"testing"
"github.com/stretchr/testify/assert"
@@ -17,10 +19,10 @@ func TestNewNetwork(t *testing.T) {
}
func TestAllocatePeerIP(t *testing.T) {
ipNet := net.IPNet{IP: net.ParseIP("100.64.0.0"), Mask: net.IPMask{255, 255, 255, 0}}
var ips []net.IP
prefix := netip.MustParsePrefix("100.64.0.0/24")
var ips []netip.Addr
for i := 0; i < 252; i++ {
ip, err := AllocatePeerIP(ipNet, ips)
ip, err := AllocatePeerIP(prefix, ips)
if err != nil {
t.Fatal(err)
}
@@ -41,19 +43,19 @@ func TestAllocatePeerIP(t *testing.T) {
func TestAllocatePeerIPSmallSubnet(t *testing.T) {
// Test /27 network (10.0.0.0/27) - should only have 30 usable IPs (10.0.0.1 to 10.0.0.30)
ipNet := net.IPNet{IP: net.ParseIP("10.0.0.0"), Mask: net.IPMask{255, 255, 255, 224}}
var ips []net.IP
prefix := netip.MustParsePrefix("10.0.0.0/27")
var ips []netip.Addr
// Allocate all available IPs in the /27 network
for i := 0; i < 30; i++ {
ip, err := AllocatePeerIP(ipNet, ips)
ip, err := AllocatePeerIP(prefix, ips)
if err != nil {
t.Fatal(err)
}
// Verify IP is within the correct range
if !ipNet.Contains(ip) {
t.Errorf("allocated IP %s is not within network %s", ip.String(), ipNet.String())
if !prefix.Contains(ip) {
t.Errorf("allocated IP %s is not within network %s", ip.String(), prefix.String())
}
ips = append(ips, ip)
@@ -72,7 +74,7 @@ func TestAllocatePeerIPSmallSubnet(t *testing.T) {
}
// Try to allocate one more IP - should fail as network is full
_, err := AllocatePeerIP(ipNet, ips)
_, err := AllocatePeerIP(prefix, ips)
if err == nil {
t.Error("expected error when network is full, but got none")
}
@@ -95,10 +97,11 @@ func TestAllocatePeerIPVariousCIDRs(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
_, ipNet, err := net.ParseCIDR(tc.cidr)
prefix, err := netip.ParsePrefix(tc.cidr)
require.NoError(t, err)
prefix = prefix.Masked()
var ips []net.IP
var ips []netip.Addr
// For larger networks, test only a subset to avoid long test runs
testCount := tc.expectedUsable
@@ -108,21 +111,21 @@ func TestAllocatePeerIPVariousCIDRs(t *testing.T) {
// Allocate IPs and verify they're within the correct range
for i := 0; i < testCount; i++ {
ip, err := AllocatePeerIP(*ipNet, ips)
ip, err := AllocatePeerIP(prefix, ips)
require.NoError(t, err, "failed to allocate IP %d", i)
// Verify IP is within the correct range
assert.True(t, ipNet.Contains(ip), "allocated IP %s is not within network %s", ip.String(), ipNet.String())
assert.True(t, prefix.Contains(ip), "allocated IP %s is not within network %s", ip.String(), prefix.String())
// Verify IP is not network or broadcast address
networkIP := ipNet.IP.Mask(ipNet.Mask)
ones, bits := ipNet.Mask.Size()
hostBits := bits - ones
broadcastInt := uint32(ipToUint32(networkIP)) + (1 << hostBits) - 1
broadcastIP := uint32ToIP(broadcastInt)
networkAddr := prefix.Masked().Addr()
hostBits := 32 - prefix.Bits()
b := networkAddr.As4()
baseIP := binary.BigEndian.Uint32(b[:])
broadcastIP := uint32ToIP(baseIP + (1 << hostBits) - 1)
assert.False(t, ip.Equal(networkIP), "allocated network address %s", ip.String())
assert.False(t, ip.Equal(broadcastIP), "allocated broadcast address %s", ip.String())
assert.NotEqual(t, networkAddr, ip, "allocated network address %s", ip.String())
assert.NotEqual(t, broadcastIP, ip, "allocated broadcast address %s", ip.String())
ips = append(ips, ip)
}
@@ -151,3 +154,111 @@ func TestGenerateIPs(t *testing.T) {
t.Errorf("expected last ip to be: 100.64.0.253, got %s", ips[len(ips)-1].String())
}
}
func TestNewNetworkHasIPv6(t *testing.T) {
network := NewNetwork()
assert.NotNil(t, network.NetV6.IP, "v6 subnet should be allocated")
assert.True(t, network.NetV6.IP.To4() == nil, "v6 subnet should be IPv6")
assert.Equal(t, byte(0xfd), network.NetV6.IP[0], "v6 subnet should be ULA (fd prefix)")
ones, bits := network.NetV6.Mask.Size()
assert.Equal(t, 64, ones, "v6 subnet should be /64")
assert.Equal(t, 128, bits)
}
func TestAllocateIPv6SubnetUniqueness(t *testing.T) {
seen := make(map[string]struct{})
for i := 0; i < 100; i++ {
network := NewNetwork()
key := network.NetV6.IP.String()
_, duplicate := seen[key]
assert.False(t, duplicate, "duplicate v6 subnet: %s", key)
seen[key] = struct{}{}
}
}
func TestAllocateRandomPeerIPv6(t *testing.T) {
prefix := netip.MustParsePrefix("fd12:3456:7890:abcd::/64")
ip, err := AllocateRandomPeerIPv6(prefix)
require.NoError(t, err)
assert.True(t, ip.Is6(), "should be IPv6")
assert.True(t, prefix.Contains(ip), "should be within subnet")
// First 8 bytes (network prefix) should match
b := ip.As16()
prefixBytes := prefix.Addr().As16()
assert.Equal(t, prefixBytes[:8], b[:8], "prefix should match")
// Interface ID should not be all zeros
allZero := true
for _, v := range b[8:] {
if v != 0 {
allZero = false
break
}
}
assert.False(t, allZero, "interface ID should not be all zeros")
}
func TestAllocateRandomPeerIPv6_VariousPrefixes(t *testing.T) {
tests := []struct {
name string
cidr string
prefix int
}{
{"standard /64", "fd00:1234:5678:abcd::/64", 64},
{"small /112", "fd00:1234:5678:abcd::/112", 112},
{"large /48", "fd00:1234::/48", 48},
{"non-boundary /60", "fd00:1234:5670::/60", 60},
{"non-boundary /52", "fd00:1230::/52", 52},
{"minimum /120", "fd00:1234:5678:abcd::100/120", 120},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
prefix, err := netip.ParsePrefix(tt.cidr)
require.NoError(t, err)
prefix = prefix.Masked()
assert.Equal(t, tt.prefix, prefix.Bits())
for i := 0; i < 50; i++ {
ip, err := AllocateRandomPeerIPv6(prefix)
require.NoError(t, err)
assert.True(t, prefix.Contains(ip), "IP %s should be within %s", ip, prefix)
}
})
}
}
func TestAllocateRandomPeerIPv6_PreservesNetworkBits(t *testing.T) {
// For a /112, bytes 0-13 should be preserved, only bytes 14-15 should vary
prefix := netip.MustParsePrefix("fd00:1234:5678:abcd:ef01:2345:6789:0/112")
prefixBytes := prefix.Addr().As16()
for i := 0; i < 20; i++ {
ip, err := AllocateRandomPeerIPv6(prefix)
require.NoError(t, err)
// First 14 bytes (112 bits = 14 bytes) must match the network
b := ip.As16()
assert.Equal(t, prefixBytes[:14], b[:14], "network bytes should be preserved for /112")
}
}
func TestAllocateRandomPeerIPv6_NonByteBoundary(t *testing.T) {
// For a /60, the first 7.5 bytes are network, so byte 7 is partial
prefix := netip.MustParsePrefix("fd00:1234:5678:abc0::/60")
prefixBytes := prefix.Addr().As16()
for i := 0; i < 50; i++ {
ip, err := AllocateRandomPeerIPv6(prefix)
require.NoError(t, err)
b := ip.As16()
assert.True(t, prefix.Contains(ip), "IP %s should be within %s", ip, prefix)
// First 7 bytes must match exactly
assert.Equal(t, prefixBytes[:7], b[:7], "full network bytes should match for /60")
// Byte 7: top 4 bits (0xc = 1100) must be preserved
assert.Equal(t, prefixBytes[7]&0xf0, b[7]&0xf0, "partial byte network bits should be preserved for /60")
}
}

View File

@@ -3,7 +3,6 @@ package types
import (
"context"
"maps"
"net"
"net/netip"
"slices"
"strconv"
@@ -114,13 +113,17 @@ func (c *NetworkMapComponents) Calculate(ctx context.Context) *NetworkMap {
peersToConnect, expiredPeers := c.filterPeersByLoginExpiration(aclPeers)
routesUpdate := c.getRoutesToSync(targetPeerID, peersToConnect, peerGroups)
routesFirewallRules := c.getPeerRoutesFirewallRules(ctx, targetPeerID)
includeIPv6 := false
if p := c.Peers[targetPeerID]; p != nil {
includeIPv6 = p.SupportsIPv6() && p.IPv6.IsValid()
}
routesUpdate := filterAndExpandRoutes(c.getRoutesToSync(targetPeerID, peersToConnect, peerGroups), includeIPv6)
routesFirewallRules := c.getPeerRoutesFirewallRules(ctx, targetPeerID, includeIPv6)
isRouter, networkResourcesRoutes, sourcePeers := c.getNetworkResourcesRoutesToSync(targetPeerID)
var networkResourcesFirewallRules []*RouteFirewallRule
if isRouter {
networkResourcesFirewallRules = c.getPeerNetworkResourceFirewallRules(ctx, targetPeerID, networkResourcesRoutes)
networkResourcesFirewallRules = c.getPeerNetworkResourceFirewallRules(ctx, targetPeerID, networkResourcesRoutes, includeIPv6)
}
peersToConnectIncludingRouters := c.addNetworksRoutingPeers(
@@ -156,7 +159,7 @@ func (c *NetworkMapComponents) Calculate(ctx context.Context) *NetworkMap {
return &NetworkMap{
Peers: peersToConnectIncludingRouters,
Network: c.Network.Copy(),
Routes: append(networkResourcesRoutes, routesUpdate...),
Routes: append(filterAndExpandRoutes(networkResourcesRoutes, includeIPv6), routesUpdate...),
DNSConfig: dnsUpdate,
OfflinePeers: expiredPeers,
FirewallRules: firewallRules,
@@ -296,7 +299,7 @@ func (c *NetworkMapComponents) connResourcesGenerator(targetPeer *nbpeer.Peer) (
peersExists[peer.ID] = struct{}{}
}
peerIP := net.IP(peer.IP).String()
peerIP := peer.IP.String()
fr := FirewallRule{
PolicyID: rule.ID,
@@ -315,10 +318,17 @@ func (c *NetworkMapComponents) connResourcesGenerator(targetPeer *nbpeer.Peer) (
if len(rule.Ports) == 0 && len(rule.PortRanges) == 0 {
rules = append(rules, &fr)
continue
} else {
rules = append(rules, expandPortsAndRanges(fr, rule, targetPeer)...)
}
rules = append(rules, expandPortsAndRanges(fr, rule, targetPeer)...)
rules = appendIPv6FirewallRule(rules, rulesExists, peer, targetPeer, rule, firewallRuleContext{
direction: direction,
dirStr: dirStr,
protocolStr: protocolStr,
actionStr: actionStr,
portsJoined: portsJoined,
})
}
}, func() ([]*nbpeer.Peer, []*FirewallRule) {
return peers, rules
@@ -454,6 +464,29 @@ func (c *NetworkMapComponents) peerIsNameserver(peerIPStr string, nsGroup *nbdns
return false
}
// filterAndExpandRoutes drops v6 routes for non-capable peers and duplicates
// the default v4 route (0.0.0.0/0) as ::/0 for v6-capable peers.
// TODO: the "-v6" suffix on IDs could collide with user-supplied route IDs.
func filterAndExpandRoutes(routes []*route.Route, includeIPv6 bool) []*route.Route {
filtered := make([]*route.Route, 0, len(routes))
for _, r := range routes {
if !includeIPv6 && r.Network.Addr().Is6() {
continue
}
filtered = append(filtered, r)
if includeIPv6 && r.Network.Bits() == 0 && r.Network.Addr().Is4() {
v6 := r.Copy()
v6.ID = r.ID + "-v6-default"
v6.NetID = r.NetID + "-v6"
v6.Network = netip.MustParsePrefix("::/0")
v6.NetworkType = route.IPv6Network
filtered = append(filtered, v6)
}
}
return filtered
}
func (c *NetworkMapComponents) getRoutesToSync(peerID string, aclPeers []*nbpeer.Peer, peerGroups LookupMap) []*route.Route {
routes, peerDisabledRoutes := c.getRoutingPeerRoutes(peerID)
peerRoutesMembership := make(LookupMap)
@@ -550,13 +583,13 @@ func (c *NetworkMapComponents) filterRoutesFromPeersOfSameHAGroup(routes []*rout
return filteredRoutes
}
func (c *NetworkMapComponents) getPeerRoutesFirewallRules(ctx context.Context, peerID string) []*RouteFirewallRule {
func (c *NetworkMapComponents) getPeerRoutesFirewallRules(ctx context.Context, peerID string, includeIPv6 bool) []*RouteFirewallRule {
routesFirewallRules := make([]*RouteFirewallRule, 0)
enabledRoutes, _ := c.getRoutingPeerRoutes(peerID)
for _, r := range enabledRoutes {
if len(r.AccessControlGroups) == 0 {
defaultPermit := c.getDefaultPermit(r)
defaultPermit := c.getDefaultPermit(r, includeIPv6)
routesFirewallRules = append(routesFirewallRules, defaultPermit...)
continue
}
@@ -565,7 +598,7 @@ func (c *NetworkMapComponents) getPeerRoutesFirewallRules(ctx context.Context, p
for _, accessGroup := range r.AccessControlGroups {
policies := c.getAllRoutePoliciesFromGroups([]string{accessGroup})
rules := c.getRouteFirewallRules(ctx, peerID, policies, r, distributionPeers)
rules := c.getRouteFirewallRules(ctx, peerID, policies, r, distributionPeers, includeIPv6)
routesFirewallRules = append(routesFirewallRules, rules...)
}
}
@@ -573,8 +606,10 @@ func (c *NetworkMapComponents) getPeerRoutesFirewallRules(ctx context.Context, p
return routesFirewallRules
}
func (c *NetworkMapComponents) getDefaultPermit(r *route.Route) []*RouteFirewallRule {
var rules []*RouteFirewallRule
func (c *NetworkMapComponents) getDefaultPermit(r *route.Route, includeIPv6 bool) []*RouteFirewallRule {
if r.Network.Addr().Is6() && !includeIPv6 {
return nil
}
sources := []string{"0.0.0.0/0"}
if r.Network.Addr().Is6() {
@@ -591,9 +626,9 @@ func (c *NetworkMapComponents) getDefaultPermit(r *route.Route) []*RouteFirewall
RouteID: r.ID,
}
rules = append(rules, &rule)
rules := []*RouteFirewallRule{&rule}
if r.IsDynamic() {
if includeIPv6 && r.IsDynamic() {
ruleV6 := rule
ruleV6.SourceRanges = []string{"::/0"}
rules = append(rules, &ruleV6)
@@ -632,7 +667,7 @@ func (c *NetworkMapComponents) getAllRoutePoliciesFromGroups(accessControlGroups
return routePolicies
}
func (c *NetworkMapComponents) getRouteFirewallRules(ctx context.Context, peerID string, policies []*Policy, route *route.Route, distributionPeers map[string]struct{}) []*RouteFirewallRule {
func (c *NetworkMapComponents) getRouteFirewallRules(ctx context.Context, peerID string, policies []*Policy, route *route.Route, distributionPeers map[string]struct{}, includeIPv6 bool) []*RouteFirewallRule {
var fwRules []*RouteFirewallRule
for _, policy := range policies {
if !policy.Enabled {
@@ -645,7 +680,7 @@ func (c *NetworkMapComponents) getRouteFirewallRules(ctx context.Context, peerID
}
rulePeers := c.getRulePeers(rule, policy.SourcePostureChecks, peerID, distributionPeers)
rules := generateRouteFirewallRules(ctx, route, rule, rulePeers, FirewallRuleDirectionIN)
rules := generateRouteFirewallRules(ctx, route, rule, rulePeers, FirewallRuleDirectionIN, includeIPv6)
fwRules = append(fwRules, rules...)
}
}
@@ -710,33 +745,49 @@ func (c *NetworkMapComponents) getNetworkResourcesRoutesToSync(peerID string) (b
}
}
addedResourceRoute := false
for _, policy := range c.ResourcePoliciesMap[resource.ID] {
var peers []string
if policy.Rules[0].SourceResource.Type == ResourceTypePeer && policy.Rules[0].SourceResource.ID != "" {
peers = []string{policy.Rules[0].SourceResource.ID}
} else {
peers = c.getUniquePeerIDsFromGroupsIDs(policy.SourceGroups())
}
if addSourcePeers {
for _, pID := range c.getPostureValidPeers(peers, policy.SourcePostureChecks) {
allSourcePeers[pID] = struct{}{}
}
} else if slices.Contains(peers, peerID) && c.ValidatePostureChecksOnPeer(peerID, policy.SourcePostureChecks) {
for peerId, router := range networkRoutingPeers {
routes = append(routes, c.getNetworkResourcesRoutes(resource, peerId, router)...)
}
addedResourceRoute = true
}
if addedResourceRoute {
break
}
}
newRoutes := c.processResourcePolicies(peerID, resource, networkRoutingPeers, addSourcePeers, allSourcePeers)
routes = append(routes, newRoutes...)
}
return isRoutingPeer, routes, allSourcePeers
}
func (c *NetworkMapComponents) processResourcePolicies(
peerID string,
resource *resourceTypes.NetworkResource,
networkRoutingPeers map[string]*routerTypes.NetworkRouter,
addSourcePeers bool,
allSourcePeers map[string]struct{},
) []*route.Route {
var routes []*route.Route
for _, policy := range c.ResourcePoliciesMap[resource.ID] {
peers := c.getResourcePolicyPeers(policy)
if addSourcePeers {
for _, pID := range c.getPostureValidPeers(peers, policy.SourcePostureChecks) {
allSourcePeers[pID] = struct{}{}
}
continue
}
if slices.Contains(peers, peerID) && c.ValidatePostureChecksOnPeer(peerID, policy.SourcePostureChecks) {
for peerId, router := range networkRoutingPeers {
routes = append(routes, c.getNetworkResourcesRoutes(resource, peerId, router)...)
}
break
}
}
return routes
}
func (c *NetworkMapComponents) getResourcePolicyPeers(policy *Policy) []string {
if policy.Rules[0].SourceResource.Type == ResourceTypePeer && policy.Rules[0].SourceResource.ID != "" {
return []string{policy.Rules[0].SourceResource.ID}
}
return c.getUniquePeerIDsFromGroupsIDs(policy.SourceGroups())
}
func (c *NetworkMapComponents) getNetworkResourcesRoutes(resource *resourceTypes.NetworkResource, peerID string, router *routerTypes.NetworkRouter) []*route.Route {
resourceAppliedPolicies := c.ResourcePoliciesMap[resource.ID]
@@ -796,7 +847,7 @@ func (c *NetworkMapComponents) getPostureValidPeers(inputPeers []string, posture
return dest
}
func (c *NetworkMapComponents) getPeerNetworkResourceFirewallRules(ctx context.Context, peerID string, routes []*route.Route) []*RouteFirewallRule {
func (c *NetworkMapComponents) getPeerNetworkResourceFirewallRules(ctx context.Context, peerID string, routes []*route.Route, includeIPv6 bool) []*RouteFirewallRule {
routesFirewallRules := make([]*RouteFirewallRule, 0)
peerInfo := c.GetPeerInfo(peerID)
@@ -813,7 +864,7 @@ func (c *NetworkMapComponents) getPeerNetworkResourceFirewallRules(ctx context.C
resourcePolicies := c.ResourcePoliciesMap[resourceID]
distributionPeers := c.getPoliciesSourcePeers(resourcePolicies)
rules := c.getRouteFirewallRules(ctx, peerID, resourcePolicies, r, distributionPeers)
rules := c.getRouteFirewallRules(ctx, peerID, resourcePolicies, r, distributionPeers, includeIPv6)
for _, rule := range rules {
if len(rule.SourceRanges) > 0 {
routesFirewallRules = append(routesFirewallRules, rule)
@@ -897,3 +948,36 @@ func (c *NetworkMapComponents) addNetworksRoutingPeers(
return peersToConnect
}
type firewallRuleContext struct {
direction int
dirStr string
protocolStr string
actionStr string
portsJoined string
}
func appendIPv6FirewallRule(rules []*FirewallRule, rulesExists map[string]struct{}, peer, targetPeer *nbpeer.Peer, rule *PolicyRule, rc firewallRuleContext) []*FirewallRule {
if !peer.IPv6.IsValid() || !targetPeer.SupportsIPv6() || !targetPeer.IPv6.IsValid() {
return rules
}
v6IP := peer.IPv6.String()
v6RuleID := rule.ID + v6IP + rc.dirStr + rc.protocolStr + rc.actionStr + rc.portsJoined
if _, ok := rulesExists[v6RuleID]; ok {
return rules
}
rulesExists[v6RuleID] = struct{}{}
v6fr := FirewallRule{
PolicyID: rule.ID,
PeerIP: v6IP,
Direction: rc.direction,
Action: rc.actionStr,
Protocol: rc.protocolStr,
}
if len(rule.Ports) == 0 && len(rule.PortRanges) == 0 {
return append(rules, &v6fr)
}
return append(rules, expandPortsAndRanges(v6fr, rule, targetPeer)...)
}

View File

@@ -42,7 +42,7 @@ func buildScalableTestAccount(numPeers, numGroups int, withDefaultPolicy bool) (
for i := range numPeers {
peerID := fmt.Sprintf("peer-%d", i)
ip := net.IP{100, byte(64 + i/65536), byte((i / 256) % 256), byte(i % 256)}
ip := netip.AddrFrom4([4]byte{100, byte(64 + i/65536), byte((i / 256) % 256), byte(i % 256)})
wtVersion := "0.25.0"
if i%2 == 0 {
wtVersion = "0.40.0"
@@ -1083,7 +1083,7 @@ func TestComponents_PeerIsNameserverExcludedFromNSGroup(t *testing.T) {
nsIP := account.Peers["peer-0"].IP
account.NameServerGroups["ns-self"] = &nbdns.NameServerGroup{
ID: "ns-self", Name: "Self NS", Enabled: true, Groups: []string{"group-all"},
NameServers: []nbdns.NameServer{{IP: netip.AddrFrom4([4]byte{nsIP[0], nsIP[1], nsIP[2], nsIP[3]}), NSType: nbdns.UDPNameServerType, Port: 53}},
NameServers: []nbdns.NameServer{{IP: nsIP, NSType: nbdns.UDPNameServerType, Port: 53}},
}
nm := componentsNetworkMap(account, "peer-0", validatedPeers)

View File

@@ -681,22 +681,22 @@ func TestNetworkMapComponents_RouterExcludesOtherNetworkRoutes(t *testing.T) {
func createComponentTestAccount() *types.Account {
peers := map[string]*nbpeer.Peer{
"peer-src-1": {
ID: "peer-src-1", IP: net.IP{100, 64, 0, 1}, Key: "key-src-1", DNSLabel: "src1",
ID: "peer-src-1", IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), Key: "key-src-1", DNSLabel: "src1",
Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now()}, UserID: "user-1",
Meta: nbpeer.PeerSystemMeta{WtVersion: "0.35.0", GoOS: "linux"},
},
"peer-src-2": {
ID: "peer-src-2", IP: net.IP{100, 64, 0, 2}, Key: "key-src-2", DNSLabel: "src2",
ID: "peer-src-2", IP: netip.AddrFrom4([4]byte{100, 64, 0, 2}), Key: "key-src-2", DNSLabel: "src2",
Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now()}, UserID: "user-1",
Meta: nbpeer.PeerSystemMeta{WtVersion: "0.35.0", GoOS: "linux"},
},
"peer-dst-1": {
ID: "peer-dst-1", IP: net.IP{100, 64, 0, 3}, Key: "key-dst-1", DNSLabel: "dst1",
ID: "peer-dst-1", IP: netip.AddrFrom4([4]byte{100, 64, 0, 3}), Key: "key-dst-1", DNSLabel: "dst1",
Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now()}, UserID: "user-2",
Meta: nbpeer.PeerSystemMeta{WtVersion: "0.35.0", GoOS: "linux"},
},
"peer-router-1": {
ID: "peer-router-1", IP: net.IP{100, 64, 0, 10}, Key: "key-router-1", DNSLabel: "router1",
ID: "peer-router-1", IP: netip.AddrFrom4([4]byte{100, 64, 0, 10}), Key: "key-router-1", DNSLabel: "router1",
Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now()}, UserID: "user-1",
Meta: nbpeer.PeerSystemMeta{WtVersion: "0.35.0", GoOS: "linux"},
},

View File

@@ -46,6 +46,8 @@ type Settings struct {
// NetworkRange is the custom network range for that account
NetworkRange netip.Prefix `gorm:"serializer:json"`
// NetworkRangeV6 is the custom IPv6 network range for that account
NetworkRangeV6 netip.Prefix `gorm:"serializer:json"`
// PeerExposeEnabled enables or disables peer-initiated service expose
PeerExposeEnabled bool
@@ -65,6 +67,12 @@ type Settings struct {
// when false, updates require user interaction from the UI
AutoUpdateAlways bool `gorm:"default:false"`
// IPv6EnabledGroups is the list of group IDs whose peers receive IPv6 overlay addresses.
// Peers not in any of these groups will not be allocated an IPv6 address.
// Empty list means IPv6 is disabled for the account.
// For new accounts this defaults to the All group.
IPv6EnabledGroups []string `gorm:"serializer:json"`
// EmbeddedIdpEnabled indicates if the embedded identity provider is enabled.
// This is a runtime-only field, not stored in the database.
EmbeddedIdpEnabled bool `gorm:"-"`
@@ -98,8 +106,10 @@ func (s *Settings) Copy() *Settings {
LazyConnectionEnabled: s.LazyConnectionEnabled,
DNSDomain: s.DNSDomain,
NetworkRange: s.NetworkRange,
NetworkRangeV6: s.NetworkRangeV6,
AutoUpdateVersion: s.AutoUpdateVersion,
AutoUpdateAlways: s.AutoUpdateAlways,
IPv6EnabledGroups: slices.Clone(s.IPv6EnabledGroups),
EmbeddedIdpEnabled: s.EmbeddedIdpEnabled,
LocalAuthDisabled: s.LocalAuthDisabled,
LocalMfaEnabled: s.LocalMfaEnabled,

View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"slices"
"strings"
"time"
"unicode"
@@ -825,6 +826,11 @@ func (am *DefaultAccountManager) processUserUpdate(ctx context.Context, transact
}
}
}
allGroupChanges := slices.Concat(removedGroups, addedGroups)
if err := am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, allGroupChanges); err != nil {
return false, nil, nil, nil, fmt.Errorf("reconcile IPv6 for group changes: %w", err)
}
}
updateAccountPeers := len(userPeers) > 0