From 31ed241a1a3a42ff8cc7ba4815f9b1c445e3de6b Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:37:09 +0200 Subject: [PATCH 1/9] [management] component types (#6866) --- .../shared/grpc/components_encoder.go | 25 ++--- .../shared/grpc/components_encoder_test.go | 100 ++++++++--------- .../grpc/components_envelope_response_test.go | 10 +- management/server/groups/manager.go | 9 +- .../http/handlers/peers/peers_handler.go | 21 ++-- .../networks/resources/types/resource.go | 22 ++++ .../server/networks/routers/types/router.go | 31 ++++++ management/server/peer.go | 4 +- management/server/peer/peer.go | 30 +++++ management/server/peer_test.go | 4 +- management/server/posture/nb_version.go | 30 +---- management/server/posture/nb_version_test.go | 65 ----------- management/server/types/account.go | 15 +-- management/server/types/account_components.go | 60 +++++----- .../types/account_private_netmap_test.go | 3 +- management/server/types/account_test.go | 2 +- management/server/types/aliases.go | 29 ++--- .../server}/types/group.go | 36 ++++-- management/server/types/ipv6_endtoend_test.go | 3 +- .../types/networkmap_components_test.go | 2 +- management/server/util/util.go | 31 ------ shared/management/networkmap/decode.go | 60 ++++------ shared/management/networkmap/encode.go | 7 +- shared/management/networkmap/envelope.go | 4 +- shared/management/networkmap/envelope_test.go | 47 ++++---- shared/management/types/component_types.go | 103 ++++++++++++++++++ shared/management/types/firewall_helpers.go | 14 +-- shared/management/types/firewall_rule.go | 5 +- shared/management/types/firewall_rule_test.go | 13 +-- shared/management/types/network.go | 49 +++++++-- .../management/types/network_merge_test.go | 8 +- .../management/types/networkmap_components.go | 79 +++++++------- .../types/networkmap_components_compact.go | 19 ++-- version/version.go | 24 ++++ version/version_test.go | 71 +++++++++++- 35 files changed, 609 insertions(+), 426 deletions(-) rename {shared/management => management/server}/types/group.go (83%) create mode 100644 shared/management/types/component_types.go rename management/server/util/util_test.go => shared/management/types/network_merge_test.go (88%) diff --git a/management/internals/shared/grpc/components_encoder.go b/management/internals/shared/grpc/components_encoder.go index d7b787464..7e43cf478 100644 --- a/management/internals/shared/grpc/components_encoder.go +++ b/management/internals/shared/grpc/components_encoder.go @@ -5,9 +5,6 @@ import ( "strconv" nbdns "github.com/netbirdio/netbird/dns" - resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" - routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" - nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/types" nbroute "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/networkmap" @@ -166,7 +163,7 @@ func (e *componentEncoder) indexAllPeers() { } } -func (e *componentEncoder) appendPeer(p *nbpeer.Peer) uint32 { +func (e *componentEncoder) appendPeer(p *types.ComponentPeer) uint32 { if idx, ok := e.peerOrder[p.ID]; ok { return idx } @@ -180,7 +177,7 @@ func (e *componentEncoder) appendPeer(p *nbpeer.Peer) uint32 { // (c.RouterPeers may contain peers not in c.Peers when validation rules drop // them) and returns their wire indexes for the RouterPeerIndexes field. Must // run before any encoder that resolves peer ids via e.peerOrder. -func (e *componentEncoder) indexRouterPeers(routers map[string]*nbpeer.Peer) []uint32 { +func (e *componentEncoder) indexRouterPeers(routers map[string]*types.ComponentPeer) []uint32 { if len(routers) == 0 { return nil } @@ -514,7 +511,7 @@ func encodeCustomZones(zones []nbdns.CustomZone) []*proto.CustomZone { return out } -func (e *componentEncoder) encodeNetworkResources(resources []*resourceTypes.NetworkResource) []*proto.NetworkResourceRaw { +func (e *componentEncoder) encodeNetworkResources(resources []*types.ComponentResource) []*proto.NetworkResourceRaw { if len(resources) == 0 { return nil } @@ -543,7 +540,7 @@ func (e *componentEncoder) encodeNetworkResources(resources []*resourceTypes.Net return out } -func (e *componentEncoder) encodeRoutersMap(routersMap map[string]map[string]*routerTypes.NetworkRouter) map[string]*proto.NetworkRouterList { +func (e *componentEncoder) encodeRoutersMap(routersMap map[string]map[string]*types.ComponentRouter) map[string]*proto.NetworkRouterList { if len(routersMap) == 0 { return nil } @@ -692,20 +689,20 @@ func toAccountNetwork(n *types.Network) *proto.AccountNetwork { return out } -func toPeerCompact(p *nbpeer.Peer) *proto.PeerCompact { +func toPeerCompact(p *types.ComponentPeer) *proto.PeerCompact { pc := &proto.PeerCompact{ WgPubKey: decodeWgKey(p.Key), SshPubKey: []byte(p.SSHKey), DnsLabel: p.DNSLabel, - AgentVersion: p.Meta.WtVersion, - AddedWithSsoLogin: p.UserID != "", + AgentVersion: p.AgentVersion, + AddedWithSsoLogin: p.AddedWithSSOLogin, LoginExpirationEnabled: p.LoginExpirationEnabled, SshEnabled: p.SSHEnabled, - SupportsIpv6: p.SupportsIPv6(), - SupportsSourcePrefixes: p.SupportsSourcePrefixes(), - ServerSshAllowed: p.Meta.Flags.ServerSSHAllowed, + SupportsIpv6: p.SupportsIPv6, + SupportsSourcePrefixes: p.SupportsSourcePrefixes, + ServerSshAllowed: p.ServerSSHAllowed, } - if p.LastLogin != nil { + if !p.LastLogin.IsZero() { pc.LastLoginUnixNano = p.LastLogin.UnixNano() } switch { diff --git a/management/internals/shared/grpc/components_encoder_test.go b/management/internals/shared/grpc/components_encoder_test.go index d82bba362..100ab0948 100644 --- a/management/internals/shared/grpc/components_encoder_test.go +++ b/management/internals/shared/grpc/components_encoder_test.go @@ -15,9 +15,6 @@ import ( goproto "google.golang.org/protobuf/proto" nbdns "github.com/netbirdio/netbird/dns" - resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" - routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" - nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/types" nbroute "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/proto" @@ -155,29 +152,28 @@ func envelopesEquivalent(a, b *proto.NetworkMapEnvelope) bool { } func newTestComponents() *types.NetworkMapComponents { - peerA := &nbpeer.Peer{ - ID: "peer-a", - Key: testWgKeyA, - IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), - DNSLabel: "peera", - SSHKey: "ssh-a", - Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now()}, - Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + peerA := &types.ComponentPeer{ + ID: "peer-a", + Key: testWgKeyA, + IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), + DNSLabel: "peera", + SSHKey: "ssh-a", + AgentVersion: "0.40.0", } - peerB := &nbpeer.Peer{ - ID: "peer-b", - Key: testWgKeyB, - IP: netip.AddrFrom4([4]byte{100, 64, 0, 2}), - IPv6: netip.AddrFrom16([16]byte{0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2}), - DNSLabel: "peerb", - Meta: nbpeer.PeerSystemMeta{WtVersion: "0.25.0"}, + peerB := &types.ComponentPeer{ + ID: "peer-b", + Key: testWgKeyB, + IP: netip.AddrFrom4([4]byte{100, 64, 0, 2}), + IPv6: netip.AddrFrom16([16]byte{0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2}), + DNSLabel: "peerb", + AgentVersion: "0.25.0", } - peerC := &nbpeer.Peer{ - ID: "peer-c", - Key: testWgKeyC, - IP: netip.AddrFrom4([4]byte{100, 64, 0, 3}), - DNSLabel: "peerc", - Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + peerC := &types.ComponentPeer{ + ID: "peer-c", + Key: testWgKeyC, + IP: netip.AddrFrom4([4]byte{100, 64, 0, 3}), + DNSLabel: "peerc", + AgentVersion: "0.40.0", } return &types.NetworkMapComponents{ @@ -191,12 +187,12 @@ func newTestComponents() *types.NetworkMapComponents { PeerLoginExpirationEnabled: true, PeerLoginExpiration: 2 * time.Hour, }, - Peers: map[string]*nbpeer.Peer{ + Peers: map[string]*types.ComponentPeer{ "peer-a": peerA, "peer-b": peerB, "peer-c": peerC, }, - Groups: map[string]*types.Group{ + Groups: map[string]*types.ComponentGroup{ "group-src": {ID: "group-src", PublicID: "1", Name: "Src", Peers: []string{"peer-a"}}, "group-dst": {ID: "group-dst", PublicID: "2", Name: "Dst", Peers: []string{"peer-b", "peer-c"}}, }, @@ -215,7 +211,7 @@ func newTestComponents() *types.NetworkMapComponents { }}, }, }, - RouterPeers: map[string]*nbpeer.Peer{"peer-c": peerC}, + RouterPeers: map[string]*types.ComponentPeer{"peer-c": peerC}, } } @@ -381,12 +377,12 @@ func TestEncodeNetworkMapEnvelope_MalformedWgKey(t *testing.T) { func TestEncodeNetworkMapEnvelope_IPv6OnlyPeer(t *testing.T) { c := newTestComponents() - v6Only := &nbpeer.Peer{ - ID: "peer-v6", - Key: testWgKeyA, - IPv6: netip.AddrFrom16([16]byte{0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9}), - DNSLabel: "peerv6", - Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + v6Only := &types.ComponentPeer{ + ID: "peer-v6", + Key: testWgKeyA, + IPv6: netip.AddrFrom16([16]byte{0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9}), + DNSLabel: "peerv6", + AgentVersion: "0.40.0", } c.Peers["peer-v6"] = v6Only @@ -405,11 +401,11 @@ func TestEncodeNetworkMapEnvelope_IPv6OnlyPeer(t *testing.T) { func TestEncodeNetworkMapEnvelope_PeerWithoutIP(t *testing.T) { c := newTestComponents() - c.Peers["peer-noip"] = &nbpeer.Peer{ - ID: "peer-noip", - Key: testWgKeyA, - DNSLabel: "peernoip", - Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + c.Peers["peer-noip"] = &types.ComponentPeer{ + ID: "peer-noip", + Key: testWgKeyA, + DNSLabel: "peernoip", + AgentVersion: "0.40.0", } full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() @@ -444,9 +440,9 @@ func TestEncodeNetworkMapEnvelope_EmptyInput(t *testing.T) { func TestEncodeNetworkMapEnvelope_PeerLoginExpirationFields(t *testing.T) { c := newTestComponents() now := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC) - c.Peers["peer-a"].UserID = "user-1" + c.Peers["peer-a"].AddedWithSSOLogin = true c.Peers["peer-a"].LoginExpirationEnabled = true - c.Peers["peer-a"].LastLogin = &now + c.Peers["peer-a"].LastLogin = now full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() @@ -557,7 +553,7 @@ func TestEncodeNetworkMapEnvelope_ResourceOnlyPolicyShippedAndIndexed(t *testing } // Resource must appear in components.NetworkResources with a seq id — // encoder uses that to translate the xid map key to uint32. - c.NetworkResources = []*resourceTypes.NetworkResource{ + c.NetworkResources = []*types.ComponentResource{ {ID: "resource-x", PublicID: "77", Name: "res-x", Enabled: true}, } @@ -625,11 +621,11 @@ func TestEncodeNetworkMapEnvelope_PostureFailedPeers(t *testing.T) { func TestEncodeNetworkMapEnvelope_RoutersMap(t *testing.T) { c := newTestComponents() c.NetworkXIDToPublicID = map[string]string{"net-1": "5"} - c.RoutersMap = map[string]map[string]*routerTypes.NetworkRouter{ + c.RoutersMap = map[string]map[string]*types.ComponentRouter{ "net-1": { "peer-c": { - ID: "router-1", PublicID: "200", - Peer: "peer-c", Masquerade: true, Metric: 10, Enabled: true, + PublicID: "200", + Peer: "peer-c", Masquerade: true, Metric: 10, Enabled: true, }, }, } @@ -655,14 +651,14 @@ func TestEncodeNetworkMapEnvelope_RouterPeerNotInComponentsPeers(t *testing.T) { // peer_index reference must still resolve. c := newTestComponents() delete(c.Peers, "peer-c") - routerPeer := &nbpeer.Peer{ + routerPeer := &types.ComponentPeer{ ID: "peer-c", Key: testWgKeyC, IP: netip.AddrFrom4([4]byte{100, 64, 0, 3}), - DNSLabel: "peerc", Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + DNSLabel: "peerc", AgentVersion: "0.40.0", } - c.RouterPeers = map[string]*nbpeer.Peer{"peer-c": routerPeer} + c.RouterPeers = map[string]*types.ComponentPeer{"peer-c": routerPeer} c.NetworkXIDToPublicID = map[string]string{"net-1": "5"} - c.RoutersMap = map[string]map[string]*routerTypes.NetworkRouter{ - "net-1": {"peer-c": {ID: "r-1", PublicID: "1", Peer: "peer-c", Enabled: true}}, + c.RoutersMap = map[string]map[string]*types.ComponentRouter{ + "net-1": {"peer-c": {PublicID: "1", Peer: "peer-c", Enabled: true}}, } full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() @@ -695,9 +691,9 @@ func TestToProxyPatch_EmptyInputReturnsNil(t *testing.T) { func TestToProxyPatch_PopulatesAllFields(t *testing.T) { nm := &types.NetworkMap{ - Peers: []*nbpeer.Peer{{ + Peers: []*types.ComponentPeer{{ ID: "ext-peer", Key: testWgKeyA, IP: netip.AddrFrom4([4]byte{100, 64, 0, 9}), - DNSLabel: "extpeer", Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + DNSLabel: "extpeer", AgentVersion: "0.40.0", }}, FirewallRules: []*types.FirewallRule{{ PeerIP: "100.64.0.9", Action: "accept", Direction: 0, Protocol: "tcp", @@ -780,6 +776,6 @@ func TestEncodeNetworkMapEnvelope_AccountSettingsAlwaysEmitted(t *testing.T) { func emptyNetworkMapComponents() *types.NetworkMapComponents { return types.EmptyNetworkMapComponents( &types.NetworkMapComponents{ - PeerID: "peer-id", Peers: map[string]*nbpeer.Peer{"peer-id": {}}}, + PeerID: "peer-id", Peers: map[string]*types.ComponentPeer{"peer-id": {}}}, ) } diff --git a/management/internals/shared/grpc/components_envelope_response_test.go b/management/internals/shared/grpc/components_envelope_response_test.go index bf35bb7b9..20f4e6824 100644 --- a/management/internals/shared/grpc/components_envelope_response_test.go +++ b/management/internals/shared/grpc/components_envelope_response_test.go @@ -19,10 +19,10 @@ func TestComputeSSHEnabledForPeer(t *testing.T) { mkComponents := func(rule *types.PolicyRule, sshEnabled bool) (*types.NetworkMapComponents, *nbpeer.Peer) { peer := &nbpeer.Peer{ID: targetPeerID, SSHEnabled: sshEnabled} - group := &types.Group{ID: targetGroupID, Name: "dst", Peers: []string{targetPeerID}} + group := &types.ComponentGroup{ID: targetGroupID, Name: "dst", Peers: []string{targetPeerID}} return &types.NetworkMapComponents{ - Peers: map[string]*nbpeer.Peer{targetPeerID: peer}, - Groups: map[string]*types.Group{targetGroupID: group}, + Peers: map[string]*types.ComponentPeer{targetPeerID: peer.ToComponent()}, + Groups: map[string]*types.ComponentGroup{targetGroupID: group}, Policies: []*types.Policy{{ ID: "p", Enabled: true, @@ -158,8 +158,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) { func TestComputeSSHEnabledForPeer_TargetMissingFromComponents(t *testing.T) { peer := &nbpeer.Peer{ID: "missing", SSHEnabled: true} c := &types.NetworkMapComponents{ - Peers: map[string]*nbpeer.Peer{}, // target peer NOT present - Groups: map[string]*types.Group{ + Peers: map[string]*types.ComponentPeer{}, // target peer NOT present + Groups: map[string]*types.ComponentGroup{ "g": {ID: "g", Peers: []string{"missing"}}, }, Policies: []*types.Policy{{ diff --git a/management/server/groups/manager.go b/management/server/groups/manager.go index c9a877d6f..6d19b1c35 100644 --- a/management/server/groups/manager.go +++ b/management/server/groups/manager.go @@ -6,6 +6,7 @@ import ( "github.com/netbirdio/netbird/management/server/account" "github.com/netbirdio/netbird/management/server/activity" + resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/permissions/modules" "github.com/netbirdio/netbird/management/server/permissions/operations" @@ -30,6 +31,10 @@ type managerImpl struct { accountManager account.Manager } +func eventMetaResource(group *types.Group, resource *resourceTypes.NetworkResource) map[string]any { + return map[string]any{"name": group.Name, "id": group.ID, "resource_name": resource.Name, "resource_id": resource.ID, "resource_type": resource.Type} +} + type mockManager struct { } @@ -109,7 +114,7 @@ func (m *managerImpl) AddResourceToGroupInTransaction(ctx context.Context, trans } event := func() { - m.accountManager.StoreEvent(ctx, userID, groupID, accountID, activity.ResourceAddedToGroup, group.EventMetaResource(networkResource)) + m.accountManager.StoreEvent(ctx, userID, groupID, accountID, activity.ResourceAddedToGroup, eventMetaResource(group, networkResource)) } return event, nil @@ -133,7 +138,7 @@ func (m *managerImpl) RemoveResourceFromGroupInTransaction(ctx context.Context, } event := func() { - m.accountManager.StoreEvent(ctx, userID, groupID, accountID, activity.ResourceRemovedFromGroup, group.EventMetaResource(networkResource)) + m.accountManager.StoreEvent(ctx, userID, groupID, accountID, activity.ResourceRemovedFromGroup, eventMetaResource(group, networkResource)) } return event, nil diff --git a/management/server/http/handlers/peers/peers_handler.go b/management/server/http/handlers/peers/peers_handler.go index 310f90653..03a37c3ec 100644 --- a/management/server/http/handlers/peers/peers_handler.go +++ b/management/server/http/handlers/peers/peers_handler.go @@ -446,7 +446,7 @@ func (h *Handler) GetAccessiblePeers(w http.ResponseWriter, r *http.Request) { netMap := account.GetPeerNetworkMapFromComponents(ctx, peerID, dns.CustomZone{}, nil, validPeers, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap(), nil, account.GetActiveGroupUsers()) - util.WriteJSONObject(ctx, w, toAccessiblePeers(netMap, dnsDomain)) + util.WriteJSONObject(ctx, w, toAccessiblePeers(netMap, account.Peers, dnsDomain)) } func (h *Handler) CreateTemporaryAccess(w http.ResponseWriter, r *http.Request) { @@ -534,15 +534,20 @@ func (h *Handler) CreateTemporaryAccess(w http.ResponseWriter, r *http.Request) util.WriteJSONObject(r.Context(), w, resp) } -func toAccessiblePeers(netMap *types.NetworkMap, dnsDomain string) []api.AccessiblePeer { +// toAccessiblePeers rehydrates the calculated map's component peers into the +// account's full peer objects, which carry the location/status/meta fields +// the API response needs. +func toAccessiblePeers(netMap *types.NetworkMap, accountPeers map[string]*nbpeer.Peer, dnsDomain string) []api.AccessiblePeer { accessiblePeers := make([]api.AccessiblePeer, 0, len(netMap.Peers)+len(netMap.OfflinePeers)) - for _, p := range netMap.Peers { - accessiblePeers = append(accessiblePeers, peerToAccessiblePeer(p, dnsDomain)) - } - - for _, p := range netMap.OfflinePeers { - accessiblePeers = append(accessiblePeers, peerToAccessiblePeer(p, dnsDomain)) + add := func(peers []*types.ComponentPeer) { + for _, p := range peers { + if peer := accountPeers[p.ID]; peer != nil { + accessiblePeers = append(accessiblePeers, peerToAccessiblePeer(peer, dnsDomain)) + } + } } + add(netMap.Peers) + add(netMap.OfflinePeers) return accessiblePeers } diff --git a/management/server/networks/resources/types/resource.go b/management/server/networks/resources/types/resource.go index 4cf7f7ea3..643f9cdd6 100644 --- a/management/server/networks/resources/types/resource.go +++ b/management/server/networks/resources/types/resource.go @@ -14,6 +14,7 @@ import ( nbDomain "github.com/netbirdio/netbird/shared/management/domain" "github.com/netbirdio/netbird/shared/management/http/api" + sharedTypes "github.com/netbirdio/netbird/shared/management/types" ) type NetworkResourceType string @@ -64,6 +65,27 @@ func NewNetworkResource(accountID, networkID, name, description, address string, }, nil } +// ToComponent converts the resource to its self-contained components +// representation. Returns nil for a nil resource. +func (n *NetworkResource) ToComponent() *sharedTypes.ComponentResource { + if n == nil { + return nil + } + return &sharedTypes.ComponentResource{ + ID: n.ID, + PublicID: n.PublicID, + NetworkID: n.NetworkID, + AccountID: n.AccountID, + Name: n.Name, + Description: n.Description, + Type: sharedTypes.ComponentResourceType(n.Type), + Address: n.Address, + Domain: n.Domain, + Prefix: n.Prefix, + Enabled: n.Enabled, + } +} + func (n *NetworkResource) ToAPIResponse(groups []api.GroupMinimum) *api.NetworkResource { addr := n.Prefix.String() if n.Type == Domain { diff --git a/management/server/networks/routers/types/router.go b/management/server/networks/routers/types/router.go index 189d7f792..b8097cdbb 100644 --- a/management/server/networks/routers/types/router.go +++ b/management/server/networks/routers/types/router.go @@ -7,6 +7,7 @@ import ( "github.com/netbirdio/netbird/management/server/networks/types" "github.com/netbirdio/netbird/shared/management/http/api" + sharedTypes "github.com/netbirdio/netbird/shared/management/types" ) type NetworkRouter struct { @@ -21,6 +22,36 @@ type NetworkRouter struct { Enabled bool } +// ToComponent converts the router to its self-contained components +// representation. Returns nil for a nil router. +func (n *NetworkRouter) ToComponent() *sharedTypes.ComponentRouter { + if n == nil { + return nil + } + return &sharedTypes.ComponentRouter{ + NetworkID: n.NetworkID, + PublicID: n.PublicID, + Peer: n.Peer, + PeerGroups: n.PeerGroups, + Masquerade: n.Masquerade, + Metric: n.Metric, + Enabled: n.Enabled, + } +} + +// ToComponentMap converts a peer-keyed router map to its components +// representation. +func ToComponentMap(routers map[string]*NetworkRouter) map[string]*sharedTypes.ComponentRouter { + if routers == nil { + return nil + } + out := make(map[string]*sharedTypes.ComponentRouter, len(routers)) + for id, r := range routers { + out[id] = r.ToComponent() + } + return out +} + func NewNetworkRouter(accountID string, networkID string, peer string, peerGroups []string, masquerade bool, metric int, enabled bool) (*NetworkRouter, error) { r := &NetworkRouter{ ID: xid.New().String(), diff --git a/management/server/peer.go b/management/server/peer.go index 5f2f5d2a2..589cf9abf 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -405,7 +405,7 @@ func (am *DefaultAccountManager) CreatePeerJob(ctx context.Context, accountID, p return status.NewPeerNotPartOfAccountError() } - meetMinVer, err := posture.MeetsMinVersion(remoteJobsMinVer, p.Meta.WtVersion) + meetMinVer, err := version.MeetsMinVersion(remoteJobsMinVer, p.Meta.WtVersion) if !version.IsDevelopmentVersion(p.Meta.WtVersion) && (!meetMinVer || err != nil) { return status.Errorf(status.PreconditionFailed, "peer version %s does not meet the minimum required version %s for remote jobs", p.Meta.WtVersion, remoteJobsMinVer) } @@ -1588,7 +1588,7 @@ func affectedPeerIDsFromNetworkMap(nmap *types.NetworkMap, selfPeerID string) [] } seen := make(map[string]struct{}, len(nmap.Peers)+len(nmap.OfflinePeers)) ids := make([]string, 0, len(nmap.Peers)+len(nmap.OfflinePeers)) - add := func(peers []*nbpeer.Peer) { + add := func(peers []*types.ComponentPeer) { for _, p := range peers { if p == nil || p.ID == "" || p.ID == selfPeerID { continue diff --git a/management/server/peer/peer.go b/management/server/peer/peer.go index 39022d095..7c4971285 100644 --- a/management/server/peer/peer.go +++ b/management/server/peer/peer.go @@ -13,6 +13,7 @@ import ( "github.com/netbirdio/netbird/management/server/util" "github.com/netbirdio/netbird/shared/management/http/api" + sharedTypes "github.com/netbirdio/netbird/shared/management/types" ) // Peer capability constants mirror the proto enum values. @@ -205,6 +206,35 @@ func (p *Peer) AddedWithSSOLogin() bool { return p.UserID != "" } +// ToComponent converts the peer to its self-contained components +// representation, carrying exactly the subset of peer data that crosses the +// components wire format. Returns nil for a nil peer so callers can convert +// possibly-missing peers without guarding. +func (p *Peer) ToComponent() *sharedTypes.ComponentPeer { + if p == nil { + return nil + } + cp := &sharedTypes.ComponentPeer{ + ID: p.ID, + Key: p.Key, + IP: p.IP, + IPv6: p.IPv6, + DNSLabel: p.DNSLabel, + SSHKey: p.SSHKey, + SSHEnabled: p.SSHEnabled, + ServerSSHAllowed: p.Meta.Flags.ServerSSHAllowed, + AgentVersion: p.Meta.WtVersion, + SupportsSourcePrefixes: p.SupportsSourcePrefixes(), + SupportsIPv6: p.SupportsIPv6(), + LoginExpirationEnabled: p.LoginExpirationEnabled, + AddedWithSSOLogin: p.AddedWithSSOLogin(), + } + if p.LastLogin != nil { + cp.LastLogin = *p.LastLogin + } + return cp +} + // HasCapability reports whether the peer has the given capability. func (p *Peer) HasCapability(capability int32) bool { return slices.Contains(p.Meta.Capabilities, capability) diff --git a/management/server/peer_test.go b/management/server/peer_test.go index d471a1302..a7f8ba695 100644 --- a/management/server/peer_test.go +++ b/management/server/peer_test.go @@ -1092,14 +1092,14 @@ func TestToSyncResponse(t *testing.T) { } networkMap := &types.NetworkMap{ Network: &types.Network{Net: *ipnet, Serial: 1000}, - Peers: []*nbpeer.Peer{{ + Peers: []*types.ComponentPeer{{ 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{{ + OfflinePeers: []*types.ComponentPeer{{ IP: netip.MustParseAddr("192.168.1.3"), IPv6: netip.MustParseAddr("fd00::3"), Key: "peer3-key", diff --git a/management/server/posture/nb_version.go b/management/server/posture/nb_version.go index 6e4757021..3cace3b5f 100644 --- a/management/server/posture/nb_version.go +++ b/management/server/posture/nb_version.go @@ -3,11 +3,9 @@ package posture import ( "context" "fmt" - "strings" - - "github.com/hashicorp/go-version" nbpeer "github.com/netbirdio/netbird/management/server/peer" + nbversion "github.com/netbirdio/netbird/version" ) type NBVersionCheck struct { @@ -16,14 +14,8 @@ type NBVersionCheck struct { var _ Check = (*NBVersionCheck)(nil) -// sanitizeVersion removes anything after the pre-release tag (e.g., "-dev", "-alpha", etc.) -func sanitizeVersion(version string) string { - parts := strings.Split(version, "-") - return parts[0] -} - func (n *NBVersionCheck) Check(ctx context.Context, peer nbpeer.Peer) (bool, error) { - meetsMin, err := MeetsMinVersion(n.MinVersion, peer.Meta.WtVersion) + meetsMin, err := nbversion.MeetsMinVersion(n.MinVersion, peer.Meta.WtVersion) if err != nil { return false, err } @@ -48,21 +40,3 @@ func (n *NBVersionCheck) Validate() error { } return nil } - -// MeetsMinVersion checks if the peer's version meets or exceeds the minimum required version -func MeetsMinVersion(minVer, peerVer string) (bool, error) { - peerVer = sanitizeVersion(peerVer) - minVer = sanitizeVersion(minVer) - - peerNBVer, err := version.NewVersion(peerVer) - if err != nil { - return false, err - } - - constraints, err := version.NewConstraint(">= " + minVer) - if err != nil { - return false, err - } - - return constraints.Check(peerNBVer), nil -} diff --git a/management/server/posture/nb_version_test.go b/management/server/posture/nb_version_test.go index d3478afc2..1bf485453 100644 --- a/management/server/posture/nb_version_test.go +++ b/management/server/posture/nb_version_test.go @@ -139,68 +139,3 @@ func TestNBVersionCheck_Validate(t *testing.T) { }) } } - -func TestMeetsMinVersion(t *testing.T) { - tests := []struct { - name string - minVer string - peerVer string - want bool - wantErr bool - }{ - { - name: "Peer version greater than min version", - minVer: "0.26.0", - peerVer: "0.60.1", - want: true, - wantErr: false, - }, - { - name: "Peer version equals min version", - minVer: "1.0.0", - peerVer: "1.0.0", - want: true, - wantErr: false, - }, - { - name: "Peer version less than min version", - minVer: "1.0.0", - peerVer: "0.9.9", - want: false, - wantErr: false, - }, - { - name: "Peer version with pre-release tag greater than min version", - minVer: "1.0.0", - peerVer: "1.0.1-alpha", - want: true, - wantErr: false, - }, - { - name: "Invalid peer version format", - minVer: "1.0.0", - peerVer: "dev", - want: false, - wantErr: true, - }, - { - name: "Invalid min version format", - minVer: "invalid.version", - peerVer: "1.0.0", - want: false, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := MeetsMinVersion(tt.minVer, tt.peerVer) - if tt.wantErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - } - assert.Equal(t, tt.want, got) - }) - } -} diff --git a/management/server/types/account.go b/management/server/types/account.go index 05033ae15..588e63a09 100644 --- a/management/server/types/account.go +++ b/management/server/types/account.go @@ -283,8 +283,8 @@ func (a *Account) SynthesizePrivateServiceZones(peerID string) []nbdns.CustomZon // it, adding a single private service would black-hole every // other name under the zone apex. zone = &nbdns.CustomZone{ - Domain: dns.Fqdn(serviceDomainZone), - Records: []nbdns.SimpleRecord{}, + Domain: dns.Fqdn(serviceDomainZone), + Records: []nbdns.SimpleRecord{}, NonAuthoritative: true, SearchDomainDisabled: true, } @@ -1082,6 +1082,7 @@ func (a *Account) connResourcesGenerator(ctx context.Context, targetPeer *nbpeer peersExists := make(map[string]struct{}) rules := make([]*FirewallRule, 0) peers := make([]*nbpeer.Peer, 0) + targetComponent := targetPeer.ToComponent() return func(rule *PolicyRule, groupPeers []*nbpeer.Peer, direction int) { for _, peer := range groupPeers { @@ -1117,10 +1118,10 @@ func (a *Account) connResourcesGenerator(ctx context.Context, targetPeer *nbpeer if len(rule.Ports) == 0 && len(rule.PortRanges) == 0 { rules = append(rules, &fr) } else { - rules = append(rules, ExpandPortsAndRanges(fr, rule, targetPeer)...) + rules = append(rules, ExpandPortsAndRanges(fr, rule, targetComponent)...) } - rules = AppendIPv6FirewallRule(rules, rulesExists, peer, targetPeer, rule, FirewallRuleContext{ + rules = AppendIPv6FirewallRule(rules, rulesExists, peer.ToComponent(), targetComponent, rule, FirewallRuleContext{ Direction: direction, DirStr: strconv.Itoa(direction), ProtocolStr: string(protocol), @@ -1280,7 +1281,7 @@ func (a *Account) getRouteFirewallRules(ctx context.Context, peerID string, poli return fwRules } -func (a *Account) getRulePeers(rule *PolicyRule, postureChecks []string, peerID string, distributionPeers map[string]struct{}, validatedPeersMap map[string]struct{}) []*nbpeer.Peer { +func (a *Account) getRulePeers(rule *PolicyRule, postureChecks []string, peerID string, distributionPeers map[string]struct{}, validatedPeersMap map[string]struct{}) []*ComponentPeer { distPeersWithPolicy := make(map[string]struct{}) for _, id := range rule.Sources { group := a.Groups[id] @@ -1307,13 +1308,13 @@ func (a *Account) getRulePeers(rule *PolicyRule, postureChecks []string, peerID } } - distributionGroupPeers := make([]*nbpeer.Peer, 0, len(distPeersWithPolicy)) + distributionGroupPeers := make([]*ComponentPeer, 0, len(distPeersWithPolicy)) for pID := range distPeersWithPolicy { peer := a.Peers[pID] if peer == nil { continue } - distributionGroupPeers = append(distributionGroupPeers, peer) + distributionGroupPeers = append(distributionGroupPeers, peer.ToComponent()) } return distributionGroupPeers } diff --git a/management/server/types/account_components.go b/management/server/types/account_components.go index 0205a1f55..af27788d8 100644 --- a/management/server/types/account_components.go +++ b/management/server/types/account_components.go @@ -9,9 +9,7 @@ import ( nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/management/internals/modules/zones" - resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" - nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/telemetry" "github.com/netbirdio/netbird/route" ) @@ -113,7 +111,7 @@ func (a *Account) GetPeerNetworkMapComponents( PeerID: peerID, Network: a.Network.Copy(), // must include the target peer as it's required on the client - Peers: map[string]*nbpeer.Peer{peerID: peer}, + Peers: map[string]*ComponentPeer{peerID: peer.ToComponent()}, }) } @@ -126,7 +124,7 @@ func (a *Account) GetPeerNetworkMapComponents( PeerID: peerID, Network: a.Network.Copy(), // must include the target peer as it's required on the client - Peers: map[string]*nbpeer.Peer{peerID: peer}, + Peers: map[string]*ComponentPeer{peerID: peer.ToComponent()}, }) } @@ -136,10 +134,10 @@ func (a *Account) GetPeerNetworkMapComponents( NameServerGroups: make([]*nbdns.NameServerGroup, 0), CustomZoneDomain: peersCustomZone.Domain, ResourcePoliciesMap: make(map[string][]*Policy), - RoutersMap: make(map[string]map[string]*routerTypes.NetworkRouter), - NetworkResources: make([]*resourceTypes.NetworkResource, 0), + RoutersMap: make(map[string]map[string]*ComponentRouter), + NetworkResources: make([]*ComponentResource, 0), PostureFailedPeers: make(map[string]map[string]struct{}, len(a.PostureChecks)), - RouterPeers: make(map[string]*nbpeer.Peer), + RouterPeers: make(map[string]*ComponentPeer), NetworkXIDToPublicID: make(map[string]string, len(a.Networks)), PostureCheckXIDToPublicID: make(map[string]string, len(a.PostureChecks)), } @@ -174,7 +172,7 @@ func (a *Account) GetPeerNetworkMapComponents( } components.Peers = relevantPeers - components.Groups = relevantGroups + components.Groups = GroupsToComponent(relevantGroups) components.Policies = relevantPolicies components.Routes = relevantRoutes components.AllDNSRecords = filterDNSRecordsByPeers(peersCustomZone.Records, relevantPeers, peer.SupportsIPv6() && peer.IPv6.IsValid()) @@ -223,7 +221,7 @@ func (a *Account) GetPeerNetworkMapComponents( } for _, pID := range a.getPostureValidPeersSaveFailed(peers, policy.SourcePostureChecks, validatedPeersMap, &components.PostureFailedPeers) { if _, exists := components.Peers[pID]; !exists { - components.Peers[pID] = a.GetPeer(pID) + components.Peers[pID] = a.GetPeer(pID).ToComponent() } } } else { @@ -256,14 +254,14 @@ func (a *Account) GetPeerNetworkMapComponents( for _, srcGroupID := range rule.Sources { if g := a.Groups[srcGroupID]; g != nil { if _, exists := components.Groups[srcGroupID]; !exists { - components.Groups[srcGroupID] = g + components.Groups[srcGroupID] = g.ToComponent() } } } for _, dstGroupID := range rule.Destinations { if g := a.Groups[dstGroupID]; g != nil { if _, exists := components.Groups[dstGroupID]; !exists { - components.Groups[dstGroupID] = g + components.Groups[dstGroupID] = g.ToComponent() } } } @@ -278,20 +276,22 @@ func (a *Account) GetPeerNetworkMapComponents( // network in the account — accounts with many tenants/networks // shipped tens of unrelated peers in `peers[]` and `routers_map`. if addSourcePeers { - components.RoutersMap[resource.NetworkID] = networkRoutingPeers + components.RoutersMap[resource.NetworkID] = routerTypes.ToComponentMap(networkRoutingPeers) for peerIDKey := range networkRoutingPeers { if p := a.Peers[peerIDKey]; p != nil { - if _, exists := components.RouterPeers[peerIDKey]; !exists { - components.RouterPeers[peerIDKey] = p + cp := components.RouterPeers[peerIDKey] + if cp == nil { + cp = p.ToComponent() + components.RouterPeers[peerIDKey] = cp } if _, exists := components.Peers[peerIDKey]; !exists { if _, validated := validatedPeersMap[peerIDKey]; validated { - components.Peers[peerIDKey] = p + components.Peers[peerIDKey] = cp } } } } - components.NetworkResources = append(components.NetworkResources, resource) + components.NetworkResources = append(components.NetworkResources, resource.ToComponent()) } } @@ -312,14 +312,14 @@ func (a *Account) getPeersGroupsPoliciesRoutes( peerSSHEnabled bool, validatedPeersMap map[string]struct{}, postureFailedPeers *map[string]map[string]struct{}, -) (map[string]*nbpeer.Peer, map[string]*Group, []*Policy, []*route.Route, sshRequirements) { - relevantPeerIDs := make(map[string]*nbpeer.Peer, len(a.Peers)/4) +) (map[string]*ComponentPeer, map[string]*Group, []*Policy, []*route.Route, sshRequirements) { + relevantPeerIDs := make(map[string]*ComponentPeer, len(a.Peers)/4) relevantGroupIDs := make(map[string]*Group, len(a.Groups)/4) relevantPolicies := make([]*Policy, 0, len(a.Policies)) relevantRoutes := make([]*route.Route, 0, len(a.Routes)) sshReqs := sshRequirements{neededGroupIDs: make(map[string]struct{})} - relevantPeerIDs[peerID] = a.GetPeer(peerID) + relevantPeerIDs[peerID] = a.GetPeer(peerID).ToComponent() peerGroupSet := make(map[string]struct{}, 8) for groupID, group := range a.Groups { @@ -384,7 +384,7 @@ func (a *Account) getPeersGroupsPoliciesRoutes( if r.Peer != "" { if _, ok := validatedPeersMap[r.Peer]; ok { if p := a.GetPeer(r.Peer); p != nil { - relevantPeerIDs[r.Peer] = p + relevantPeerIDs[r.Peer] = p.ToComponent() } } } @@ -401,7 +401,7 @@ func (a *Account) getPeersGroupsPoliciesRoutes( continue } if p := a.GetPeer(pid); p != nil { - relevantPeerIDs[pid] = p + relevantPeerIDs[pid] = p.ToComponent() } } } @@ -458,7 +458,9 @@ func (a *Account) getPeersGroupsPoliciesRoutes( if peerInSources { policyRelevant = true for _, pid := range destinationPeers { - relevantPeerIDs[pid] = a.GetPeer(pid) + if _, exists := relevantPeerIDs[pid]; !exists { + relevantPeerIDs[pid] = a.GetPeer(pid).ToComponent() + } } for _, dstGroupID := range rule.Destinations { relevantGroupIDs[dstGroupID] = a.GetGroup(dstGroupID) @@ -468,7 +470,9 @@ func (a *Account) getPeersGroupsPoliciesRoutes( if peerInDestinations { policyRelevant = true for _, pid := range sourcePeers { - relevantPeerIDs[pid] = a.GetPeer(pid) + if _, exists := relevantPeerIDs[pid]; !exists { + relevantPeerIDs[pid] = a.GetPeer(pid).ToComponent() + } } for _, srcGroupID := range rule.Sources { relevantGroupIDs[srcGroupID] = a.GetGroup(srcGroupID) @@ -624,7 +628,7 @@ func (a *Account) getPostureValidPeersSaveFailed(inputPeers []string, postureChe // that name them. Calculate() tolerates groups with empty Peers (the inner // loops simply iterate zero times), so retaining them is behaviourally a // no-op for the legacy path that consumes the same NetworkMapComponents. -func filterGroupPeers(groups *map[string]*Group, peers map[string]*nbpeer.Peer) { +func filterGroupPeers(groups *map[string]*ComponentGroup, peers map[string]*ComponentPeer) { for groupID, groupInfo := range *groups { filteredPeers := make([]string, 0, len(groupInfo.Peers)) for _, pid := range groupInfo.Peers { @@ -634,14 +638,14 @@ func filterGroupPeers(groups *map[string]*Group, peers map[string]*nbpeer.Peer) } if len(filteredPeers) != len(groupInfo.Peers) { - ng := groupInfo.Copy() + ng := *groupInfo ng.Peers = filteredPeers - (*groups)[groupID] = ng + (*groups)[groupID] = &ng } } } -func filterPostureFailedPeers(postureFailedPeers *map[string]map[string]struct{}, policies []*Policy, resourcePoliciesMap map[string][]*Policy, peers map[string]*nbpeer.Peer) { +func filterPostureFailedPeers(postureFailedPeers *map[string]map[string]struct{}, policies []*Policy, resourcePoliciesMap map[string][]*Policy, peers map[string]*ComponentPeer) { if len(*postureFailedPeers) == 0 { return } @@ -676,7 +680,7 @@ func filterPostureFailedPeers(postureFailedPeers *map[string]map[string]struct{} } } -func filterDNSRecordsByPeers(records []nbdns.SimpleRecord, peers map[string]*nbpeer.Peer, includeIPv6 bool) []nbdns.SimpleRecord { +func filterDNSRecordsByPeers(records []nbdns.SimpleRecord, peers map[string]*ComponentPeer, includeIPv6 bool) []nbdns.SimpleRecord { if len(records) == 0 || len(peers) == 0 { return nil } diff --git a/management/server/types/account_private_netmap_test.go b/management/server/types/account_private_netmap_test.go index dc097ce26..11b3d985a 100644 --- a/management/server/types/account_private_netmap_test.go +++ b/management/server/types/account_private_netmap_test.go @@ -9,7 +9,6 @@ import ( "github.com/stretchr/testify/require" nbdns "github.com/netbirdio/netbird/dns" - nbpeer "github.com/netbirdio/netbird/management/server/peer" ) func TestPrivateService_NetworkMap_UserPeer_AndProxyPeer(t *testing.T) { @@ -49,7 +48,7 @@ func TestPrivateService_NetworkMap_UserPeer_AndProxyPeer(t *testing.T) { }) } -func netmapPeerIDs(peers []*nbpeer.Peer) []string { +func netmapPeerIDs(peers []*ComponentPeer) []string { ids := make([]string, 0, len(peers)) for _, p := range peers { if p == nil { diff --git a/management/server/types/account_test.go b/management/server/types/account_test.go index e5b5708fa..67d9e1c6f 100644 --- a/management/server/types/account_test.go +++ b/management/server/types/account_test.go @@ -666,7 +666,7 @@ func Test_ExpandPortsAndRanges_SSHRuleExpansion(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := ExpandPortsAndRanges(tt.base, tt.rule, tt.peer) + result := ExpandPortsAndRanges(tt.base, tt.rule, tt.peer.ToComponent()) var ports []string for _, fr := range result { diff --git a/management/server/types/aliases.go b/management/server/types/aliases.go index f5837a343..9324cfa1e 100644 --- a/management/server/types/aliases.go +++ b/management/server/types/aliases.go @@ -6,7 +6,6 @@ import ( "net" "net/netip" - nbpeer "github.com/netbirdio/netbird/management/server/peer" nbroute "github.com/netbirdio/netbird/route" sharedtypes "github.com/netbirdio/netbird/shared/management/types" ) @@ -18,9 +17,6 @@ type DNSSettings = sharedtypes.DNSSettings type FirewallRule = sharedtypes.FirewallRule -type Group = sharedtypes.Group -type GroupPeer = sharedtypes.GroupPeer - type Network = sharedtypes.Network type NetworkMap = sharedtypes.NetworkMap type ForwardingRule = sharedtypes.ForwardingRule @@ -42,6 +38,18 @@ type RouteFirewallRule = sharedtypes.RouteFirewallRule type NetworkMapComponents = sharedtypes.NetworkMapComponents +type ComponentPeer = sharedtypes.ComponentPeer +type ComponentGroup = sharedtypes.ComponentGroup +type ComponentRouter = sharedtypes.ComponentRouter +type ComponentResource = sharedtypes.ComponentResource +type ComponentResourceType = sharedtypes.ComponentResourceType + +const ( + ComponentResourceHost = sharedtypes.ComponentResourceHost + ComponentResourceSubnet = sharedtypes.ComponentResourceSubnet + ComponentResourceDomain = sharedtypes.ComponentResourceDomain +) + var EmptyNetworkMapComponents = sharedtypes.EmptyNetworkMapComponents type AccountSettingsInfo = sharedtypes.AccountSettingsInfo @@ -52,12 +60,7 @@ type NetworkMapComponentsCompact = sharedtypes.NetworkMapComponentsCompact type LookupMap = sharedtypes.LookupMap type FirewallRuleContext = sharedtypes.FirewallRuleContext -const ( - GroupIssuedAPI = sharedtypes.GroupIssuedAPI - GroupIssuedJWT = sharedtypes.GroupIssuedJWT - GroupIssuedIntegration = sharedtypes.GroupIssuedIntegration - GroupAllName = sharedtypes.GroupAllName -) +const GroupAllName = sharedtypes.GroupAllName // Function forwarders preserve types.X(...) call sites that previously // resolved to package-local funcs. Plain forwarders (not var aliases) keep @@ -67,11 +70,11 @@ func PolicyRuleImpliesLegacySSH(rule *PolicyRule) bool { return sharedtypes.PolicyRuleImpliesLegacySSH(rule) } -func ExpandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *nbpeer.Peer) []*FirewallRule { +func ExpandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *ComponentPeer) []*FirewallRule { return sharedtypes.ExpandPortsAndRanges(base, rule, peer) } -func AppendIPv6FirewallRule(rules []*FirewallRule, rulesExists map[string]struct{}, peer, targetPeer *nbpeer.Peer, rule *PolicyRule, rc FirewallRuleContext) []*FirewallRule { +func AppendIPv6FirewallRule(rules []*FirewallRule, rulesExists map[string]struct{}, peer, targetPeer *ComponentPeer, rule *PolicyRule, rc FirewallRuleContext) []*FirewallRule { return sharedtypes.AppendIPv6FirewallRule(rules, rulesExists, peer, targetPeer, rule, rc) } @@ -79,7 +82,7 @@ func CalculateNetworkMapFromComponents(ctx context.Context, components *NetworkM return sharedtypes.CalculateNetworkMapFromComponents(ctx, components) } -func GenerateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule *PolicyRule, groupPeers []*nbpeer.Peer, direction int, includeIPv6 bool) []*RouteFirewallRule { +func GenerateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule *PolicyRule, groupPeers []*ComponentPeer, direction int, includeIPv6 bool) []*RouteFirewallRule { return sharedtypes.GenerateRouteFirewallRules(ctx, route, rule, groupPeers, direction, includeIPv6) } diff --git a/shared/management/types/group.go b/management/server/types/group.go similarity index 83% rename from shared/management/types/group.go rename to management/server/types/group.go index e6e285e62..a5e196997 100644 --- a/shared/management/types/group.go +++ b/management/server/types/group.go @@ -2,7 +2,6 @@ package types import ( "github.com/netbirdio/netbird/management/server/integration_reference" - "github.com/netbirdio/netbird/management/server/networks/resources/types" ) const ( @@ -68,10 +67,6 @@ func (g *Group) EventMeta() map[string]any { return map[string]any{"name": g.Name} } -func (g *Group) EventMetaResource(resource *types.NetworkResource) map[string]any { - return map[string]any{"name": g.Name, "id": g.ID, "resource_name": resource.Name, "resource_id": resource.ID, "resource_type": resource.Type} -} - func (g *Group) Copy() *Group { group := &Group{ ID: g.ID, @@ -95,14 +90,39 @@ func (g *Group) HasPeers() bool { return len(g.Peers) > 0 } -// GroupAllName is the reserved name of the default group that contains every peer in an account. -const GroupAllName = "All" - // IsGroupAll checks if the group is a default "All" group. func (g *Group) IsGroupAll() bool { return g.Name == GroupAllName } +// ToComponent converts the group to its self-contained components +// representation. The Peers slice is shared, not copied — components are +// treated as immutable snapshots. Returns nil for a nil group. +func (g *Group) ToComponent() *ComponentGroup { + if g == nil { + return nil + } + return &ComponentGroup{ + ID: g.ID, + PublicID: g.PublicID, + Name: g.Name, + Peers: g.Peers, + } +} + +// GroupsToComponent converts an id-keyed group map to its components +// representation, preserving nil entries. +func GroupsToComponent(groups map[string]*Group) map[string]*ComponentGroup { + if groups == nil { + return nil + } + out := make(map[string]*ComponentGroup, len(groups)) + for id, g := range groups { + out[id] = g.ToComponent() + } + return out +} + // AddPeer adds peerID to Peers if not present, returning true if added. func (g *Group) AddPeer(peerID string) bool { if peerID == "" { diff --git a/management/server/types/ipv6_endtoend_test.go b/management/server/types/ipv6_endtoend_test.go index ddd1f649f..d83603abe 100644 --- a/management/server/types/ipv6_endtoend_test.go +++ b/management/server/types/ipv6_endtoend_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/require" nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/types" ) func TestNetworkMapComponents_IPv6EndToEnd(t *testing.T) { @@ -104,7 +105,7 @@ func TestNetworkMapComponents_RemotePeerWithoutCapability(t *testing.T) { require.NotNil(t, nm) t.Run("AllowedIPs include remote v6", func(t *testing.T) { - var dst *nbpeer.Peer + var dst *types.ComponentPeer for _, p := range nm.Peers { if p.ID == "peer-dst-1" { dst = p diff --git a/management/server/types/networkmap_components_test.go b/management/server/types/networkmap_components_test.go index 1a99b4511..3f2288f88 100644 --- a/management/server/types/networkmap_components_test.go +++ b/management/server/types/networkmap_components_test.go @@ -49,7 +49,7 @@ func allPeersValidated(account *types.Account, excludePeerIDs ...string) map[str return validated } -func peerIDs(peers []*nbpeer.Peer) []string { +func peerIDs(peers []*types.ComponentPeer) []string { ids := make([]string, len(peers)) for i, p := range peers { ids[i] = p.ID diff --git a/management/server/util/util.go b/management/server/util/util.go index 617484274..d85b55f02 100644 --- a/management/server/util/util.go +++ b/management/server/util/util.go @@ -19,34 +19,3 @@ func Difference(a, b []string) []string { func ToPtr[T any](value T) *T { return &value } - -type comparableObject[T any] interface { - Equal(other T) bool -} - -func MergeUnique[T comparableObject[T]](arr1, arr2 []T) []T { - var result []T - - for _, item := range arr1 { - if !contains(result, item) { - result = append(result, item) - } - } - - for _, item := range arr2 { - if !contains(result, item) { - result = append(result, item) - } - } - - return result -} - -func contains[T comparableObject[T]](slice []T, element T) bool { - for _, item := range slice { - if item.Equal(element) { - return true - } - } - return false -} diff --git a/shared/management/networkmap/decode.go b/shared/management/networkmap/decode.go index c66074b4f..d15117b6e 100644 --- a/shared/management/networkmap/decode.go +++ b/shared/management/networkmap/decode.go @@ -11,9 +11,6 @@ import ( log "github.com/sirupsen/logrus" nbdns "github.com/netbirdio/netbird/dns" - resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" - routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" - nbpeer "github.com/netbirdio/netbird/management/server/peer" nbroute "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/domain" "github.com/netbirdio/netbird/shared/management/proto" @@ -38,17 +35,17 @@ func DecodeEnvelope(env *proto.NetworkMapEnvelope) (*types.NetworkMapComponents, Network: decodeAccountNetwork(full.Network), AccountSettings: decodeAccountSettings(full.AccountSettings), CustomZoneDomain: full.CustomZoneDomain, - Peers: make(map[string]*nbpeer.Peer, len(full.Peers)), - Groups: make(map[string]*types.Group, len(full.Groups)), + Peers: make(map[string]*types.ComponentPeer, len(full.Peers)), + Groups: make(map[string]*types.ComponentGroup, len(full.Groups)), Policies: make([]*types.Policy, 0, len(full.Policies)), Routes: make([]*nbroute.Route, 0, len(full.Routes)), NameServerGroups: make([]*nbdns.NameServerGroup, 0, len(full.NameserverGroups)), AllDNSRecords: decodeSimpleRecords(full.AllDnsRecords), AccountZones: decodeCustomZones(full.AccountZones), ResourcePoliciesMap: make(map[string][]*types.Policy), - RoutersMap: make(map[string]map[string]*routerTypes.NetworkRouter), - NetworkResources: make([]*resourceTypes.NetworkResource, 0, len(full.NetworkResources)), - RouterPeers: make(map[string]*nbpeer.Peer), + RoutersMap: make(map[string]map[string]*types.ComponentRouter), + NetworkResources: make([]*types.ComponentResource, 0, len(full.NetworkResources)), + RouterPeers: make(map[string]*types.ComponentPeer), AllowedUserIDs: stringSliceToSet(full.AllowedUserIds), PostureFailedPeers: make(map[string]map[string]struct{}, len(full.PostureFailedPeers)), GroupIDToUserIDs: make(map[string][]string, len(full.GroupIdToUserIds)), @@ -101,7 +98,7 @@ func DecodeEnvelope(env *proto.NetworkMapEnvelope) (*types.NetworkMapComponents, log.WithField("peer idx", idx).Error("unrecognized peer idx during decoding") } } - group := &types.Group{ + group := &types.ComponentGroup{ ID: groupID, PublicID: gc.Id, Peers: peerIDs, @@ -151,7 +148,7 @@ func DecodeEnvelope(env *proto.NetworkMapEnvelope) (*types.NetworkMapComponents, // Phase 7: routers_map (outer key = network seq id, inner key = peer-id // reconstructed from peer_index). Synthesized network id is "net_". for networkID, list := range full.RoutersMap { - inner := make(map[string]*routerTypes.NetworkRouter, len(list.Entries)) + inner := make(map[string]*types.ComponentRouter, len(list.Entries)) for _, entry := range list.Entries { if !entry.PeerIndexSet { continue @@ -161,8 +158,7 @@ func DecodeEnvelope(env *proto.NetworkMapEnvelope) (*types.NetworkMapComponents, continue } peerID := peerIDByIndex[entry.PeerIndex] - inner[peerID] = &routerTypes.NetworkRouter{ - ID: "", + inner[peerID] = &types.ComponentRouter{ NetworkID: networkID, PublicID: entry.Id, Peer: peerID, @@ -264,40 +260,22 @@ func decodeAccountSettings(as *proto.AccountSettingsCompact) *types.AccountSetti } } -func decodePeerCompact(pc *proto.PeerCompact, peerID string) *nbpeer.Peer { - var caps []int32 - if pc.SupportsSourcePrefixes { - caps = append(caps, nbpeer.PeerCapabilitySourcePrefixes) - } - if pc.SupportsIpv6 { - caps = append(caps, nbpeer.PeerCapabilityIPv6Overlay) - } - peer := &nbpeer.Peer{ +func decodePeerCompact(pc *proto.PeerCompact, peerID string) *types.ComponentPeer { + peer := &types.ComponentPeer{ ID: peerID, Key: peerID, SSHKey: string(pc.SshPubKey), SSHEnabled: pc.SshEnabled, DNSLabel: pc.DnsLabel, LoginExpirationEnabled: pc.LoginExpirationEnabled, - Meta: nbpeer.PeerSystemMeta{ - WtVersion: pc.AgentVersion, - Capabilities: caps, - Flags: nbpeer.Flags{ - ServerSSHAllowed: pc.ServerSshAllowed, - }, - }, - } - if pc.AddedWithSsoLogin { - // Set a non-empty UserID so (*Peer).AddedWithSSOLogin() returns true. - // The original UserID isn't on the wire; the value is intentionally - // visibly synthetic so any future consumer that mistakes UserID for a - // real account user xid won't silently match (or worse, write the - // sentinel into a downstream record). - peer.UserID = "" + AgentVersion: pc.AgentVersion, + SupportsSourcePrefixes: pc.SupportsSourcePrefixes, + SupportsIPv6: pc.SupportsIpv6, + ServerSSHAllowed: pc.ServerSshAllowed, + AddedWithSSOLogin: pc.AddedWithSsoLogin, } if pc.LastLoginUnixNano != 0 { - t := time.Unix(0, pc.LastLoginUnixNano) - peer.LastLogin = &t + peer.LastLogin = time.Unix(0, pc.LastLoginUnixNano) } switch len(pc.Ip) { case 4: @@ -424,14 +402,14 @@ func decodeNameServerGroupRaw(nsg *proto.NameServerGroupRaw) *nbdns.NameServerGr return out } -func decodeNetworkResource(nr *proto.NetworkResourceRaw) *resourceTypes.NetworkResource { - out := &resourceTypes.NetworkResource{ +func decodeNetworkResource(nr *proto.NetworkResourceRaw) *types.ComponentResource { + out := &types.ComponentResource{ ID: nr.Id, PublicID: nr.Id, NetworkID: nr.NetworkSeq, Name: nr.Name, Description: nr.Description, - Type: resourceTypes.NetworkResourceType(nr.Type), + Type: types.ComponentResourceType(nr.Type), Address: nr.Address, Domain: nr.DomainValue, Enabled: nr.Enabled, diff --git a/shared/management/networkmap/encode.go b/shared/management/networkmap/encode.go index e808480ea..ccde32faf 100644 --- a/shared/management/networkmap/encode.go +++ b/shared/management/networkmap/encode.go @@ -20,10 +20,9 @@ import ( nbdns "github.com/netbirdio/netbird/dns" "net/netip" - nbpeer "github.com/netbirdio/netbird/management/server/peer" - "github.com/netbirdio/netbird/shared/management/types" nbroute "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/proto" + "github.com/netbirdio/netbird/shared/management/types" "github.com/netbirdio/netbird/shared/netiputil" "github.com/netbirdio/netbird/shared/sshauth" ) @@ -274,7 +273,7 @@ func ToProtocolDNSConfig(update nbdns.Config, cache DNSConfigCache, forwardPort // AppendRemotePeerConfig appends typed peers as proto.RemotePeerConfig // entries to dst and returns the result. -func AppendRemotePeerConfig(dst []*proto.RemotePeerConfig, peers []*nbpeer.Peer, dnsName string, includeIPv6 bool) []*proto.RemotePeerConfig { +func AppendRemotePeerConfig(dst []*proto.RemotePeerConfig, peers []*types.ComponentPeer, dnsName string, includeIPv6 bool) []*proto.RemotePeerConfig { for _, rPeer := range peers { allowedIPs := []string{rPeer.IP.String() + "/32"} if includeIPv6 && rPeer.IPv6.IsValid() { @@ -285,7 +284,7 @@ func AppendRemotePeerConfig(dst []*proto.RemotePeerConfig, peers []*nbpeer.Peer, AllowedIps: allowedIPs, SshConfig: &proto.SSHConfig{SshPubKey: []byte(rPeer.SSHKey)}, Fqdn: rPeer.FQDN(dnsName), - AgentVersion: rPeer.Meta.WtVersion, + AgentVersion: rPeer.AgentVersion, }) } return dst diff --git a/shared/management/networkmap/envelope.go b/shared/management/networkmap/envelope.go index 3f045a9eb..a928c5059 100644 --- a/shared/management/networkmap/envelope.go +++ b/shared/management/networkmap/envelope.go @@ -54,8 +54,8 @@ func EnvelopeToNetworkMap(ctx context.Context, env *proto.NetworkMapEnvelope, lo } components.PeerID = canonicalKey - includeIPv6 := localPeer.SupportsIPv6() && localPeer.IPv6.IsValid() - useSourcePrefixes := localPeer.SupportsSourcePrefixes() + includeIPv6 := localPeer.SupportsIPv6 && localPeer.IPv6.IsValid() + useSourcePrefixes := localPeer.SupportsSourcePrefixes typedNM := components.Calculate(ctx) diff --git a/shared/management/networkmap/envelope_test.go b/shared/management/networkmap/envelope_test.go index 11a5335be..a81478aff 100644 --- a/shared/management/networkmap/envelope_test.go +++ b/shared/management/networkmap/envelope_test.go @@ -13,7 +13,6 @@ import ( goproto "google.golang.org/protobuf/proto" mgmtgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" - nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/types" nbnetworkmap "github.com/netbirdio/netbird/shared/management/networkmap" "github.com/netbirdio/netbird/shared/management/proto" @@ -144,14 +143,14 @@ func TestDecodeEnvelope_MalformedWgKeyPeerSkipped(t *testing.T) { func TestEnvelopeRoundTrip_AllGroupShortCircuitParity(t *testing.T) { ctx := context.Background() - peers := map[string]*nbpeer.Peer{} + peers := map[string]*types.ComponentPeer{} for i, id := range []string{"peer-T", "peer-S", "peer-ALL", "peer-O"} { - peers[id] = &nbpeer.Peer{ - ID: id, - Key: randomWgKey(t), - IP: netip.AddrFrom4([4]byte{100, 64, 0, byte(i + 1)}), - DNSLabel: id, - Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + peers[id] = &types.ComponentPeer{ + ID: id, + Key: randomWgKey(t), + IP: netip.AddrFrom4([4]byte{100, 64, 0, byte(i + 1)}), + DNSLabel: id, + AgentVersion: "0.40.0", } } @@ -165,7 +164,7 @@ func TestEnvelopeRoundTrip_AllGroupShortCircuitParity(t *testing.T) { AccountSettings: &types.AccountSettingsInfo{}, DNSSettings: &types.DNSSettings{}, Peers: peers, - Groups: map[string]*types.Group{ + Groups: map[string]*types.ComponentGroup{ "g-src": {ID: "g-src", PublicID: "1", Name: "staff", Peers: []string{"peer-T", "peer-S"}}, "g-all": {ID: "g-all", PublicID: "2", Name: "All", Peers: []string{"peer-ALL"}}, "g-two": {ID: "g-two", PublicID: "3", Name: "second", Peers: []string{"peer-T", "peer-O"}}, @@ -232,22 +231,22 @@ func buildSmokeComponents(t *testing.T) (*types.NetworkMapComponents, string) { peerAKey := randomWgKey(t) peerBKey := randomWgKey(t) - peerA := &nbpeer.Peer{ - ID: "peer-A", - Key: peerAKey, - IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), - DNSLabel: "peerA", - Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + peerA := &types.ComponentPeer{ + ID: "peer-A", + Key: peerAKey, + IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), + DNSLabel: "peerA", + AgentVersion: "0.40.0", } - peerB := &nbpeer.Peer{ - ID: "peer-B", - Key: peerBKey, - IP: netip.AddrFrom4([4]byte{100, 64, 0, 2}), - DNSLabel: "peerB", - Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0"}, + peerB := &types.ComponentPeer{ + ID: "peer-B", + Key: peerBKey, + IP: netip.AddrFrom4([4]byte{100, 64, 0, 2}), + DNSLabel: "peerB", + AgentVersion: "0.40.0", } - group := &types.Group{ + group := &types.ComponentGroup{ ID: "group-all", PublicID: "1", Name: "All", Peers: []string{"peer-A", "peer-B"}, } @@ -274,11 +273,11 @@ func buildSmokeComponents(t *testing.T) (*types.NetworkMapComponents, string) { }, AccountSettings: &types.AccountSettingsInfo{}, DNSSettings: &types.DNSSettings{}, - Peers: map[string]*nbpeer.Peer{ + Peers: map[string]*types.ComponentPeer{ "peer-A": peerA, "peer-B": peerB, }, - Groups: map[string]*types.Group{ + Groups: map[string]*types.ComponentGroup{ "group-all": group, }, Policies: []*types.Policy{policy}, diff --git a/shared/management/types/component_types.go b/shared/management/types/component_types.go new file mode 100644 index 000000000..a511097b1 --- /dev/null +++ b/shared/management/types/component_types.go @@ -0,0 +1,103 @@ +package types + +import ( + "net/netip" + "time" +) + +// ComponentPeer is the self-contained peer representation used by +// NetworkMapComponents and the calculated NetworkMap. It carries exactly the +// subset of peer data that crosses the components wire format, so the shared +// calculation layer stays independent of the management server's domain +// types. +type ComponentPeer struct { + ID string + Key string + IP netip.Addr + IPv6 netip.Addr + DNSLabel string + SSHKey string + SSHEnabled bool + ServerSSHAllowed bool + AgentVersion string + SupportsSourcePrefixes bool + SupportsIPv6 bool + LoginExpirationEnabled bool + AddedWithSSOLogin bool + LastLogin time.Time +} + +// FQDN returns the peer's FQDN combined of the peer's DNS label and the system's DNS domain. +func (p *ComponentPeer) FQDN(dnsDomain string) string { + if dnsDomain == "" { + return "" + } + return p.DNSLabel + "." + dnsDomain +} + +// LoginExpired indicates whether the peer's login has expired, mirroring the +// server-side peer semantics: only SSO-added peers with login expiration +// enabled can expire. +func (p *ComponentPeer) LoginExpired(expiresIn time.Duration) (bool, time.Duration) { + if !p.AddedWithSSOLogin || !p.LoginExpirationEnabled { + return false, 0 + } + timeLeft := time.Until(p.LastLogin.Add(expiresIn)) + return timeLeft <= 0, timeLeft +} + +// GroupAllName is the reserved name of the default group that contains every peer in an account. +const GroupAllName = "All" + +// ComponentGroup is the self-contained group representation used by +// NetworkMapComponents: just the membership view the network-map calculation +// needs, without the server's storage fields. +type ComponentGroup struct { + ID string + PublicID string + Name string + Peers []string +} + +// IsGroupAll checks if the group is a default "All" group. +func (g *ComponentGroup) IsGroupAll() bool { + return g.Name == GroupAllName +} + +// ComponentRouter is the self-contained network-router representation used by +// NetworkMapComponents. +type ComponentRouter struct { + NetworkID string + PublicID string + Peer string + PeerGroups []string + Masquerade bool + Metric int + Enabled bool +} + +// ComponentResourceType mirrors the network-resource type enum on the +// components wire format. +type ComponentResourceType string + +const ( + ComponentResourceHost ComponentResourceType = "host" + ComponentResourceSubnet ComponentResourceType = "subnet" + ComponentResourceDomain ComponentResourceType = "domain" +) + +// ComponentResource is the self-contained network-resource representation +// used by NetworkMapComponents. +type ComponentResource struct { + ID string + PublicID string + NetworkID string + AccountID string + Name string + Description string + Type ComponentResourceType + Address string + Domain string + Prefix netip.Prefix + Enabled bool +} diff --git a/shared/management/types/firewall_helpers.go b/shared/management/types/firewall_helpers.go index dd174abe4..6e43af33e 100644 --- a/shared/management/types/firewall_helpers.go +++ b/shared/management/types/firewall_helpers.go @@ -3,8 +3,6 @@ package types import ( "strconv" - nbpeer "github.com/netbirdio/netbird/management/server/peer" - "github.com/netbirdio/netbird/management/server/posture" "github.com/netbirdio/netbird/version" ) @@ -48,8 +46,8 @@ func portsIncludesSSH(ports []string) bool { } // ExpandPortsAndRanges expands Ports and PortRanges of a rule into individual firewall rules. -func ExpandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *nbpeer.Peer) []*FirewallRule { - features := peerSupportedFirewallFeatures(peer.Meta.WtVersion) +func ExpandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *ComponentPeer) []*FirewallRule { + features := peerSupportedFirewallFeatures(peer.AgentVersion) var expanded []*FirewallRule @@ -106,8 +104,8 @@ func isPortInRule(portString string, portInt uint16, rule *FirewallRule) bool { return rule.Port == portString || (rule.PortRange.Start <= portInt && portInt <= rule.PortRange.End) } -func shouldCheckRulesForNativeSSH(supportsNative bool, rule *PolicyRule, peer *nbpeer.Peer) bool { - return supportsNative && peer.SSHEnabled && peer.Meta.Flags.ServerSSHAllowed && rule.Protocol == PolicyRuleProtocolTCP +func shouldCheckRulesForNativeSSH(supportsNative bool, rule *PolicyRule, peer *ComponentPeer) bool { + return supportsNative && peer.SSHEnabled && peer.ServerSSHAllowed && rule.Protocol == PolicyRuleProtocolTCP } func peerSupportedFirewallFeatures(peerVer string) supportedFeatures { @@ -117,13 +115,13 @@ func peerSupportedFirewallFeatures(peerVer string) supportedFeatures { var features supportedFeatures - meetMinVer, err := posture.MeetsMinVersion(firewallRuleMinNativeSSHVer, peerVer) + meetMinVer, err := version.MeetsMinVersion(firewallRuleMinNativeSSHVer, peerVer) features.nativeSSH = err == nil && meetMinVer if features.nativeSSH { features.portRanges = true } else { - meetMinVer, err = posture.MeetsMinVersion(firewallRuleMinPortRangesVer, peerVer) + meetMinVer, err = version.MeetsMinVersion(firewallRuleMinPortRangesVer, peerVer) features.portRanges = err == nil && meetMinVer } diff --git a/shared/management/types/firewall_rule.go b/shared/management/types/firewall_rule.go index 87dcfe307..67cb581a2 100644 --- a/shared/management/types/firewall_rule.go +++ b/shared/management/types/firewall_rule.go @@ -9,7 +9,6 @@ import ( log "github.com/sirupsen/logrus" - nbpeer "github.com/netbirdio/netbird/management/server/peer" nbroute "github.com/netbirdio/netbird/route" ) @@ -51,7 +50,7 @@ func (r *FirewallRule) Equal(other *FirewallRule) bool { // 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 { +func GenerateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule *PolicyRule, groupPeers []*ComponentPeer, direction int, includeIPv6 bool) []*RouteFirewallRule { rulesExists := make(map[string]struct{}) rules := make([]*RouteFirewallRule, 0) @@ -107,7 +106,7 @@ func GenerateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule } // splitPeerSourcesByFamily separates peer IPs into v4 (/32) and v6 (/128) source ranges. -func splitPeerSourcesByFamily(groupPeers []*nbpeer.Peer) (v4, v6 []string) { +func splitPeerSourcesByFamily(groupPeers []*ComponentPeer) (v4, v6 []string) { v4 = make([]string, 0, len(groupPeers)) v6 = make([]string, 0, len(groupPeers)) for _, peer := range groupPeers { diff --git a/shared/management/types/firewall_rule_test.go b/shared/management/types/firewall_rule_test.go index 9de4ca04a..c21cfa2df 100644 --- a/shared/management/types/firewall_rule_test.go +++ b/shared/management/types/firewall_rule_test.go @@ -8,13 +8,12 @@ import ( "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{ + peers := []*ComponentPeer{ { IP: netip.MustParseAddr("100.64.0.1"), IPv6: netip.MustParseAddr("fd00::1"), @@ -36,7 +35,7 @@ func TestSplitPeerSourcesByFamily(t *testing.T) { } func TestGenerateRouteFirewallRules_V4Route(t *testing.T) { - peers := []*nbpeer.Peer{ + peers := []*ComponentPeer{ { IP: netip.MustParseAddr("100.64.0.1"), IPv6: netip.MustParseAddr("fd00::1"), @@ -65,7 +64,7 @@ func TestGenerateRouteFirewallRules_V4Route(t *testing.T) { } func TestGenerateRouteFirewallRules_V6Route(t *testing.T) { - peers := []*nbpeer.Peer{ + peers := []*ComponentPeer{ { IP: netip.MustParseAddr("100.64.0.1"), IPv6: netip.MustParseAddr("fd00::1"), @@ -93,7 +92,7 @@ func TestGenerateRouteFirewallRules_V6Route(t *testing.T) { } func TestGenerateRouteFirewallRules_DynamicRoute_DualStack(t *testing.T) { - peers := []*nbpeer.Peer{ + peers := []*ComponentPeer{ { IP: netip.MustParseAddr("100.64.0.1"), IPv6: netip.MustParseAddr("fd00::1"), @@ -126,7 +125,7 @@ func TestGenerateRouteFirewallRules_DynamicRoute_DualStack(t *testing.T) { } func TestGenerateRouteFirewallRules_DynamicRoute_NoV6Peers(t *testing.T) { - peers := []*nbpeer.Peer{ + peers := []*ComponentPeer{ {IP: netip.MustParseAddr("100.64.0.1")}, {IP: netip.MustParseAddr("100.64.0.2")}, } @@ -150,7 +149,7 @@ func TestGenerateRouteFirewallRules_DynamicRoute_NoV6Peers(t *testing.T) { } func TestGenerateRouteFirewallRules_IncludeIPv6False(t *testing.T) { - peers := []*nbpeer.Peer{ + peers := []*ComponentPeer{ { IP: netip.MustParseAddr("100.64.0.1"), IPv6: netip.MustParseAddr("fd00::1"), diff --git a/shared/management/types/network.go b/shared/management/types/network.go index fe67bfd97..72a5cc5b3 100644 --- a/shared/management/types/network.go +++ b/shared/management/types/network.go @@ -15,8 +15,6 @@ import ( "golang.org/x/exp/maps" nbdns "github.com/netbirdio/netbird/dns" - nbpeer "github.com/netbirdio/netbird/management/server/peer" - "github.com/netbirdio/netbird/management/server/util" "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/proto" "github.com/netbirdio/netbird/shared/management/status" @@ -39,11 +37,11 @@ const ( ) type NetworkMap struct { - Peers []*nbpeer.Peer + Peers []*ComponentPeer Network *Network Routes []*route.Route DNSConfig nbdns.Config - OfflinePeers []*nbpeer.Peer + OfflinePeers []*ComponentPeer FirewallRules []*FirewallRule RoutesFirewallRules []*RouteFirewallRule ForwardingRules []*ForwardingRule @@ -53,15 +51,46 @@ type NetworkMap struct { func (nm *NetworkMap) Merge(other *NetworkMap) { nm.Peers = mergeUniquePeersByID(nm.Peers, other.Peers) - nm.Routes = util.MergeUnique(nm.Routes, other.Routes) + nm.Routes = mergeUnique(nm.Routes, other.Routes) nm.OfflinePeers = mergeUniquePeersByID(nm.OfflinePeers, other.OfflinePeers) - nm.FirewallRules = util.MergeUnique(nm.FirewallRules, other.FirewallRules) - nm.RoutesFirewallRules = util.MergeUnique(nm.RoutesFirewallRules, other.RoutesFirewallRules) - nm.ForwardingRules = util.MergeUnique(nm.ForwardingRules, other.ForwardingRules) + nm.FirewallRules = mergeUnique(nm.FirewallRules, other.FirewallRules) + nm.RoutesFirewallRules = mergeUnique(nm.RoutesFirewallRules, other.RoutesFirewallRules) + nm.ForwardingRules = mergeUnique(nm.ForwardingRules, other.ForwardingRules) } -func mergeUniquePeersByID(peers1, peers2 []*nbpeer.Peer) []*nbpeer.Peer { - result := make(map[string]*nbpeer.Peer) +type comparableObject[T any] interface { + Equal(other T) bool +} + +func mergeUnique[T comparableObject[T]](arr1, arr2 []T) []T { + var result []T + + for _, item := range arr1 { + if !containsEqual(result, item) { + result = append(result, item) + } + } + + for _, item := range arr2 { + if !containsEqual(result, item) { + result = append(result, item) + } + } + + return result +} + +func containsEqual[T comparableObject[T]](slice []T, element T) bool { + for _, item := range slice { + if item.Equal(element) { + return true + } + } + return false +} + +func mergeUniquePeersByID(peers1, peers2 []*ComponentPeer) []*ComponentPeer { + result := make(map[string]*ComponentPeer) for _, peer := range peers1 { result[peer.ID] = peer } diff --git a/management/server/util/util_test.go b/shared/management/types/network_merge_test.go similarity index 88% rename from management/server/util/util_test.go rename to shared/management/types/network_merge_test.go index 5c928b369..a7ef24c1e 100644 --- a/management/server/util/util_test.go +++ b/shared/management/types/network_merge_test.go @@ -1,4 +1,4 @@ -package util +package types import ( "testing" @@ -17,7 +17,7 @@ func (t testObject) Equal(other testObject) bool { func Test_MergeUniqueArraysWithoutDuplicates(t *testing.T) { arr1 := []testObject{{value: 1}, {value: 2}} arr2 := []testObject{{value: 2}, {value: 3}} - result := MergeUnique(arr1, arr2) + result := mergeUnique(arr1, arr2) assert.Len(t, result, 3) assert.Contains(t, result, testObject{value: 1}) assert.Contains(t, result, testObject{value: 2}) @@ -27,14 +27,14 @@ func Test_MergeUniqueArraysWithoutDuplicates(t *testing.T) { func Test_MergeUniqueHandlesEmptyArrays(t *testing.T) { arr1 := []testObject{} arr2 := []testObject{} - result := MergeUnique(arr1, arr2) + result := mergeUnique(arr1, arr2) assert.Empty(t, result) } func Test_MergeUniqueHandlesOneEmptyArray(t *testing.T) { arr1 := []testObject{{value: 1}, {value: 2}} arr2 := []testObject{} - result := MergeUnique(arr1, arr2) + result := mergeUnique(arr1, arr2) assert.Len(t, result, 2) assert.Contains(t, result, testObject{value: 1}) assert.Contains(t, result, testObject{value: 2}) diff --git a/shared/management/types/networkmap_components.go b/shared/management/types/networkmap_components.go index fdb70f2f7..a708e99e1 100644 --- a/shared/management/types/networkmap_components.go +++ b/shared/management/types/networkmap_components.go @@ -12,9 +12,6 @@ import ( "github.com/netbirdio/netbird/client/ssh/auth" nbdns "github.com/netbirdio/netbird/dns" - resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" - routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" - nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/domain" ) @@ -27,22 +24,22 @@ type NetworkMapComponents struct { DNSSettings *DNSSettings CustomZoneDomain string - Peers map[string]*nbpeer.Peer - Groups map[string]*Group + Peers map[string]*ComponentPeer + Groups map[string]*ComponentGroup Policies []*Policy Routes []*route.Route NameServerGroups []*nbdns.NameServerGroup AllDNSRecords []nbdns.SimpleRecord AccountZones []nbdns.CustomZone ResourcePoliciesMap map[string][]*Policy - RoutersMap map[string]map[string]*routerTypes.NetworkRouter - NetworkResources []*resourceTypes.NetworkResource + RoutersMap map[string]map[string]*ComponentRouter + NetworkResources []*ComponentResource GroupIDToUserIDs map[string][]string AllowedUserIDs map[string]struct{} PostureFailedPeers map[string]map[string]struct{} - RouterPeers map[string]*nbpeer.Peer + RouterPeers map[string]*ComponentPeer // NetworkXIDToPublicID maps Network.ID (xid) → PublicID. // Consumed by the envelope encoder to @@ -78,15 +75,15 @@ func EmptyNetworkMapComponents(nm *NetworkMapComponents) *NetworkMapComponents { return nm } -func (c *NetworkMapComponents) GetPeerInfo(peerID string) *nbpeer.Peer { +func (c *NetworkMapComponents) GetPeerInfo(peerID string) *ComponentPeer { return c.Peers[peerID] } -func (c *NetworkMapComponents) GetRouterPeerInfo(peerID string) *nbpeer.Peer { +func (c *NetworkMapComponents) GetRouterPeerInfo(peerID string) *ComponentPeer { return c.RouterPeers[peerID] } -func (c *NetworkMapComponents) GetGroupInfo(groupID string) *Group { +func (c *NetworkMapComponents) GetGroupInfo(groupID string) *ComponentGroup { return c.Groups[groupID] } @@ -142,7 +139,7 @@ func (c *NetworkMapComponents) Calculate(ctx context.Context) *NetworkMap { includeIPv6 := false if p := c.Peers[targetPeerID]; p != nil { - includeIPv6 = p.SupportsIPv6() && p.IPv6.IsValid() + includeIPv6 = p.SupportsIPv6 && p.IPv6.IsValid() } routesUpdate := filterAndExpandRoutes(c.getRoutesToSync(targetPeerID, peersToConnect, peerGroups), includeIPv6) routesFirewallRules := c.getPeerRoutesFirewallRules(ctx, targetPeerID, includeIPv6) @@ -200,7 +197,7 @@ func (c *NetworkMapComponents) IsEmpty() bool { return c.empty } -func (c *NetworkMapComponents) getPeerConnectionResources(targetPeerID string) ([]*nbpeer.Peer, []*FirewallRule, map[string]map[string]struct{}, bool) { +func (c *NetworkMapComponents) getPeerConnectionResources(targetPeerID string) ([]*ComponentPeer, []*FirewallRule, map[string]map[string]struct{}, bool) { targetPeer := c.GetPeerInfo(targetPeerID) if targetPeer == nil { return nil, nil, nil, false @@ -220,7 +217,7 @@ func (c *NetworkMapComponents) getPeerConnectionResources(targetPeerID string) ( continue } - var sourcePeers, destinationPeers []*nbpeer.Peer + var sourcePeers, destinationPeers []*ComponentPeer var peerInSources, peerInDestinations bool if rule.SourceResource.Type == ResourceTypePeer && rule.SourceResource.ID != "" { @@ -303,13 +300,13 @@ func (c *NetworkMapComponents) getAllowedUserIDs() map[string]struct{} { return make(map[string]struct{}) } -func (c *NetworkMapComponents) connResourcesGenerator(targetPeer *nbpeer.Peer) (func(*PolicyRule, []*nbpeer.Peer, int), func() ([]*nbpeer.Peer, []*FirewallRule)) { +func (c *NetworkMapComponents) connResourcesGenerator(targetPeer *ComponentPeer) (func(*PolicyRule, []*ComponentPeer, int), func() ([]*ComponentPeer, []*FirewallRule)) { rulesExists := make(map[string]struct{}) peersExists := make(map[string]struct{}) rules := make([]*FirewallRule, 0) - peers := make([]*nbpeer.Peer, 0) + peers := make([]*ComponentPeer, 0) - return func(rule *PolicyRule, groupPeers []*nbpeer.Peer, direction int) { + return func(rule *PolicyRule, groupPeers []*ComponentPeer, direction int) { protocol := rule.Protocol if protocol == PolicyRuleProtocolNetbirdSSH { protocol = PolicyRuleProtocolTCP @@ -361,15 +358,15 @@ func (c *NetworkMapComponents) connResourcesGenerator(targetPeer *nbpeer.Peer) ( PortsJoined: portsJoined, }) } - }, func() ([]*nbpeer.Peer, []*FirewallRule) { + }, func() ([]*ComponentPeer, []*FirewallRule) { return peers, rules } } -func (c *NetworkMapComponents) getAllPeersFromGroups(groups []string, peerID string, sourcePostureChecksIDs []string) ([]*nbpeer.Peer, bool) { +func (c *NetworkMapComponents) getAllPeersFromGroups(groups []string, peerID string, sourcePostureChecksIDs []string) ([]*ComponentPeer, bool) { peerInGroups := false uniquePeerIDs := c.getUniquePeerIDsFromGroupsIDs(groups) - filteredPeers := make([]*nbpeer.Peer, 0, len(uniquePeerIDs)) + filteredPeers := make([]*ComponentPeer, 0, len(uniquePeerIDs)) for _, p := range uniquePeerIDs { peerInfo := c.GetPeerInfo(p) @@ -421,22 +418,22 @@ func (c *NetworkMapComponents) getUniquePeerIDsFromGroupsIDs(groups []string) [] return ids } -func (c *NetworkMapComponents) getPeerFromResource(resource Resource, peerID string) ([]*nbpeer.Peer, bool) { +func (c *NetworkMapComponents) getPeerFromResource(resource Resource, peerID string) ([]*ComponentPeer, bool) { if resource.ID == peerID { - return []*nbpeer.Peer{}, true + return []*ComponentPeer{}, true } peerInfo := c.GetPeerInfo(resource.ID) if peerInfo == nil { - return []*nbpeer.Peer{}, false + return []*ComponentPeer{}, false } - return []*nbpeer.Peer{peerInfo}, false + return []*ComponentPeer{peerInfo}, false } -func (c *NetworkMapComponents) filterPeersByLoginExpiration(aclPeers []*nbpeer.Peer) ([]*nbpeer.Peer, []*nbpeer.Peer) { - peersToConnect := make([]*nbpeer.Peer, 0, len(aclPeers)) - var expiredPeers []*nbpeer.Peer +func (c *NetworkMapComponents) filterPeersByLoginExpiration(aclPeers []*ComponentPeer) ([]*ComponentPeer, []*ComponentPeer) { + peersToConnect := make([]*ComponentPeer, 0, len(aclPeers)) + var expiredPeers []*ComponentPeer for _, p := range aclPeers { expired, _ := p.LoginExpired(c.AccountSettings.PeerLoginExpiration) @@ -518,7 +515,7 @@ func filterAndExpandRoutes(routes []*route.Route, includeIPv6 bool) []*route.Rou return filtered } -func (c *NetworkMapComponents) getRoutesToSync(peerID string, aclPeers []*nbpeer.Peer, peerGroups LookupMap) []*route.Route { +func (c *NetworkMapComponents) getRoutesToSync(peerID string, aclPeers []*ComponentPeer, peerGroups LookupMap) []*route.Route { routes, peerDisabledRoutes := c.getRoutingPeerRoutes(peerID) peerRoutesMembership := make(LookupMap) for _, r := range append(routes, peerDisabledRoutes...) { @@ -732,7 +729,7 @@ func (c *NetworkMapComponents) getRouteFirewallRules(ctx context.Context, peerID return fwRules } -func (c *NetworkMapComponents) getRulePeers(rule *PolicyRule, postureChecks []string, peerID string, distributionPeers map[string]struct{}) []*nbpeer.Peer { +func (c *NetworkMapComponents) getRulePeers(rule *PolicyRule, postureChecks []string, peerID string, distributionPeers map[string]struct{}) []*ComponentPeer { distPeersWithPolicy := make(map[string]struct{}) for _, id := range rule.Sources { group := c.GetGroupInfo(id) @@ -759,7 +756,7 @@ func (c *NetworkMapComponents) getRulePeers(rule *PolicyRule, postureChecks []st } } - distributionGroupPeers := make([]*nbpeer.Peer, 0, len(distPeersWithPolicy)) + distributionGroupPeers := make([]*ComponentPeer, 0, len(distPeersWithPolicy)) for pID := range distPeersWithPolicy { peerInfo := c.GetPeerInfo(pID) if peerInfo == nil { @@ -799,8 +796,8 @@ func (c *NetworkMapComponents) getNetworkResourcesRoutesToSync(peerID string) (b func (c *NetworkMapComponents) processResourcePolicies( peerID string, - resource *resourceTypes.NetworkResource, - networkRoutingPeers map[string]*routerTypes.NetworkRouter, + resource *ComponentResource, + networkRoutingPeers map[string]*ComponentRouter, addSourcePeers bool, allSourcePeers map[string]struct{}, ) []*route.Route { @@ -833,7 +830,7 @@ func (c *NetworkMapComponents) getResourcePolicyPeers(policy *Policy) []string { return c.getUniquePeerIDsFromGroupsIDs(policy.SourceGroups()) } -func (c *NetworkMapComponents) getNetworkResourcesRoutes(resource *resourceTypes.NetworkResource, peerID string, router *routerTypes.NetworkRouter) []*route.Route { +func (c *NetworkMapComponents) getNetworkResourcesRoutes(resource *ComponentResource, peerID string, router *ComponentRouter) []*route.Route { resourceAppliedPolicies := c.ResourcePoliciesMap[resource.ID] var routes []*route.Route @@ -847,7 +844,7 @@ func (c *NetworkMapComponents) getNetworkResourcesRoutes(resource *resourceTypes return routes } -func (c *NetworkMapComponents) networkResourceToRoute(resource *resourceTypes.NetworkResource, peer *nbpeer.Peer, router *routerTypes.NetworkRouter) *route.Route { +func (c *NetworkMapComponents) networkResourceToRoute(resource *ComponentResource, peer *ComponentPeer, router *ComponentRouter) *route.Route { r := &route.Route{ ID: route.ID(resource.ID + ":" + peer.ID), AccountID: resource.AccountID, @@ -861,7 +858,7 @@ func (c *NetworkMapComponents) networkResourceToRoute(resource *resourceTypes.Ne Description: resource.Description, } - if resource.Type == resourceTypes.Host || resource.Type == resourceTypes.Subnet { + if resource.Type == ComponentResourceHost || resource.Type == ComponentResourceSubnet { r.Network = resource.Prefix r.NetworkType = route.IPv4Network @@ -870,7 +867,7 @@ func (c *NetworkMapComponents) networkResourceToRoute(resource *resourceTypes.Ne } } - if resource.Type == resourceTypes.Domain { + if resource.Type == ComponentResourceDomain { domainList, err := domain.FromStringList([]string{resource.Domain}) if err == nil { r.Domains = domainList @@ -948,11 +945,11 @@ func (c *NetworkMapComponents) getPoliciesSourcePeers(policies []*Policy) map[st func (c *NetworkMapComponents) addNetworksRoutingPeers( networkResourcesRoutes []*route.Route, peerID string, - peersToConnect []*nbpeer.Peer, - expiredPeers []*nbpeer.Peer, + peersToConnect []*ComponentPeer, + expiredPeers []*ComponentPeer, isRouter bool, sourcePeers map[string]struct{}, -) []*nbpeer.Peer { +) []*ComponentPeer { networkRoutesPeers := make(map[string]struct{}, len(networkResourcesRoutes)) for _, r := range networkResourcesRoutes { @@ -1002,8 +999,8 @@ type FirewallRuleContext struct { 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() { +func AppendIPv6FirewallRule(rules []*FirewallRule, rulesExists map[string]struct{}, peer, targetPeer *ComponentPeer, rule *PolicyRule, rc FirewallRuleContext) []*FirewallRule { + if !peer.IPv6.IsValid() || !targetPeer.SupportsIPv6 || !targetPeer.IPv6.IsValid() { return rules } diff --git a/shared/management/types/networkmap_components_compact.go b/shared/management/types/networkmap_components_compact.go index b60f8bdb1..a1f53690d 100644 --- a/shared/management/types/networkmap_components_compact.go +++ b/shared/management/types/networkmap_components_compact.go @@ -2,9 +2,6 @@ package types import ( nbdns "github.com/netbirdio/netbird/dns" - resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" - routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" - nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/route" ) @@ -21,7 +18,7 @@ type NetworkMapComponentsCompact struct { DNSSettings *DNSSettings CustomZoneDomain string - AllPeers []*nbpeer.Peer + AllPeers []*ComponentPeer PeerIndexes []int RouterPeerIndexes []int @@ -34,8 +31,8 @@ type NetworkMapComponentsCompact struct { AllDNSRecords []nbdns.SimpleRecord AccountZones []nbdns.CustomZone - RoutersMap map[string]map[string]*routerTypes.NetworkRouter - NetworkResources []*resourceTypes.NetworkResource + RoutersMap map[string]map[string]*ComponentRouter + NetworkResources []*ComponentResource GroupIDToUserIDs map[string][]string AllowedUserIDs map[string]struct{} @@ -44,7 +41,7 @@ type NetworkMapComponentsCompact struct { func (c *NetworkMapComponents) ToCompact() *NetworkMapComponentsCompact { peerToIndex := make(map[string]int) - var allPeers []*nbpeer.Peer + var allPeers []*ComponentPeer for id, peer := range c.Peers { if _, exists := peerToIndex[id]; !exists { @@ -150,7 +147,7 @@ func (c *NetworkMapComponents) ToCompact() *NetworkMapComponentsCompact { } func (c *NetworkMapComponentsCompact) ToFull() *NetworkMapComponents { - peers := make(map[string]*nbpeer.Peer, len(c.PeerIndexes)) + peers := make(map[string]*ComponentPeer, len(c.PeerIndexes)) for _, idx := range c.PeerIndexes { if idx >= 0 && idx < len(c.AllPeers) { peer := c.AllPeers[idx] @@ -158,7 +155,7 @@ func (c *NetworkMapComponentsCompact) ToFull() *NetworkMapComponents { } } - routerPeers := make(map[string]*nbpeer.Peer, len(c.RouterPeerIndexes)) + routerPeers := make(map[string]*ComponentPeer, len(c.RouterPeerIndexes)) for _, idx := range c.RouterPeerIndexes { if idx >= 0 && idx < len(c.AllPeers) { peer := c.AllPeers[idx] @@ -166,7 +163,7 @@ func (c *NetworkMapComponentsCompact) ToFull() *NetworkMapComponents { } } - groups := make(map[string]*Group, len(c.Groups)) + groups := make(map[string]*ComponentGroup, len(c.Groups)) for id, gc := range c.Groups { peerIDs := make([]string, 0, len(gc.PeerIndexes)) for _, idx := range gc.PeerIndexes { @@ -174,7 +171,7 @@ func (c *NetworkMapComponentsCompact) ToFull() *NetworkMapComponents { peerIDs = append(peerIDs, c.AllPeers[idx].ID) } } - groups[id] = &Group{ + groups[id] = &ComponentGroup{ ID: id, Name: gc.Name, Peers: peerIDs, diff --git a/version/version.go b/version/version.go index 074305bd6..b92e5ac7e 100644 --- a/version/version.go +++ b/version/version.go @@ -71,6 +71,30 @@ func NetbirdCommit() string { return revision } +// sanitizeVersion removes anything after the pre-release tag (e.g., "-dev", "-alpha", etc.) +func sanitizeVersion(version string) string { + parts := strings.Split(version, "-") + return parts[0] +} + +// MeetsMinVersion checks if the peer's version meets or exceeds the minimum required version +func MeetsMinVersion(minVer, peerVer string) (bool, error) { + peerVer = sanitizeVersion(peerVer) + minVer = sanitizeVersion(minVer) + + peerNBVer, err := v.NewVersion(peerVer) + if err != nil { + return false, err + } + + constraints, err := v.NewConstraint(">= " + minVer) + if err != nil { + return false, err + } + + return constraints.Check(peerNBVer), nil +} + // IsDevelopmentVersion reports whether the given version string identifies // a non-release / development build. It is the single source of truth for // "is this a dev build" checks across the codebase; use it instead of diff --git a/version/version_test.go b/version/version_test.go index cdba6b804..f05bcbd87 100644 --- a/version/version_test.go +++ b/version/version_test.go @@ -1,6 +1,10 @@ package version -import "testing" +import ( + "testing" + + "github.com/stretchr/testify/assert" +) func TestIsDevelopmentVersion(t *testing.T) { tests := []struct { @@ -26,3 +30,68 @@ func TestIsDevelopmentVersion(t *testing.T) { }) } } + +func TestMeetsMinVersion(t *testing.T) { + tests := []struct { + name string + minVer string + peerVer string + want bool + wantErr bool + }{ + { + name: "Peer version greater than min version", + minVer: "0.26.0", + peerVer: "0.60.1", + want: true, + wantErr: false, + }, + { + name: "Peer version equals min version", + minVer: "1.0.0", + peerVer: "1.0.0", + want: true, + wantErr: false, + }, + { + name: "Peer version less than min version", + minVer: "1.0.0", + peerVer: "0.9.9", + want: false, + wantErr: false, + }, + { + name: "Peer version with pre-release tag greater than min version", + minVer: "1.0.0", + peerVer: "1.0.1-alpha", + want: true, + wantErr: false, + }, + { + name: "Invalid peer version format", + minVer: "1.0.0", + peerVer: "dev", + want: false, + wantErr: true, + }, + { + name: "Invalid min version format", + minVer: "invalid.version", + peerVer: "1.0.0", + want: false, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := MeetsMinVersion(tt.minVer, tt.peerVer) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + assert.Equal(t, tt.want, got) + }) + } +} From d4a4418969a2c11b01b13d1ecf9a239273c2f8bb Mon Sep 17 00:00:00 2001 From: Bethuel Mmbaga Date: Thu, 23 Jul 2026 12:41:13 +0300 Subject: [PATCH 2/9] [misc] Simplify enterprise bootstrap (#6869) --- infrastructure_files/getting-started-enterprise.sh | 7 ------- infrastructure_files/migrate-to-enterprise.sh | 7 ------- 2 files changed, 14 deletions(-) diff --git a/infrastructure_files/getting-started-enterprise.sh b/infrastructure_files/getting-started-enterprise.sh index 135440180..31f3f1c13 100755 --- a/infrastructure_files/getting-started-enterprise.sh +++ b/infrastructure_files/getting-started-enterprise.sh @@ -234,9 +234,6 @@ init_environment() { NETBIRD_LICENSE_KEY=$(read_secret "Enter license key (input hidden)") - GHCR_USERNAME="netbirdExtAccess1" - GHCR_TOKEN=$(read_secret "Enter GHCR token (input hidden)") - POSTGRES_USER="netbird" POSTGRES_DB="netbird" POSTGRES_PASSWORD=$(rand_secret) @@ -263,10 +260,6 @@ init_environment() { install -m 600 /dev/null config.yaml render_config_yaml >> config.yaml - echo "Logging in to ghcr.io ..." - printf '%s' "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USERNAME" --password-stdin - unset GHCR_TOKEN - echo "" echo "Pulling images ..." $DOCKER_COMPOSE_COMMAND pull diff --git a/infrastructure_files/migrate-to-enterprise.sh b/infrastructure_files/migrate-to-enterprise.sh index 8e8a41114..d4c59699b 100755 --- a/infrastructure_files/migrate-to-enterprise.sh +++ b/infrastructure_files/migrate-to-enterprise.sh @@ -490,8 +490,6 @@ init_migration() { echo "" echo "Step 1: Image swap (community → Enterprise). License key required." NB_LICENSE_KEY=$(read_secret " License key") - GHCR_USERNAME="netbirdExtAccess1" - GHCR_TOKEN=$(read_secret " GHCR token (input hidden)") # Step 2 — optional echo "" @@ -588,11 +586,6 @@ apply_changes() { fi } >> "$ENV_FILE" - echo "" - echo "Logging in to ghcr.io ..." - printf '%s' "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USERNAME" --password-stdin - unset GHCR_TOKEN - echo "" echo "Pulling enterprise images ..." $DOCKER_COMPOSE_COMMAND pull From 0936918d2411f6cdc6dc4733f29f2ee81fb5310b Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Thu, 23 Jul 2026 20:32:32 +0900 Subject: [PATCH 3/9] [management, proxy] add Kimi (Moonshot AI) provider to Agent Network (#6853) --- .github/workflows/agent-network-e2e.yml | 3 ++ e2e/agentnetwork/chat_test.go | 49 ++++++++++++++----- e2e/agentnetwork/guardrail_test.go | 6 ++- e2e/harness/client.go | 13 ++++- .../modules/agentnetwork/catalog/catalog.go | 41 ++++++++++++++++ .../llm/pricing/defaults_pricing.yaml | 24 +++++++++ 6 files changed, 121 insertions(+), 15 deletions(-) diff --git a/.github/workflows/agent-network-e2e.yml b/.github/workflows/agent-network-e2e.yml index d78e3bbd3..bf4868871 100644 --- a/.github/workflows/agent-network-e2e.yml +++ b/.github/workflows/agent-network-e2e.yml @@ -51,6 +51,9 @@ jobs: # token (and URL, for gateways) is unset, so partial coverage is fine. OPENAI_TOKEN: ${{ secrets.E2E_OPENAI_TOKEN }} ANTHROPIC_TOKEN: ${{ secrets.E2E_ANTHROPIC_TOKEN }} + # Moonshot AI platform key (platform.kimi.ai); drives both Kimi wire + # shapes (OpenAI /v1 and Anthropic /anthropic) through kimi_api. + KIMI_TOKEN: ${{ secrets.E2E_KIMI_TOKEN }} VERCEL_URL: ${{ secrets.E2E_VERCEL_URL }} VERCEL_TOKEN: ${{ secrets.E2E_VERCEL_TOKEN }} OPENROUTER_URL: ${{ secrets.E2E_OPENROUTER_URL }} diff --git a/e2e/agentnetwork/chat_test.go b/e2e/agentnetwork/chat_test.go index a928d1265..90c3766ec 100644 --- a/e2e/agentnetwork/chat_test.go +++ b/e2e/agentnetwork/chat_test.go @@ -20,14 +20,15 @@ import ( // covers whatever credentials are present (source ~/.llm-keys locally / set the // Actions secrets in CI). type providerCase struct { - name string - catalogID string - upstream string - apiKey string - model string // body model (chat/messages) or path model@version (vertex) - kind string // harness.WireChat, harness.WireMessages, or harness.WireVertex - project string // vertex only: GCP project for the rawPredict path - region string // vertex only: GCP region for the rawPredict path + name string + catalogID string + upstream string + apiKey string + model string // body model (chat/messages) or path model@version (vertex) + kind string // harness.WireChat, harness.WireMessages, or harness.WireVertex + project string // vertex only: GCP project for the rawPredict path + region string // vertex only: GCP region for the rawPredict path + pathPrefix string // base-URL path prefix the agent carries (e.g. "/anthropic" for Kimi) } // availableProviders builds the matrix from the provider env vars that are set. @@ -39,6 +40,24 @@ func availableProviders() []providerCase { if k := os.Getenv("ANTHROPIC_TOKEN"); k != "" { ps = append(ps, providerCase{name: "anthropic", catalogID: "anthropic_api", upstream: "https://api.anthropic.com", apiKey: k, model: "claude-haiku-4-5", kind: harness.WireMessages}) } + if k := os.Getenv("KIMI_TOKEN"); k != "" { + // Kimi (Moonshot AI) serves two body shapes from the same key: OpenAI + // Chat Completions on the bare host (/v1/...) and the Anthropic + // Messages API under the /anthropic path prefix (the endpoint + // Moonshot's Claude Code guide uses). The provider keeps the bare + // default upstream and the AGENT carries the /anthropic prefix in + // its base URL — exactly the documented Claude Code / Kimi CLI + // setup (ANTHROPIC_BASE_URL=https:///anthropic) — so one + // provider serves both shapes and the prefix rides through to + // Moonshot. Run the Anthropic shape, the flagship Claude Code path; + // the OpenAI wire shape is covered live by the other chat-shaped + // matrix providers, and Kimi-over-chat passed with kimi-k3 before + // the single-model constraint surfaced (run #73 on the kimi feature + // branch). The platform serves this account exactly ONE model — + // kimi-k3 (kimi-k2-thinking and even kimi-latest return + // resource_not_found_error on both surfaces). + ps = append(ps, providerCase{name: "kimi", catalogID: "kimi_api", upstream: "https://api.moonshot.ai", apiKey: k, model: "kimi-k3", kind: harness.WireMessages, pathPrefix: "/anthropic"}) + } if k, u := os.Getenv("VERCEL_TOKEN"), os.Getenv("VERCEL_URL"); k != "" && u != "" { ps = append(ps, providerCase{name: "vercel", catalogID: "vercel_ai_gateway", upstream: u, apiKey: k, model: "openai/gpt-4o-mini", kind: harness.WireChat}) } @@ -84,12 +103,18 @@ func availableProviders() []providerCase { } } - // Bedrock: path-routed, bearer auth. Model is a cross-region inference - // profile id (distinct string from the first-party Anthropic case). + // Bedrock: path-routed, bearer auth. Model is the FULL cross-region + // inference-profile id exactly as AWS issues it — region-family prefix + // plus the date/version suffix. A bare or wrong-region id makes Bedrock + // reject the request with "The provided model identifier is invalid" + // before any inference runs. The proxy normalizes this id to the catalog + // key (anthropic.claude-haiku-4-5) for routing/pricing/allowlists. + // Defaults pair eu-central-1 with the eu.* profile; AWS_REGION overrides + // the region and the prefix follows its family. if k := os.Getenv("AWS_BEARER_TOKEN_BEDROCK"); k != "" { region := os.Getenv("AWS_REGION") if region == "" { - region = "us-east-1" + region = "eu-central-1" } // A valid Bedrock inference-profile id (region prefix + date + version), // overridable per account. `global.` profiles can be invoked from any @@ -246,7 +271,7 @@ func TestProvidersMatrix(t *testing.T) { case harness.WireBedrock: c, b, cerr = cl.Bedrock(ctx, settings.Endpoint, proxyIP, pc.model, "Reply with exactly: pong", sessionID) default: - c, b, cerr = cl.Chat(ctx, settings.Endpoint, proxyIP, pc.kind, pc.model, "Reply with exactly: pong", sessionID) + c, b, cerr = cl.ChatPrefixed(ctx, settings.Endpoint, proxyIP, pc.pathPrefix, pc.kind, pc.model, "Reply with exactly: pong", sessionID) } if cerr == nil { code, body = c, b diff --git a/e2e/agentnetwork/guardrail_test.go b/e2e/agentnetwork/guardrail_test.go index 1e4b222f0..6a2487a88 100644 --- a/e2e/agentnetwork/guardrail_test.go +++ b/e2e/agentnetwork/guardrail_test.go @@ -52,7 +52,9 @@ func catalogModel(pc providerCase) string { func disallowedModel(pc providerCase) string { switch pc.kind { case harness.WireBedrock: - return "us.anthropic.claude-opus-4-8" + // Same profile prefix as the allowed model so only the model name + // differs; the guardrail must deny it before it reaches AWS. + return strings.SplitN(pc.model, ".", 2)[0] + ".anthropic.claude-opus-4-8" case harness.WireVertex: return "claude-opus-4-8@20250101" default: @@ -72,7 +74,7 @@ func sendModel(ctx context.Context, t *testing.T, cl *harness.Client, endpoint, case harness.WireVertex: code, _, err = cl.Vertex(ctx, endpoint, proxyIP, pc.project, pc.region, model, "Reply with exactly: pong", "") default: - code, _, err = cl.Chat(ctx, endpoint, proxyIP, pc.kind, model, "Reply with exactly: pong", "") + code, _, err = cl.ChatPrefixed(ctx, endpoint, proxyIP, pc.pathPrefix, pc.kind, model, "Reply with exactly: pong", "") } require.NoError(t, err, "request must reach the proxy for %s", pc.name) return code diff --git a/e2e/harness/client.go b/e2e/harness/client.go index 4c9983e4a..2ffcf653d 100644 --- a/e2e/harness/client.go +++ b/e2e/harness/client.go @@ -239,6 +239,17 @@ const ( // the wire shape: WireChat (OpenAI) or WireMessages (Anthropic). A non-empty // sessionID is sent as the universal x-session-id header the proxy records. func (cl *Client) Chat(ctx context.Context, endpoint, proxyIP, kind, model, prompt, sessionID string) (int, string, error) { + return cl.ChatPrefixed(ctx, endpoint, proxyIP, "", kind, model, prompt, sessionID) +} + +// ChatPrefixed is Chat with a base-URL path prefix prepended to the wire +// path, mirroring agents whose base URL carries a shape-selecting prefix that +// rides through to the upstream — e.g. Claude Code against a Kimi provider +// sets ANTHROPIC_BASE_URL=https:///anthropic so the proxy forwards +// /anthropic/v1/messages to Moonshot's Anthropic surface while the provider's +// upstream URL stays the bare https://api.moonshot.ai. Empty prefix is plain +// Chat. +func (cl *Client) ChatPrefixed(ctx context.Context, endpoint, proxyIP, pathPrefix, kind, model, prompt, sessionID string) (int, string, error) { var path, body string var headers []string switch kind { @@ -250,7 +261,7 @@ func (cl *Client) Chat(ctx context.Context, endpoint, proxyIP, kind, model, prom path = "/v1/chat/completions" body = fmt.Sprintf(`{"model":%q,"messages":[{"role":"user","content":%q}]}`, model, prompt) } - return cl.post(ctx, endpoint, proxyIP, path, body, withSessionID(headers, sessionID)) + return cl.post(ctx, endpoint, proxyIP, pathPrefix+path, body, withSessionID(headers, sessionID)) } // Vertex issues an Anthropic-on-Vertex rawPredict POST over the tunnel. Unlike diff --git a/management/internals/modules/agentnetwork/catalog/catalog.go b/management/internals/modules/agentnetwork/catalog/catalog.go index f82cffae6..f82e94bf3 100644 --- a/management/internals/modules/agentnetwork/catalog/catalog.go +++ b/management/internals/modules/agentnetwork/catalog/catalog.go @@ -420,6 +420,47 @@ var providers = []Provider{ {ID: "mistral-embed", Label: "Mistral Embed", InputPer1k: 0.0001, OutputPer1k: 0, ContextWindow: 8192}, }, }, + { + ID: "kimi_api", + Kind: KindProvider, + Name: "Kimi (Moonshot AI) API", + Description: "Kimi K3 / K2 models via the Moonshot AI platform", + DefaultHost: "api.moonshot.ai", + AuthHeaderName: "Authorization", + AuthHeaderTemplate: "Bearer ${API_KEY}", + DefaultContentType: "application/json", + BrandColor: "#1A1A2E", + // ParserID empty on purpose: Moonshot serves two body shapes on + // the same host and key, and the proxy's URL sniffer dispatches + // both (same pattern as Bifrost). /v1/chat/completions matches + // OpenAIParser; the Anthropic-compatible endpoint the official + // Claude Code guide uses (/anthropic/v1/messages) contains + // "/v1/messages" and matches AnthropicParser. Pinning "openai" + // here would misparse the Claude Code path — the primary way + // teams consume Kimi for coding today. Both endpoints accept the + // same Moonshot key via Authorization: Bearer (Claude Code's + // ANTHROPIC_AUTH_TOKEN rides that header too). + // + // api.moonshot.ai is the international platform; mainland-China + // accounts live on api.moonshot.cn with separate billing — + // operators there override the host on the provider record. The + // kimi.com subscription coding endpoint (api.kimi.com/coding, + // model id "k3") is account-bound seat licensing rather than a + // meterable platform key, so it's deliberately not the default. + ParserID: "", + // Pricing per Moonshot's platform rates at K3 launch (July 2026): + // $3/$15 per MTok with $0.30 cached input, flat across the 1M-token + // window. kimi-k3 is the ONLY model the platform serves newer + // accounts — K2-era ids (kimi-k2-thinking) and even the kimi-latest + // alias return resource_not_found_error, verified live 2026-07-21 — + // so it's the only catalog entry. Grandfathered accounts with K2 + // access can still type those ids on the provider's model rows. + // The consumer app's "K3 Swarm Max" mode is not an API SKU, so it + // doesn't appear here. + Models: []Model{ + {ID: "kimi-k3", Label: "Kimi K3", InputPer1k: 0.003, OutputPer1k: 0.015, ContextWindow: 1000000}, + }, + }, { ID: "litellm_proxy", Kind: KindGateway, diff --git a/proxy/internal/llm/pricing/defaults_pricing.yaml b/proxy/internal/llm/pricing/defaults_pricing.yaml index cd5c64fbf..3fba8fe3f 100644 --- a/proxy/internal/llm/pricing/defaults_pricing.yaml +++ b/proxy/internal/llm/pricing/defaults_pricing.yaml @@ -161,6 +161,16 @@ openai: input_per_1k: 0.0001 output_per_1k: 0 + # Kimi / Moonshot AI (kimi_api) — OpenAI-compatible /v1 endpoint. Moonshot + # reports cache hits OpenAI-style when present; cached input is 10% of + # input ($0.30 vs $3.00 per MTok). kimi-k3 is the only model the platform + # serves newer accounts (K2-era ids and kimi-latest 404), matching the + # management catalog. + kimi-k3: + input_per_1k: 0.003 + output_per_1k: 0.015 + cached_input_per_1k: 0.0003 + anthropic: # Claude 4.x family — cache reads ≈10% of input, cache writes ≈125% of input. # Pricing source: Anthropic's current published rates per million tokens, @@ -206,6 +216,20 @@ anthropic: cache_read_per_1k: 0.0001 cache_creation_per_1k: 0.00125 + # Kimi / Moonshot AI (kimi_api) via the Anthropic-compatible endpoint + # (/anthropic/v1/messages — the official Claude Code setup). Same rates + # as the OpenAI-shape entry above. "kimi-k3[1m]" is the model id some + # Claude Code guides set for the 1M-context alias; priced identically so + # cost metering doesn't silently skip those requests. + kimi-k3: + input_per_1k: 0.003 + output_per_1k: 0.015 + cache_read_per_1k: 0.0003 + "kimi-k3[1m]": + input_per_1k: 0.003 + output_per_1k: 0.015 + cache_read_per_1k: 0.0003 + bedrock: # AWS Bedrock model ids, normalised by the request parser (cross-region # inference-profile prefix + version/throughput suffix stripped), e.g. From 6d15d0729ae5e7d8812f9e981ab0ae1cc78c1a09 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:37:52 +0900 Subject: [PATCH 4/9] [client] Clear stale UDP checksum in eBPF XDP proxy after port rewrite (#6861) --- client/internal/ebpf/ebpf/bpf_bpfeb.o | Bin 14032 -> 14408 bytes client/internal/ebpf/ebpf/bpf_bpfel.o | Bin 14032 -> 14408 bytes client/internal/ebpf/ebpf/src/dns_fwd.c | 3 +++ client/internal/ebpf/ebpf/src/wg_proxy.c | 6 ++++++ 4 files changed, 9 insertions(+) diff --git a/client/internal/ebpf/ebpf/bpf_bpfeb.o b/client/internal/ebpf/ebpf/bpf_bpfeb.o index 6e9cda44a653f1d726a95d1cab30c898967402f6..7433ad740ac150d19705f49d188d055d3c4c8cc2 100644 GIT binary patch literal 14408 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 literal 14032 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 a^%GEpYeN5gdest == GENERAL_DNS_PORT && ip->daddr == dns_ip) { udp->dest = dns_port; + // Clear the now-stale checksum; zero means "not computed" for IPv4. + udp->check = 0; return XDP_PASS; } if (udp->source == dns_port && ip->saddr == dns_ip) { udp->source = GENERAL_DNS_PORT; + udp->check = 0; return XDP_PASS; } diff --git a/client/internal/ebpf/ebpf/src/wg_proxy.c b/client/internal/ebpf/ebpf/src/wg_proxy.c index 88fea65cf..5e7474928 100644 --- a/client/internal/ebpf/ebpf/src/wg_proxy.c +++ b/client/internal/ebpf/ebpf/src/wg_proxy.c @@ -50,5 +50,11 @@ int xdp_wg_proxy(struct iphdr *ip, struct udphdr *udp) { __be16 new_dst_port = htons(proxy_port); udp->dest = new_dst_port; udp->source = new_src_port; + + // The ports are covered by the UDP checksum. This is an IPv4 loopback hop + // and the payload is already integrity-protected, so clear the checksum (a + // zero UDP checksum means "not computed" for IPv4) rather than leave a + // stale value the kernel would drop as UDP_CSUM. + udp->check = 0; return XDP_PASS; } From 3358138cccb4f5d65692984334bcd20da0146c81 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:48:16 +0200 Subject: [PATCH 5/9] [client] Fix flaky Test_ConnectPeers busy-loop handshake wait (#6871) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes `Test_ConnectPeers` in `client/iface` is flaky in CI, timing out with `waiting for peer handshake timeout after 30s`. The wait loop polled `getPeer()` in a tight busy-loop with no sleep (a `select` with a `default` branch), pegging a CPU core. The peers run userspace WireGuard (stdnet transport), so the spin starved the wireguard-go goroutines that actually process the handshake, making the 30s wait flaky under CI load. Poll on a 500ms ticker instead so the CPU is yielded between checks, and check the handshake state before waiting. Same logic, no busy-spin. ## Issue ticket number and link N/A — CI flakiness fix. ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [x] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature ## Documentation - [x] Documentation is **not needed** for this change (test-only fix) --- View with [code]smith Autofix with [code]smith Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled. ## Summary by CodeRabbit * **Tests** * Improved peer connection test waiting behavior to avoid busy spinning. * Added a timeout and periodic checks, with clearer failure handling when a handshake does not complete. --- client/iface/iface_test.go | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/client/iface/iface_test.go b/client/iface/iface_test.go index 8ff2bbb54..43b3d8168 100644 --- a/client/iface/iface_test.go +++ b/client/iface/iface_test.go @@ -569,17 +569,17 @@ func Test_ConnectPeers(t *testing.T) { if err != nil { t.Fatal(err) } - // todo: investigate why in some tests execution we need 30s + // The peers use userspace WireGuard (stdnet transport). A tight busy-loop + // here starves the wireguard-go goroutines that process the handshake, so + // poll on a ticker instead and yield the CPU between checks. WireGuard also + // only retries a lost handshake initiation every REKEY_TIMEOUT (5s), which + // is why the overall wait can occasionally stretch to tens of seconds. timeout := 30 * time.Second timeoutChannel := time.After(timeout) + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() for { - select { - case <-timeoutChannel: - t.Fatalf("waiting for peer handshake timeout after %s", timeout.String()) - default: - } - peer, gpErr := getPeer(peer1ifaceName, peer2Key.PublicKey().String()) if gpErr != nil { t.Fatal(gpErr) @@ -588,6 +588,12 @@ func Test_ConnectPeers(t *testing.T) { t.Log("peers successfully handshake") break } + + select { + case <-timeoutChannel: + t.Fatalf("waiting for peer handshake timeout after %s", timeout.String()) + case <-ticker.C: + } } } From 46568f7af8b950b87a96b69be701888b4597b1f6 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:40:54 +0200 Subject: [PATCH 6/9] [client] Reconcile routed allowed IPs when a lazy connection goes idle (#6863) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes Under lazy connections, when a routing peer goes idle its WireGuard peer is torn down and re-created with a wake endpoint by the activity listener, carrying only the overlay /32 (`peerCfg.AllowedIPs`). The routed subnet prefixes are dropped from the device on the Connected→Idle transition. They are meant to be restored by the route watcher, which reacts to the peer's status change and calls `recalculateRoutes` → `AddAllowedIP`. Two things prevent that from healing the peer: - `AddAllowedIP` uses `update_only`, which is a silent no-op (no error) when the peer does not exist. While the peer is being torn down and re-armed with its wake endpoint, it is briefly absent, so a re-add that lands in that window is lost. - The allowed-IP refcounter only calls its add function on a prefix's 0→1 transition. The routed prefix stays referenced across the idle cycle, so once the device entry is gone the refcounter does not re-push it on its own, and nothing retries. As a result, traffic to the routed subnet is black-holed while the peer is idle. Because the wake endpoint only fires when a packet matches the peer's AllowedIPs, a packet to the subnet is dropped before reaching the wake endpoint, so it cannot wake the peer. The peer only recovers when woken by other means (e.g. a ping to its overlay IP). ## Approach This change keeps the existing Connected→Idle transition as-is and reconciles the AllowedIPs afterwards, avoiding any additional locking on the transition path. The peer is torn down and re-armed with its wake endpoint as today; the routed prefixes are then re-applied from the route manager's allowed-IP refcounter once the wake endpoint has been (re)armed. A single add-only method, `ReconcilePeerAllowedIPs(peerKey)`, re-applies every routed prefix currently tracked for the peer in the refcounter (the authoritative store; it already covers static, dynamic and dnsinterceptor routes). It runs whenever the peer's wake endpoint is (re)created in the lazy manager — every point where the activity listener builds it with the overlay /32 only: - **initial registration** (`AddPeer`, cold start): the route manager may have already pushed the peer's routes before the wake endpoint existed, so those `AddAllowedIP` calls no-op'd; the reconcile installs them on the freshly created wake endpoint. - **the two paths into idle** (`DeactivatePeer` on a remote GOAWAY, `onPeerInactivityTimedOut` on local inactivity): the peer is torn down and re-armed, so the routed prefixes must be re-applied. In every case the routed prefixes end up on the wake endpoint, so traffic to a routed subnet can wake the peer. Arming the wake endpoint and reconciling are wrapped in a single `armActivityListener` helper so the two always happen together. New helper: `refcounter.Counter.KeysMatching(pred)` to enumerate a peer's prefixes under the counter lock. Note on scope: the reconcile restores what the refcounter tracks. All routed AllowedIPs currently go through it, so this covers the routed-prefix case; it does not attempt to reconcile AllowedIPs installed outside the refcounter. The Idle→Connected (wake) path does not need this: the peer is not removed there (the listener close leaves it in place and only the endpoint is updated), so a concurrent `AddAllowedIP` lands normally. ## Testing Reproduced deterministically in a local dev setup (userspace client, `NB_WG_KERNEL_DISABLED=true`, `B_LAZY_CONN_INACTIVITY_THRESHOLD=1` inactivity threshold 1 min). A temporary 30s sleep in the tear-down → re-arm window widens the race so the route watcher's async `AddAllowedIP` reliably lands while the peer is absent and no-ops (the sleep is a test aid, not part of the change): - **without the reconcile:** after the peer goes idle, a ping to any routed IP — both a pre-existing route and one added during the window — black-holes; the peer never wakes. - **with the reconcile:** the same ping wakes the peer and passes. Added unit tests: `ReconcilePeerAllowedIPs` (re-applies all of a peer's tracked prefixes, scoped to that peer) and `refcounter.Counter.KeysMatching`. Note: `netbird status -d` is not a reliable signal for this — `AddPeerStateRoute` records the route regardless of whether the underlying `AddAllowedIP` no-op'd, so it reflects the route manager's intent rather than device state. The reliable signal is functional (ping the subnet from idle). ## Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [x] Created tests that fail without the change (unit tests for the reconcile + `KeysMatching`) ## Documentation - [x] Documentation is **not needed** for this change (internal client behavior, no API / gRPC / CLI / flag change) --- View with Codesmith Autofix with Codesmith Need help on this PR? Tag /codesmith with what you need. Autofix is disabled. ## Summary by CodeRabbit * **Bug Fixes** * Routed IP assignments are automatically reconciled and restored whenever a peer’s lazy wake endpoint is armed or re-armed. * Routed allowed IPs are re-applied after inactivity transitions and monitoring re-initialization. * If reconciliation can’t be performed, the client safely skips it; if reconciliation encounters issues, failures are logged without stopping connection monitoring. --- client/internal/conn_mgr.go | 11 +++ client/internal/engine.go | 6 ++ client/internal/lazyconn/manager/manager.go | 40 ++++++++- client/internal/routemanager/manager.go | 25 ++++++ client/internal/routemanager/mock.go | 5 ++ .../internal/routemanager/reconcile_test.go | 90 +++++++++++++++++++ .../routemanager/refcounter/refcounter.go | 20 +++++ .../refcounter/refcounter_test.go | 47 ++++++++++ 8 files changed, 241 insertions(+), 3 deletions(-) create mode 100644 client/internal/routemanager/reconcile_test.go create mode 100644 client/internal/routemanager/refcounter/refcounter_test.go diff --git a/client/internal/conn_mgr.go b/client/internal/conn_mgr.go index 754ce37a3..7a591f60c 100644 --- a/client/internal/conn_mgr.go +++ b/client/internal/conn_mgr.go @@ -49,11 +49,21 @@ type ConnMgr struct { // engine.syncMsgMux; all other reads stay under engine.syncMsgMux only. lazyConnMgrMu sync.RWMutex + // reconcileRoutedIPs re-applies a peer's routed allowed IPs after its lazy wake endpoint is + // (re)armed (Mode A at arm time). Injected by the engine; nil disables the reconcile. + reconcileRoutedIPs func(peerKey string) error + wg sync.WaitGroup lazyCtx context.Context lazyCtxCancel context.CancelFunc } +// SetRoutedIPsReconciler injects the callback used to re-apply a peer's routed allowed IPs when +// its lazy wake endpoint is (re)armed. Must be called before the lazy manager starts. +func (e *ConnMgr) SetRoutedIPsReconciler(fn func(peerKey string) error) { + e.reconcileRoutedIPs = fn +} + func NewConnMgr(engineConfig *EngineConfig, statusRecorder *peer.Status, peerStore *peerstore.Store, iface lazyconn.WGIface) *ConnMgr { e := &ConnMgr{ peerStore: peerStore, @@ -291,6 +301,7 @@ func (e *ConnMgr) Close() { func (e *ConnMgr) initLazyManager(engineCtx context.Context) { cfg := manager.Config{ InactivityThreshold: inactivityThresholdEnv(), + ReconcileAllowedIPs: e.reconcileRoutedIPs, } e.lazyConnMgrMu.Lock() diff --git a/client/internal/engine.go b/client/internal/engine.go index e1b03e878..617892e43 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -663,6 +663,12 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) iceCfg := e.createICEConfig() e.connMgr = NewConnMgr(e.config, e.statusRecorder, e.peerStore, wgIface) + e.connMgr.SetRoutedIPsReconciler(func(peerKey string) error { + if e.routeManager == nil { + return nil + } + return e.routeManager.ReconcilePeerAllowedIPs(peerKey) + }) e.connMgr.Start(e.ctx) // Wire DNS-time lazy-connection warm-up now that the connection manager diff --git a/client/internal/lazyconn/manager/manager.go b/client/internal/lazyconn/manager/manager.go index 3868e37e8..b7424bb2f 100644 --- a/client/internal/lazyconn/manager/manager.go +++ b/client/internal/lazyconn/manager/manager.go @@ -29,6 +29,11 @@ type managedPeer struct { type Config struct { InactivityThreshold *time.Duration + // ReconcileAllowedIPs re-applies a peer's routed allowed IPs after its wake endpoint is + // armed. The activity listener creates the wake peer with the overlay /32 only; without the + // routed prefixes WireGuard would not steer subnet-bound traffic to the wake endpoint, so an + // idle routing peer could never be woken by that traffic. Optional; nil disables the reconcile. + ReconcileAllowedIPs func(peerKey string) error } // Manager manages lazy connections @@ -56,6 +61,9 @@ type Manager struct { peerToHAGroups map[string][]route.HAUniqueID // peer ID -> HA groups they belong to haGroupToPeers map[route.HAUniqueID][]string // HA group -> peer IDs in the group routesMu sync.RWMutex + + // reconcileAllowedIPs re-applies a peer's routed allowed IPs after its wake endpoint is armed. + reconcileAllowedIPs func(peerKey string) error } // NewManager creates a new lazy connection manager @@ -73,6 +81,7 @@ func NewManager(config Config, engineCtx context.Context, peerStore *peerstore.S activityManager: activity.NewManager(wgIface), peerToHAGroups: make(map[string][]route.HAUniqueID), haGroupToPeers: make(map[route.HAUniqueID][]string), + reconcileAllowedIPs: config.ReconcileAllowedIPs, } if wgIface.IsUserspaceBind() { @@ -201,7 +210,7 @@ func (m *Manager) AddPeer(peerCfg lazyconn.PeerConfig) (bool, error) { return false, nil } - if err := m.activityManager.MonitorPeerActivity(peerCfg); err != nil { + if err := m.armActivityListener(peerCfg); err != nil { return false, err } @@ -288,7 +297,7 @@ func (m *Manager) DeactivatePeer(peerID peerid.ConnID) { m.inactivityManager.RemovePeer(mp.peerCfg.PublicKey) - if err := m.activityManager.MonitorPeerActivity(*mp.peerCfg); err != nil { + if err := m.armActivityListener(*mp.peerCfg); err != nil { mp.peerCfg.Log.Errorf("failed to create activity monitor: %v", err) return } @@ -465,6 +474,31 @@ func (m *Manager) close() { } // shouldDeferIdleForHA checks if peer should stay connected due to HA group requirements +// armRoutedAllowedIPs re-applies the peer's routed allowed IPs onto its freshly armed wake +// endpoint. The activity listener creates the wake peer with the overlay /32 only, so without +// this the routed prefixes would be missing and traffic to a routed subnet could not wake the +// idle routing peer. It is a no-op when no reconciler is configured. +// armActivityListener (re)arms the peer's wake endpoint via the activity manager and then +// re-applies its routed allowed IPs, so traffic to a routed subnet can wake an idle routing +// peer. The routed prefixes must be re-applied after the wake endpoint exists because the +// listener creates it with the overlay /32 only. +func (m *Manager) armActivityListener(peerCfg lazyconn.PeerConfig) error { + if err := m.activityManager.MonitorPeerActivity(peerCfg); err != nil { + return err + } + m.armRoutedAllowedIPs(&peerCfg) + return nil +} + +func (m *Manager) armRoutedAllowedIPs(peerCfg *lazyconn.PeerConfig) { + if m.reconcileAllowedIPs == nil { + return + } + if err := m.reconcileAllowedIPs(peerCfg.PublicKey); err != nil { + peerCfg.Log.Errorf("failed to reconcile routed allowed IPs on wake endpoint: %v", err) + } +} + func (m *Manager) shouldDeferIdleForHA(inactivePeers map[string]struct{}, peerID string) bool { m.routesMu.RLock() defer m.routesMu.RUnlock() @@ -577,7 +611,7 @@ func (m *Manager) onPeerInactivityTimedOut(peerIDs map[string]struct{}) { mp.peerCfg.Log.Infof("start activity monitor") - if err := m.activityManager.MonitorPeerActivity(*mp.peerCfg); err != nil { + if err := m.armActivityListener(*mp.peerCfg); err != nil { mp.peerCfg.Log.Errorf("failed to create activity monitor: %v", err) continue } diff --git a/client/internal/routemanager/manager.go b/client/internal/routemanager/manager.go index 66b24cc5a..ef69b81a4 100644 --- a/client/internal/routemanager/manager.go +++ b/client/internal/routemanager/manager.go @@ -61,6 +61,7 @@ type Manager interface { InitialRouteRange() []string SetFirewall(firewall.Manager) error SetDNSForwarderPort(port uint16) + ReconcilePeerAllowedIPs(peerKey string) error Stop(stateManager *statemanager.Manager) } @@ -232,6 +233,30 @@ func (m *DefaultManager) setupRefCounters(useNoop bool) { ) } +// ReconcilePeerAllowedIPs re-applies every routed allowed IP currently tracked for the peer +// onto the WireGuard device. The allowed-IP refcounter only calls its AddFunc (which pushes to +// the device) on a prefix's 0->1 transition, so a peer whose device entry was rebuilt without a +// matching refcounter change — e.g. a lazy connection cycling through idle->wake, which recreates +// the WireGuard peer with the overlay /32 only — ends up missing routed prefixes the refcounter +// still considers installed, and nothing retries. Calling this when the peer's WireGuard entry is +// (re)created restores convergence. It is add-only and idempotent: AddAllowedIP is update-only, so +// prefixes are re-added to an existing peer and an absent peer is left untouched. +func (m *DefaultManager) ReconcilePeerAllowedIPs(peerKey string) error { + if m.allowedIPsRefCounter == nil { + return nil + } + + return m.allowedIPsRefCounter.ReapplyMatching( + func(out string) bool { return out == peerKey }, + func(prefix netip.Prefix) error { + if err := m.wgInterface.AddAllowedIP(peerKey, prefix); err != nil { + return fmt.Errorf("add allowed IP %s for peer %s: %w", prefix, peerKey, err) + } + return nil + }, + ) +} + // Init sets up the routing func (m *DefaultManager) Init() error { m.routeSelector = m.initSelector() diff --git a/client/internal/routemanager/mock.go b/client/internal/routemanager/mock.go index 937314995..c1620b24c 100644 --- a/client/internal/routemanager/mock.go +++ b/client/internal/routemanager/mock.go @@ -112,6 +112,11 @@ func (m *MockManager) SetFirewall(firewall.Manager) error { func (m *MockManager) SetDNSForwarderPort(port uint16) { } +// ReconcilePeerAllowedIPs mock implementation of ReconcilePeerAllowedIPs from Manager interface +func (m *MockManager) ReconcilePeerAllowedIPs(peerKey string) error { + return nil +} + // Stop mock implementation of Stop from Manager interface func (m *MockManager) Stop(stateManager *statemanager.Manager) { if m.StopFunc != nil { diff --git a/client/internal/routemanager/reconcile_test.go b/client/internal/routemanager/reconcile_test.go new file mode 100644 index 000000000..2a8a4dc10 --- /dev/null +++ b/client/internal/routemanager/reconcile_test.go @@ -0,0 +1,90 @@ +//go:build !windows + +package routemanager + +import ( + "net" + "net/netip" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.zx2c4.com/wireguard/tun/netstack" + + "github.com/netbirdio/netbird/client/iface/device" + "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/internal/routemanager/refcounter" +) + +// reconcileWGMock is a minimal iface.WGIface that only records AddAllowedIP calls; every other +// method is an inert stub because ReconcilePeerAllowedIPs exercises none of them. +type reconcileWGMock struct { + mu sync.Mutex + adds map[string][]netip.Prefix +} + +func (m *reconcileWGMock) AddAllowedIP(peerKey string, allowedIP netip.Prefix) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.adds == nil { + m.adds = map[string][]netip.Prefix{} + } + m.adds[peerKey] = append(m.adds[peerKey], allowedIP) + return nil +} + +func (m *reconcileWGMock) added(peerKey string) []netip.Prefix { + m.mu.Lock() + defer m.mu.Unlock() + return m.adds[peerKey] +} + +func (m *reconcileWGMock) RemoveAllowedIP(string, netip.Prefix) error { return nil } +func (m *reconcileWGMock) Name() string { return "utun-test" } +func (m *reconcileWGMock) Address() wgaddr.Address { return wgaddr.Address{} } +func (m *reconcileWGMock) ToInterface() *net.Interface { return nil } +func (m *reconcileWGMock) IsUserspaceBind() bool { return false } +func (m *reconcileWGMock) GetFilter() device.PacketFilter { return nil } +func (m *reconcileWGMock) GetDevice() *device.FilteredDevice { return nil } +func (m *reconcileWGMock) GetNet() *netstack.Net { return nil } + +// TestReconcilePeerAllowedIPs verifies the declarative reconcile re-applies every routed prefix +// tracked for the peer (self-heal, independent of refcount level) and stays scoped to that peer. +func TestReconcilePeerAllowedIPs(t *testing.T) { + wg := &reconcileWGMock{} + m := &DefaultManager{wgInterface: wg} + m.allowedIPsRefCounter = refcounter.New[netip.Prefix, string, string]( + func(_ netip.Prefix, peerKey string) (string, error) { return peerKey, nil }, + func(netip.Prefix, string) error { return nil }, + ) + + peerA1 := netip.MustParsePrefix("10.0.0.0/24") + peerA2 := netip.MustParsePrefix("10.1.0.0/24") + peerB1 := netip.MustParsePrefix("10.2.0.0/24") + + for prefix, peer := range map[netip.Prefix]string{peerA1: "peerA", peerA2: "peerA", peerB1: "peerB"} { + _, err := m.allowedIPsRefCounter.Increment(prefix, peer) + require.NoError(t, err) + } + // Extra reference: reconcile must still re-apply the prefix even though its refcount never + // hit 0 again (the exact case the plain incremental path skips). + _, err := m.allowedIPsRefCounter.Increment(peerA1, "peerA") + require.NoError(t, err) + + require.NoError(t, m.ReconcilePeerAllowedIPs("peerA")) + + assert.ElementsMatch(t, []netip.Prefix{peerA1, peerA2}, wg.added("peerA"), + "reconcile must re-apply all routed prefixes of the peer") + assert.Empty(t, wg.added("peerB"), "reconcile must not touch another peer's prefixes") +} + +// TestReconcilePeerAllowedIPsNoCounter verifies reconcile is a safe no-op before the refcounter is +// set up. +func TestReconcilePeerAllowedIPsNoCounter(t *testing.T) { + wg := &reconcileWGMock{} + m := &DefaultManager{wgInterface: wg} + + require.NoError(t, m.ReconcilePeerAllowedIPs("peerA")) + assert.Empty(t, wg.added("peerA")) +} diff --git a/client/internal/routemanager/refcounter/refcounter.go b/client/internal/routemanager/refcounter/refcounter.go index 27a724f50..917120275 100644 --- a/client/internal/routemanager/refcounter/refcounter.go +++ b/client/internal/routemanager/refcounter/refcounter.go @@ -94,6 +94,26 @@ func (rm *Counter[Key, I, O]) Get(key Key) (Ref[O], bool) { return ref, ok } +// ReapplyMatching calls apply for every key whose stored Out satisfies pred, holding the +// counter lock for the whole pass. Running apply under the lock keeps it atomic with respect +// to Increment/Decrement: a prefix dropped to zero is removed from the map (and had its +// RemoveFunc called) before this pass observes it, so a stale key can never be re-applied. +// pred and apply are invoked under the lock, so they must not call back into the counter. +func (rm *Counter[Key, I, O]) ReapplyMatching(pred func(out O) bool, apply func(key Key) error) error { + rm.mu.Lock() + defer rm.mu.Unlock() + + var merr *multierror.Error + for key, ref := range rm.refCountMap { + if pred(ref.Out) { + if err := apply(key); err != nil { + merr = multierror.Append(merr, err) + } + } + } + return nberrors.FormatErrorOrNil(merr) +} + // Increment increments the reference count for the given key. // If this is the first reference to the key, the AddFunc is called. func (rm *Counter[Key, I, O]) Increment(key Key, in I) (Ref[O], error) { diff --git a/client/internal/routemanager/refcounter/refcounter_test.go b/client/internal/routemanager/refcounter/refcounter_test.go new file mode 100644 index 000000000..79a99c388 --- /dev/null +++ b/client/internal/routemanager/refcounter/refcounter_test.go @@ -0,0 +1,47 @@ +package refcounter + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestReapplyMatching verifies ReapplyMatching invokes apply for exactly the keys whose stored +// Out satisfies the predicate (no duplicates for multiply-referenced keys) — the primitive +// ReconcilePeerAllowedIPs relies on to re-apply a single peer's routed prefixes. +func TestReapplyMatching(t *testing.T) { + rc := New[netip.Prefix, string, string]( + func(_ netip.Prefix, peerKey string) (string, error) { return peerKey, nil }, + func(netip.Prefix, string) error { return nil }, + ) + + peerA1 := netip.MustParsePrefix("10.0.0.0/24") + peerA2 := netip.MustParsePrefix("10.1.0.0/24") + peerB1 := netip.MustParsePrefix("10.2.0.0/24") + + for prefix, peer := range map[netip.Prefix]string{peerA1: "peerA", peerA2: "peerA", peerB1: "peerB"} { + _, err := rc.Increment(prefix, peer) + require.NoError(t, err) + } + // a second reference must not make the key applied twice + _, err := rc.Increment(peerA1, "peerA") + require.NoError(t, err) + + var applied []netip.Prefix + err = rc.ReapplyMatching( + func(out string) bool { return out == "peerA" }, + func(key netip.Prefix) error { applied = append(applied, key); return nil }, + ) + require.NoError(t, err) + assert.ElementsMatch(t, []netip.Prefix{peerA1, peerA2}, applied) + + var none []netip.Prefix + err = rc.ReapplyMatching( + func(out string) bool { return out == "missing" }, + func(key netip.Prefix) error { none = append(none, key); return nil }, + ) + require.NoError(t, err) + assert.Empty(t, none) +} From e13bcdbd44be8375954b0757f3f56c11deb030e1 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Fri, 24 Jul 2026 13:10:53 +0200 Subject: [PATCH 7/9] [client] Fetch FreeBSD port files from GitHub mirror instead of cgit (#6880) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cgit.freebsd.org now sits behind an Anubis anti-bot challenge and intermittently returns an HTML challenge page with HTTP 200 instead of the requested file, breaking the FreeBSD port release job. Fetch the port Makefile and distinfo from the official freebsd-ports GitHub mirror, retry transient failures, and fail loudly if HTML is returned instead of the expected file. ## Describe your changes ## Issue ticket number and link ## Stack ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ --- View with [code]smith Autofix with [code]smith Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled. ## Summary by CodeRabbit * **Bug Fixes** * Improved reliability when retrieving FreeBSD port metadata during release and issue preparation. * Added automatic retries and HTTPS-only redirect handling for safer downloads. * Validates fetched content to detect unexpected HTML responses and avoids processing invalid data. * Updated port metadata retrieval to use a more reliable mirror, improving version extraction and the resulting comparisons and regenerated release information. --- release_files/freebsd-port-diff.sh | 28 +++++++++++++++++++----- release_files/freebsd-port-issue-body.sh | 10 ++++++--- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/release_files/freebsd-port-diff.sh b/release_files/freebsd-port-diff.sh index 6ffa141be..77ea55520 100755 --- a/release_files/freebsd-port-diff.sh +++ b/release_files/freebsd-port-diff.sh @@ -3,8 +3,8 @@ # FreeBSD Port Diff Generator for NetBird # # This script generates the diff file required for submitting a FreeBSD port update. -# It works on macOS, Linux, and FreeBSD by fetching files from FreeBSD cgit and -# computing checksums from the Go module proxy. +# It works on macOS, Linux, and FreeBSD by fetching files from the FreeBSD ports +# GitHub mirror and computing checksums from the Go module proxy. # # Usage: ./freebsd-port-diff.sh [new_version] # Example: ./freebsd-port-diff.sh 0.60.7 @@ -14,7 +14,7 @@ set -e GITHUB_REPO="netbirdio/netbird" -PORTS_CGIT_BASE="https://cgit.freebsd.org/ports/plain/security/netbird" +PORTS_MIRROR_BASE="https://raw.githubusercontent.com/freebsd/freebsd-ports/main/security/netbird" GO_PROXY="https://proxy.golang.org/github.com/netbirdio/netbird/@v" OUTPUT_DIR="${OUTPUT_DIR:-.}" AWK_FIRST_FIELD='{print $1}' @@ -30,10 +30,17 @@ fetch_all_tags() { fetch_current_ports_version() { echo "Fetching current version from FreeBSD ports..." >&2 - curl -sL "${PORTS_CGIT_BASE}/Makefile" 2>/dev/null | \ + local makefile version + makefile=$(fetch_ports_file "Makefile") || return 1 + version=$(echo "$makefile" | \ grep -E "^DISTVERSION=" | \ sed 's/DISTVERSION=[[:space:]]*//' | \ - tr -d '\t ' + tr -d '\t ') + if [[ -z "$version" ]]; then + echo "Error: Could not extract DISTVERSION from ports Makefile" >&2 + return 1 + fi + echo "$version" return 0 } @@ -45,7 +52,16 @@ fetch_latest_github_release() { fetch_ports_file() { local filename="$1" - curl -sL "${PORTS_CGIT_BASE}/${filename}" 2>/dev/null + local content + if ! content=$(curl -fsL --proto '=https' --proto-redir '=https' --retry 3 "${PORTS_MIRROR_BASE}/${filename}" 2>/dev/null); then + echo "Error: Could not fetch ${filename} from ${PORTS_MIRROR_BASE}" >&2 + return 1 + fi + if [[ "$content" == \<* ]]; then + echo "Error: Received HTML instead of ${filename} from ${PORTS_MIRROR_BASE}" >&2 + return 1 + fi + printf '%s' "$content" return 0 } diff --git a/release_files/freebsd-port-issue-body.sh b/release_files/freebsd-port-issue-body.sh index 1c23dbbbe..1f0c8a567 100755 --- a/release_files/freebsd-port-issue-body.sh +++ b/release_files/freebsd-port-issue-body.sh @@ -9,18 +9,22 @@ # Example: ./freebsd-port-issue-body.sh 0.56.0 0.59.1 # # If no versions are provided, the script will: -# - Fetch OLD version from FreeBSD ports cgit (current version in ports tree) +# - Fetch OLD version from the FreeBSD ports GitHub mirror (current version in ports tree) # - Fetch NEW version from latest NetBird GitHub release tag set -e GITHUB_REPO="netbirdio/netbird" -PORTS_CGIT_URL="https://cgit.freebsd.org/ports/plain/security/netbird/Makefile" +PORTS_MAKEFILE_URL="https://raw.githubusercontent.com/freebsd/freebsd-ports/main/security/netbird/Makefile" fetch_current_ports_version() { echo "Fetching current version from FreeBSD ports..." >&2 local makefile_content - makefile_content=$(curl -sL "$PORTS_CGIT_URL" 2>/dev/null) + makefile_content=$(curl -fsL --proto '=https' --proto-redir '=https' --retry 3 "$PORTS_MAKEFILE_URL" 2>/dev/null) || makefile_content="" + if [[ "$makefile_content" == \<* ]]; then + echo "Error: Received HTML instead of Makefile from ${PORTS_MAKEFILE_URL}" >&2 + return 1 + fi if [[ -z "$makefile_content" ]]; then echo "Error: Could not fetch Makefile from FreeBSD ports" >&2 return 1 From b65ec8b68a6a1ab8aee162a7b9e5147c0375af68 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:53:53 +0200 Subject: [PATCH 8/9] [client] Restores lost backup.Reset from #4935 (#6883) ## Describe your changes Restore the management gRPC client's backoff reset after a stream connects. This is a regression: PR #4935 originally added this reset; it was then lost in commit `58daa674e` during the `withMgmtStream` refactor (Sync/Job unification). Compared to before the reset was done AFTER receiveEvents, whereas now it's done before. Now should cover for Sync and Job (which didn't exist in #4935. Hold for long living stream that breaks. Reset prevents two things 1. long living conns going wrong from waiting a long backoff time window before to drive reconn 2. past MaxElapsedTime (3months) long lived conns failures from being treated as "unrecoverable" (hence triggering an full restart of the engine) ## Issue ticket number and link Internal support case (customer agents lost data-plane connectivity during a management maintenance/release window). No public issue. Regression introduced in commit `58daa674e`, which removed the `backOff.Reset()` added by PR #4935. ## Stack ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) Internal reconnection-behavior fix in the management gRPC client. No public NetBird CLI, API, or configuration surface changes. ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: N/A --- View with [code]smith Autofix with [code]smith Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled. ## Summary by CodeRabbit * **Bug Fixes** * Improved management stream retry behavior. * Retry delays now reset after a stream is successfully established, helping subsequent connection attempts recover more quickly. --- shared/management/client/grpc.go | 41 ++++++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/shared/management/client/grpc.go b/shared/management/client/grpc.go index 78d28e3a3..bd2d0da1f 100644 --- a/shared/management/client/grpc.go +++ b/shared/management/client/grpc.go @@ -187,16 +187,16 @@ func (c *GrpcClient) ready() bool { // Sync wraps the real client's Sync endpoint call and takes care of retries and encryption/decryption of messages // Blocking request. The result will be sent via msgHandler callback function func (c *GrpcClient) Sync(ctx context.Context, sysInfo *system.Info, msgHandler func(msg *proto.SyncResponse) error) error { - return c.withMgmtStream(ctx, func(ctx context.Context, serverPubKey wgtypes.Key) error { - return c.handleSyncStream(ctx, serverPubKey, sysInfo, msgHandler) + return c.withMgmtStream(ctx, func(ctx context.Context, serverPubKey wgtypes.Key, backOff backoff.BackOff) error { + return c.handleSyncStream(ctx, serverPubKey, sysInfo, msgHandler, backOff) }) } // Job wraps the real client's Job endpoint call and takes care of retries and encryption/decryption of messages // Blocking request. The result will be sent via msgHandler callback function func (c *GrpcClient) Job(ctx context.Context, msgHandler func(msg *proto.JobRequest) *proto.JobResponse) error { - return c.withMgmtStream(ctx, func(ctx context.Context, serverPubKey wgtypes.Key) error { - return c.handleJobStream(ctx, serverPubKey, msgHandler) + return c.withMgmtStream(ctx, func(ctx context.Context, serverPubKey wgtypes.Key, backOff backoff.BackOff) error { + return c.handleJobStream(ctx, serverPubKey, msgHandler, backOff) }) } @@ -204,7 +204,7 @@ func (c *GrpcClient) Job(ctx context.Context, msgHandler func(msg *proto.JobRequ // It takes care of retries, connection readiness, and fetching server public key. func (c *GrpcClient) withMgmtStream( ctx context.Context, - handler func(ctx context.Context, serverPubKey wgtypes.Key) error, + handler func(ctx context.Context, serverPubKey wgtypes.Key, backOff backoff.BackOff) error, ) error { backOff := defaultBackoff(ctx) operation := func() error { @@ -224,7 +224,7 @@ func (c *GrpcClient) withMgmtStream( return err } - return handler(ctx, *serverPubKey) + return handler(ctx, *serverPubKey, backOff) } err := backoff.Retry(operation, backOff) @@ -239,6 +239,7 @@ func (c *GrpcClient) handleJobStream( ctx context.Context, serverPubKey wgtypes.Key, msgHandler func(msg *proto.JobRequest) *proto.JobResponse, + backOff backoff.BackOff, ) error { ctx, cancelStream := context.WithCancel(ctx) defer cancelStream() @@ -256,6 +257,19 @@ func (c *GrpcClient) handleJobStream( log.Debug("job stream handshake sent successfully") + // The stream is up, so reset the backoff. This matters for two reasons, + // both caused by the backoff lib not resetting its state on a successful + // connection: + // 1. Without a reset, after a connect followed by an error the next retry + // starts from the accumulated (large) interval instead of retrying + // promptly, delaying reconnection. + // 2. Worse, once the accumulated elapsed time exceeds MaxElapsedTime, the + // next stream error makes NextBackOff() return Stop, so the retry loop + // exits immediately. That error is then mislabeled unrecoverable and + // bubbles up to trigger a full engine restart / data-plane teardown + // instead of a silent reconnection. + backOff.Reset() + // Main loop: receive, process, respond for { jobReq, err := c.receiveJobRequest(ctx, stream, serverPubKey) @@ -371,7 +385,7 @@ func (c *GrpcClient) sendJobResponse( return nil } -func (c *GrpcClient) handleSyncStream(ctx context.Context, serverPubKey wgtypes.Key, sysInfo *system.Info, msgHandler func(msg *proto.SyncResponse) error) error { +func (c *GrpcClient) handleSyncStream(ctx context.Context, serverPubKey wgtypes.Key, sysInfo *system.Info, msgHandler func(msg *proto.SyncResponse) error, backOff backoff.BackOff) error { ctx, cancelStream := context.WithCancel(ctx) defer cancelStream() @@ -390,6 +404,19 @@ func (c *GrpcClient) handleSyncStream(ctx context.Context, serverPubKey wgtypes. c.notifyConnected() c.setSyncStreamConnected() + // The stream is up, so reset the backoff. This matters for two reasons, + // both caused by the backoff lib not resetting its state on a successful + // connection: + // 1. Without a reset, after a connect followed by an error the next retry + // starts from the accumulated (large) interval instead of retrying + // promptly, delaying reconnection. + // 2. Worse, once the accumulated elapsed time exceeds MaxElapsedTime, the + // next stream error makes NextBackOff() return Stop, so the retry loop + // exits immediately. That error is then mislabeled unrecoverable and + // bubbles up to trigger a full engine restart / data-plane teardown + // instead of a silent reconnection. + backOff.Reset() + // blocking until error err = c.receiveUpdatesEvents(stream, serverPubKey, msgHandler) if err != nil { From 1e5b0a5c892750c36d3f53e2d825632a81ed98d1 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:23:22 +0200 Subject: [PATCH 9/9] [client] Make Test_ConnectPeers deterministic under Docker/eBPF kernel / Darwin CI (#6884) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes Make `Test_ConnectPeers` deterministic. Two issues, both surfaced once the privileged suite moved into a `--privileged` Docker container (#6425): 1. The peers used `getLocalIP()` as their WireGuard endpoint, i.e. the host's routable NIC IP (the docker bridge IP `172.17.0.2` in CI). That address might not hairpin reliably inside the container, so the handshake intermittently timed out (flaky). Use loopback (`127.1.0.x`) instead — always self-reachable. 2. On a Linux runner with the WG kernel module the iface uses the eBPF proxy factory. Its manager is a singleton with one shared XDP program + settings map, so bringing up the two ifaces makes the second factory overwrite the first's `wg_port`/`proxy_port` and the handshake is dropped. The test is incompatible with the eBPF factory, so disable it via `NB_DISABLE_EBPF_WG_PROXY` (peers then handshake directly over loopback). Running the suite across all three modes (eBPF / UDP proxy / ICE bind) would need a larger refactor. Also fixes a typo in the `ErrSharedSockStopped` message (`socked` → `socket`). ## Issue ticket number and link No public issue — CI flakiness follow-up to #6871 on `Test_ConnectPeers` (https://github.com/netbirdio/netbird/blob/main/client/iface/iface_test.go). ## Stack ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) Test-only change (plus a log-string typo). No public API, CLI, config, or behavior change. ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: N/A --- View with [code]smith Autofix with [code]smith Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled. ## Summary by CodeRabbit * **Bug Fixes** * Corrected the “shared socket stopped” error message for clearer output. * **Tests** * Improved peer connection test reliability in privileged CI by disabling the eBPF WireGuard proxy and using fixed loopback UDP endpoints for deterministic setup. --- client/iface/iface_test.go | 38 ++++++-------------------------------- sharedsock/sock_linux.go | 2 +- 2 files changed, 7 insertions(+), 33 deletions(-) diff --git a/client/iface/iface_test.go b/client/iface/iface_test.go index 43b3d8168..89c8cd16e 100644 --- a/client/iface/iface_test.go +++ b/client/iface/iface_test.go @@ -464,6 +464,8 @@ func Test_RemovePeer(t *testing.T) { } func Test_ConnectPeers(t *testing.T) { + t.Setenv("NB_DISABLE_EBPF_WG_PROXY", "true") + peer1ifaceName := fmt.Sprintf("utun%d", WgIntNumber+400) peer1wgIP := netip.MustParsePrefix("10.99.99.17/30") peer1Key, _ := wgtypes.GeneratePrivateKey() @@ -505,12 +507,8 @@ func Test_ConnectPeers(t *testing.T) { t.Fatal(err) } - localIP, err := getLocalIP() - if err != nil { - t.Fatal(err) - } - - peer1endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP, peer1wgPort)) + localIP1 := "127.0.0.1" + peer1endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP1, peer1wgPort)) if err != nil { t.Fatal(err) } @@ -546,7 +544,8 @@ func Test_ConnectPeers(t *testing.T) { t.Fatal(err) } - peer2endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP, peer2wgPort)) + localIP2 := "127.0.0.1" + peer2endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP2, peer2wgPort)) if err != nil { t.Fatal(err) } @@ -621,28 +620,3 @@ func getPeer(ifaceName, peerPubKey string) (wgtypes.Peer, error) { } return wgtypes.Peer{}, fmt.Errorf("peer not found") } - -func getLocalIP() (string, error) { - // Get all interfaces - addrs, err := net.InterfaceAddrs() - if err != nil { - return "", err - } - - for _, addr := range addrs { - ipNet, ok := addr.(*net.IPNet) - if !ok { - continue - } - if ipNet.IP.IsLoopback() { - continue - } - - if ipNet.IP.To4() == nil { - continue - } - return ipNet.IP.String(), nil - } - - return "", fmt.Errorf("no local IP found") -} diff --git a/sharedsock/sock_linux.go b/sharedsock/sock_linux.go index 4855e1aed..150e8a722 100644 --- a/sharedsock/sock_linux.go +++ b/sharedsock/sock_linux.go @@ -24,7 +24,7 @@ import ( ) // ErrSharedSockStopped indicates that shared socket has been stopped -var ErrSharedSockStopped = fmt.Errorf("shared socked stopped") +var ErrSharedSockStopped = fmt.Errorf("shared socket stopped") // SharedSocket is a net.PacketConn that initiates two raw sockets (ipv4 and ipv6) and listens to UDP packets filtered // by BPF instructions (e.g., IncomingSTUNFilter that checks and sends only STUN packets to the listeners (ReadFrom)).