diff --git a/management/server/groups/manager.go b/management/server/groups/manager.go index c9a877d6f..893be1e5a 100644 --- a/management/server/groups/manager.go +++ b/management/server/groups/manager.go @@ -109,7 +109,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, group.EventMetaResource(types.TwinNetworkResource(networkResource))) } return event, nil @@ -133,7 +133,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, group.EventMetaResource(types.TwinNetworkResource(networkResource))) } return event, nil diff --git a/management/server/peer.go b/management/server/peer.go index fd217c6df..579ff2708 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -406,7 +406,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) } diff --git a/management/server/peer/peer.go b/management/server/peer/peer.go index 39022d095..80c77592c 100644 --- a/management/server/peer/peer.go +++ b/management/server/peer/peer.go @@ -13,13 +13,14 @@ import ( "github.com/netbirdio/netbird/management/server/util" "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" ) // Peer capability constants mirror the proto enum values. const ( - PeerCapabilitySourcePrefixes int32 = 1 - PeerCapabilityIPv6Overlay int32 = 2 - PeerCapabilityComponentNetworkMap int32 = 3 + PeerCapabilitySourcePrefixes = nmdata.PeerCapabilitySourcePrefixes + PeerCapabilityIPv6Overlay = nmdata.PeerCapabilityIPv6Overlay + PeerCapabilityComponentNetworkMap = nmdata.PeerCapabilityComponentNetworkMap ) // Peer represents a machine connected to the network. 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/store/sql_store_get_account_test.go b/management/server/store/sql_store_get_account_test.go index 56f2a6c41..686839b1f 100644 --- a/management/server/store/sql_store_get_account_test.go +++ b/management/server/store/sql_store_get_account_test.go @@ -13,7 +13,6 @@ import ( "github.com/stretchr/testify/require" nbdns "github.com/netbirdio/netbird/dns" - "github.com/netbirdio/netbird/management/server/integration_reference" resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" networkTypes "github.com/netbirdio/netbird/management/server/networks/types" @@ -21,6 +20,7 @@ import ( "github.com/netbirdio/netbird/management/server/posture" "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/route" + "github.com/netbirdio/netbird/shared/management/integration_reference" ) // TestGetAccount_LoadsCustomDomains verifies GetAccount populates account.Domains. diff --git a/management/server/types/account_networkmapdata.go b/management/server/types/account_networkmapdata.go index 5ec15aecd..50757b048 100644 --- a/management/server/types/account_networkmapdata.go +++ b/management/server/types/account_networkmapdata.go @@ -87,7 +87,7 @@ func (a *Account) toNetworkMapData( nmd.NameServerGroups = append(nmd.NameServerGroups, twinNSG(nsg)) } for _, res := range a.NetworkResources { - nmd.NetworkResources = append(nmd.NetworkResources, twinNetworkResource(res)) + nmd.NetworkResources = append(nmd.NetworkResources, TwinNetworkResource(res)) } for _, pc := range a.PostureChecks { if pc != nil { @@ -274,7 +274,7 @@ func TwinRoute(r *nbroute.Route) *nmdata.Route { return twinRoute(r) } -func twinNetworkResource(r *resourceTypes.NetworkResource) *nmdata.NetworkResource { +func TwinNetworkResource(r *resourceTypes.NetworkResource) *nmdata.NetworkResource { if r == nil { return nil } diff --git a/management/server/types/aliases.go b/management/server/types/aliases.go index 8246b58e2..452a2746d 100644 --- a/management/server/types/aliases.go +++ b/management/server/types/aliases.go @@ -2,40 +2,25 @@ package types import ( "context" - "math/rand" - "net" - "net/netip" nbpeer "github.com/netbirdio/netbird/management/server/peer" nbroute "github.com/netbirdio/netbird/route" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" sharedtypes "github.com/netbirdio/netbird/shared/management/types" ) // Type aliases for types relocated to shared/management/types so that the // client-side compute path can depend on them -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 -type Policy = sharedtypes.Policy -type PolicyUpdateOperation = sharedtypes.PolicyUpdateOperation - -type PolicyRule = sharedtypes.PolicyRule -type PolicyUpdateOperationType = sharedtypes.PolicyUpdateOperationType type PolicyTrafficActionType = sharedtypes.PolicyTrafficActionType type PolicyRuleProtocolType = sharedtypes.PolicyRuleProtocolType -type PolicyRuleDirection = sharedtypes.PolicyRuleDirection type RulePortRange = sharedtypes.RulePortRange -type Resource = sharedtypes.Resource type ResourceType = sharedtypes.ResourceType type RouteFirewallRule = sharedtypes.RouteFirewallRule @@ -52,19 +37,16 @@ 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 -) - // Function forwarders preserve types.X(...) call sites that previously // resolved to package-local funcs. Plain forwarders (not var aliases) keep // the symbol immutable and allow the inliner to flatten the call. +func ParseRuleString(rule string) (PolicyRuleProtocolType, RulePortRange, error) { + return sharedtypes.ParseRuleString(rule) +} + func PolicyRuleImpliesLegacySSH(rule *PolicyRule) bool { - return sharedtypes.PolicyRuleImpliesLegacySSH(rule) + return nmdata.PolicyRuleImpliesLegacySSH(twinRule(rule)) } // ExpandPortsAndRanges / AppendIPv6FirewallRule / GenerateRouteFirewallRules @@ -87,35 +69,16 @@ func GenerateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule return sharedtypes.GenerateRouteFirewallRules(ctx, twinRoute(route), twinRule(rule), TwinPeers(groupPeers), direction, includeIPv6) } -func AllocateIPv6Subnet(r *rand.Rand) net.IPNet { - return sharedtypes.AllocateIPv6Subnet(r) -} - -func NewNetwork() *Network { - return sharedtypes.NewNetwork() -} - -func AllocatePeerIP(prefix netip.Prefix, takenIps []netip.Addr) (netip.Addr, error) { - return sharedtypes.AllocatePeerIP(prefix, takenIps) -} - -func AllocateRandomPeerIP(prefix netip.Prefix) (netip.Addr, error) { - return sharedtypes.AllocateRandomPeerIP(prefix) -} - -func AllocateRandomPeerIPv6(prefix netip.Prefix) (netip.Addr, error) { - return sharedtypes.AllocateRandomPeerIPv6(prefix) -} - -func ParseRuleString(rule string) (PolicyRuleProtocolType, RulePortRange, error) { - return sharedtypes.ParseRuleString(rule) -} - const ( FirewallRuleDirectionIN = sharedtypes.FirewallRuleDirectionIN FirewallRuleDirectionOUT = sharedtypes.FirewallRuleDirectionOUT ) +const ( + AllowedIPsFormat = sharedtypes.AllowedIPsFormat + AllowedIPsV6Format = sharedtypes.AllowedIPsV6Format +) + const ( ResourceTypePeer = sharedtypes.ResourceTypePeer ResourceTypeDomain = sharedtypes.ResourceTypeDomain @@ -135,15 +98,3 @@ const ( PolicyRuleProtocolICMP = sharedtypes.PolicyRuleProtocolICMP PolicyRuleProtocolNetbirdSSH = sharedtypes.PolicyRuleProtocolNetbirdSSH ) - -const ( - PolicyRuleFlowDirect = sharedtypes.PolicyRuleFlowDirect - PolicyRuleFlowBidirect = sharedtypes.PolicyRuleFlowBidirect -) - -const ( - DefaultRuleName = sharedtypes.DefaultRuleName - DefaultRuleDescription = sharedtypes.DefaultRuleDescription - DefaultPolicyName = sharedtypes.DefaultPolicyName - DefaultPolicyDescription = sharedtypes.DefaultPolicyDescription -) diff --git a/shared/management/types/dns_settings.go b/management/server/types/dns_settings.go similarity index 100% rename from shared/management/types/dns_settings.go rename to management/server/types/dns_settings.go diff --git a/shared/management/types/group.go b/management/server/types/group.go similarity index 94% rename from shared/management/types/group.go rename to management/server/types/group.go index e6e285e62..ac0a2a7f2 100644 --- a/shared/management/types/group.go +++ b/management/server/types/group.go @@ -1,8 +1,8 @@ package types import ( - "github.com/netbirdio/netbird/management/server/integration_reference" - "github.com/netbirdio/netbird/management/server/networks/resources/types" + "github.com/netbirdio/netbird/shared/management/integration_reference" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" ) const ( @@ -68,7 +68,7 @@ func (g *Group) EventMeta() map[string]any { return map[string]any{"name": g.Name} } -func (g *Group) EventMetaResource(resource *types.NetworkResource) map[string]any { +func (g *Group) EventMetaResource(resource *nmdata.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} } diff --git a/management/server/types/legacynmap/aliases.go b/management/server/types/legacynmap/aliases.go index 9e5cceb36..a0162243d 100644 --- a/management/server/types/legacynmap/aliases.go +++ b/management/server/types/legacynmap/aliases.go @@ -21,14 +21,14 @@ import ( type ( Account = types.Account - DNSSettings = sharedtypes.DNSSettings + DNSSettings = types.DNSSettings FirewallRule = sharedtypes.FirewallRule ForwardingRule = sharedtypes.ForwardingRule - Group = sharedtypes.Group - Network = sharedtypes.Network - Policy = sharedtypes.Policy - PolicyRule = sharedtypes.PolicyRule - Resource = sharedtypes.Resource + Group = types.Group + Network = types.Network + Policy = types.Policy + PolicyRule = types.PolicyRule + Resource = types.Resource RulePortRange = sharedtypes.RulePortRange RouteFirewallRule = sharedtypes.RouteFirewallRule ) diff --git a/management/server/types/network.go b/management/server/types/network.go new file mode 100644 index 000000000..72ca1af85 --- /dev/null +++ b/management/server/types/network.go @@ -0,0 +1,271 @@ +package types + +import ( + "encoding/binary" + "fmt" + "math/rand" + "net" + "net/netip" + "slices" + "sync" + "time" + + "github.com/c-robinson/iplib" + "github.com/rs/xid" + + "github.com/netbirdio/netbird/shared/management/status" +) + +const ( + // SubnetSize is a size of the subnet of the global network, e.g. 100.77.0.0/16 + SubnetSize = 16 + // NetSize is a global network size 100.64.0.0/10 + NetSize = 10 + + // IPv6SubnetSize is the prefix length of per-account IPv6 subnets. + // Each account gets a /64 from its unique /48 ULA prefix. + IPv6SubnetSize = 64 +) + +type Network struct { + Identifier string `json:"id"` + Net net.IPNet `gorm:"serializer:json"` + // NetV6 is the IPv6 ULA subnet for this account's overlay. Empty if not yet allocated. + NetV6 net.IPNet `gorm:"serializer:json"` + Dns string + // Serial is an ID that increments by 1 when any change to the network happened (e.g. new peer has been added). + // Used to synchronize state to the client apps. + Serial uint64 + + Mu sync.Mutex `json:"-" gorm:"-"` +} + +// NewNetwork creates a new Network initializing it with a Serial=0 +// It takes a random /16 subnet from 100.64.0.0/10 (64 different subnets) +// and a random /64 subnet from fd00:4e42::/32 for IPv6. +func NewNetwork() *Network { + n := iplib.NewNet4(net.ParseIP("100.64.0.0"), NetSize) + sub, _ := n.Subnet(SubnetSize) + + s := rand.NewSource(time.Now().UnixNano()) + r := rand.New(s) + intn := r.Intn(len(sub)) + + return &Network{ + Identifier: xid.New().String(), + Net: sub[intn].IPNet, + NetV6: AllocateIPv6Subnet(r), + Dns: "", + Serial: 0, + } +} + +// AllocateIPv6Subnet generates a random RFC 4193 ULA /64 prefix. +// The format follows RFC 4193 section 3.1: fd + 40-bit Global ID + 16-bit Subnet ID. +// The Global ID and Subnet ID are randomized (simplified from the SHA-1 algorithm +// in section 3.2.2), giving 2^56 possible /64 subnets across all accounts. +func AllocateIPv6Subnet(r *rand.Rand) net.IPNet { + ip := make(net.IP, 16) + ip[0] = 0xfd + // Bytes 1-5: 40-bit random Global ID + ip[1] = byte(r.Intn(256)) + ip[2] = byte(r.Intn(256)) + ip[3] = byte(r.Intn(256)) + ip[4] = byte(r.Intn(256)) + ip[5] = byte(r.Intn(256)) + // Bytes 6-7: 16-bit random Subnet ID + ip[6] = byte(r.Intn(256)) + ip[7] = byte(r.Intn(256)) + + return net.IPNet{ + IP: ip, + Mask: net.CIDRMask(IPv6SubnetSize, 128), + } +} + +// IncSerial increments Serial by 1 reflecting that the network state has been changed +func (n *Network) IncSerial() { + n.Mu.Lock() + defer n.Mu.Unlock() + n.Serial++ +} + +// CurrentSerial returns the Network.Serial of the network (latest state id) +func (n *Network) CurrentSerial() uint64 { + n.Mu.Lock() + defer n.Mu.Unlock() + return n.Serial +} + +func (n *Network) Copy() *Network { + n.Mu.Lock() + defer n.Mu.Unlock() + return &Network{ + Identifier: n.Identifier, + Net: n.Net, + NetV6: n.NetV6, + Dns: n.Dns, + Serial: n.Serial, + } +} + +// AllocatePeerIP picks an available IP from a netip.Prefix. +// This method considers already taken IPs and reuses IPs if there are gaps in takenIps. +// E.g. if prefix=100.30.0.0/16 and takenIps=[100.30.0.1, 100.30.0.4] then the result would be 100.30.0.2 or 100.30.0.3. +func AllocatePeerIP(prefix netip.Prefix, takenIps []netip.Addr) (netip.Addr, error) { + b := prefix.Masked().Addr().As4() + baseIP := binary.BigEndian.Uint32(b[:]) + hostBits := 32 - prefix.Bits() + totalIPs := uint32(1 << hostBits) + + taken := make(map[uint32]struct{}, len(takenIps)+1) + taken[baseIP] = struct{}{} // reserve network IP + taken[baseIP+totalIPs-1] = struct{}{} // reserve broadcast IP + + for _, ip := range takenIps { + ab := ip.As4() + taken[binary.BigEndian.Uint32(ab[:])] = struct{}{} + } + + rng := rand.New(rand.NewSource(time.Now().UnixNano())) + maxAttempts := (int(totalIPs) - len(taken)) / 100 + + for i := 0; i < maxAttempts; i++ { + offset := uint32(rng.Intn(int(totalIPs-2))) + 1 + candidate := baseIP + offset + if _, exists := taken[candidate]; !exists { + return uint32ToIP(candidate), nil + } + } + + for offset := uint32(1); offset < totalIPs-1; offset++ { + candidate := baseIP + offset + if _, exists := taken[candidate]; !exists { + return uint32ToIP(candidate), nil + } + } + + return netip.Addr{}, status.Errorf(status.PreconditionFailed, "network %s is out of IPs", prefix.String()) +} + +// AllocateRandomPeerIP picks a random available IP from a netip.Prefix. +func AllocateRandomPeerIP(prefix netip.Prefix) (netip.Addr, error) { + b := prefix.Masked().Addr().As4() + baseIP := binary.BigEndian.Uint32(b[:]) + hostBits := 32 - prefix.Bits() + totalIPs := uint32(1 << hostBits) + + rng := rand.New(rand.NewSource(time.Now().UnixNano())) + offset := uint32(rng.Intn(int(totalIPs-2))) + 1 + + candidate := baseIP + offset + return uint32ToIP(candidate), nil +} + +// AllocateRandomPeerIPv6 picks a random host address within the given IPv6 prefix. +// Only the host bits (after the prefix length) are randomized. +func AllocateRandomPeerIPv6(prefix netip.Prefix) (netip.Addr, error) { + ones := prefix.Bits() + if ones == 0 || ones > 126 || !prefix.Addr().Is6() { + return netip.Addr{}, fmt.Errorf("invalid IPv6 subnet: %s", prefix.String()) + } + + ip := prefix.Addr().As16() + + rng := rand.New(rand.NewSource(time.Now().UnixNano())) + + // Determine which byte the host bits start in + firstHostByte := ones / 8 + // If the prefix doesn't end on a byte boundary, handle the partial byte + partialBits := ones % 8 + + if partialBits > 0 { + // Keep the network bits in the partial byte, randomize the rest + hostMask := byte(0xff >> partialBits) + ip[firstHostByte] = (ip[firstHostByte] & ^hostMask) | (byte(rng.Intn(256)) & hostMask) + firstHostByte++ + } + + // Randomize remaining full host bytes + for i := firstHostByte; i < 16; i++ { + ip[i] = byte(rng.Intn(256)) + } + + // Avoid all-zeros and all-ones host parts by checking only host bits. + if isHostAllZeroOrOnes(ip[:], ones) { + ip = prefix.Masked().Addr().As16() + ip[15] |= 0x01 + } + + return netip.AddrFrom16(ip).Unmap(), nil +} + +// isHostAllZeroOrOnes checks whether all host bits (after prefixLen) are zero or all ones. +func isHostAllZeroOrOnes(ip []byte, prefixLen int) bool { + hostStart := prefixLen / 8 + partialBits := prefixLen % 8 + + hostSlice := slices.Clone(ip[hostStart:]) + if partialBits > 0 { + hostSlice[0] &= 0xff >> partialBits + } + + allZero := !slices.ContainsFunc(hostSlice, func(v byte) bool { return v != 0 }) + if allZero { + return true + } + + // Build the all-ones mask for host bits + onesMask := make([]byte, len(hostSlice)) + for i := range onesMask { + onesMask[i] = 0xff + } + if partialBits > 0 { + onesMask[0] = 0xff >> partialBits + } + + return slices.Equal(hostSlice, onesMask) +} + +func uint32ToIP(n uint32) netip.Addr { + var b [4]byte + binary.BigEndian.PutUint32(b[:], n) + return netip.AddrFrom4(b) +} + +// generateIPs generates a list of all possible IPs of the given network excluding IPs specified in the exclusion list +func generateIPs(ipNet *net.IPNet, exclusions map[string]struct{}) ([]net.IP, int) { + + var ips []net.IP + for ip := ipNet.IP.Mask(ipNet.Mask); ipNet.Contains(ip); incIP(ip) { + if _, ok := exclusions[ip.String()]; !ok && ip[3] != 0 { + ips = append(ips, copyIP(ip)) + } + } + + // remove network address, broadcast and Fake DNS resolver address + lenIPs := len(ips) + switch { + case lenIPs < 2: + return ips, lenIPs + case lenIPs < 3: + return ips[1 : len(ips)-1], lenIPs - 2 + default: + return ips[1 : len(ips)-2], lenIPs - 3 + } +} + +func copyIP(ip net.IP) net.IP { + dup := make(net.IP, len(ip)) + copy(dup, ip) + return dup +} + +func incIP(ip net.IP) { + for j := len(ip) - 1; j >= 0; j-- { + ip[j]++ + if ip[j] > 0 { + break + } + } +} diff --git a/management/server/types/network_test.go b/management/server/types/network_test.go new file mode 100644 index 000000000..d8a06dbbc --- /dev/null +++ b/management/server/types/network_test.go @@ -0,0 +1,264 @@ +package types + +import ( + "encoding/binary" + "net" + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewNetwork(t *testing.T) { + network := NewNetwork() + + // generated net should be a subnet of a larger 100.64.0.0/10 net + ipNet := net.IPNet{IP: net.ParseIP("100.64.0.0"), Mask: net.IPMask{255, 192, 0, 0}} + assert.Equal(t, ipNet.Contains(network.Net.IP), true) +} + +func TestAllocatePeerIP(t *testing.T) { + prefix := netip.MustParsePrefix("100.64.0.0/24") + var ips []netip.Addr + for i := 0; i < 252; i++ { + ip, err := AllocatePeerIP(prefix, ips) + if err != nil { + t.Fatal(err) + } + ips = append(ips, ip) + } + + assert.Len(t, ips, 252) + + uniq := make(map[string]struct{}) + for _, ip := range ips { + if _, ok := uniq[ip.String()]; !ok { + uniq[ip.String()] = struct{}{} + } else { + t.Errorf("found duplicate IP %s", ip.String()) + } + } +} + +func TestAllocatePeerIPSmallSubnet(t *testing.T) { + // Test /27 network (10.0.0.0/27) - should only have 30 usable IPs (10.0.0.1 to 10.0.0.30) + prefix := netip.MustParsePrefix("10.0.0.0/27") + var ips []netip.Addr + + // Allocate all available IPs in the /27 network + for i := 0; i < 30; i++ { + ip, err := AllocatePeerIP(prefix, ips) + if err != nil { + t.Fatal(err) + } + + // Verify IP is within the correct range + if !prefix.Contains(ip) { + t.Errorf("allocated IP %s is not within network %s", ip.String(), prefix.String()) + } + + ips = append(ips, ip) + } + + assert.Len(t, ips, 30) + + // Verify all IPs are unique + uniq := make(map[string]struct{}) + for _, ip := range ips { + if _, ok := uniq[ip.String()]; !ok { + uniq[ip.String()] = struct{}{} + } else { + t.Errorf("found duplicate IP %s", ip.String()) + } + } + + // Try to allocate one more IP - should fail as network is full + _, err := AllocatePeerIP(prefix, ips) + if err == nil { + t.Error("expected error when network is full, but got none") + } +} + +func TestAllocatePeerIPVariousCIDRs(t *testing.T) { + testCases := []struct { + name string + cidr string + expectedUsable int + }{ + {"/30 network", "192.168.1.0/30", 2}, // 4 total - 2 reserved = 2 usable + {"/29 network", "192.168.1.0/29", 6}, // 8 total - 2 reserved = 6 usable + {"/28 network", "192.168.1.0/28", 14}, // 16 total - 2 reserved = 14 usable + {"/27 network", "192.168.1.0/27", 30}, // 32 total - 2 reserved = 30 usable + {"/26 network", "192.168.1.0/26", 62}, // 64 total - 2 reserved = 62 usable + {"/25 network", "192.168.1.0/25", 126}, // 128 total - 2 reserved = 126 usable + {"/16 network", "10.0.0.0/16", 65534}, // 65536 total - 2 reserved = 65534 usable + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + prefix, err := netip.ParsePrefix(tc.cidr) + require.NoError(t, err) + prefix = prefix.Masked() + + var ips []netip.Addr + + // For larger networks, test only a subset to avoid long test runs + testCount := tc.expectedUsable + if testCount > 1000 { + testCount = 1000 + } + + // Allocate IPs and verify they're within the correct range + for i := 0; i < testCount; i++ { + ip, err := AllocatePeerIP(prefix, ips) + require.NoError(t, err, "failed to allocate IP %d", i) + + // Verify IP is within the correct range + assert.True(t, prefix.Contains(ip), "allocated IP %s is not within network %s", ip.String(), prefix.String()) + + // Verify IP is not network or broadcast address + networkAddr := prefix.Masked().Addr() + hostBits := 32 - prefix.Bits() + b := networkAddr.As4() + baseIP := binary.BigEndian.Uint32(b[:]) + broadcastIP := uint32ToIP(baseIP + (1 << hostBits) - 1) + + assert.NotEqual(t, networkAddr, ip, "allocated network address %s", ip.String()) + assert.NotEqual(t, broadcastIP, ip, "allocated broadcast address %s", ip.String()) + + ips = append(ips, ip) + } + + assert.Len(t, ips, testCount) + + // Verify all IPs are unique + uniq := make(map[string]struct{}) + for _, ip := range ips { + ipStr := ip.String() + assert.NotContains(t, uniq, ipStr, "found duplicate IP %s", ipStr) + uniq[ipStr] = struct{}{} + } + }) + } +} + +func TestGenerateIPs(t *testing.T) { + ipNet := net.IPNet{IP: net.ParseIP("100.64.0.0"), Mask: net.IPMask{255, 255, 255, 0}} + ips, ipsLen := generateIPs(&ipNet, map[string]struct{}{"100.64.0.0": {}}) + if ipsLen != 252 { + t.Errorf("expected 252 ips, got %d", len(ips)) + return + } + if ips[len(ips)-1].String() != "100.64.0.253" { + t.Errorf("expected last ip to be: 100.64.0.253, got %s", ips[len(ips)-1].String()) + } +} + +func TestNewNetworkHasIPv6(t *testing.T) { + network := NewNetwork() + + assert.NotNil(t, network.NetV6.IP, "v6 subnet should be allocated") + assert.True(t, network.NetV6.IP.To4() == nil, "v6 subnet should be IPv6") + assert.Equal(t, byte(0xfd), network.NetV6.IP[0], "v6 subnet should be ULA (fd prefix)") + + ones, bits := network.NetV6.Mask.Size() + assert.Equal(t, 64, ones, "v6 subnet should be /64") + assert.Equal(t, 128, bits) +} + +func TestAllocateIPv6SubnetUniqueness(t *testing.T) { + seen := make(map[string]struct{}) + for i := 0; i < 100; i++ { + network := NewNetwork() + key := network.NetV6.IP.String() + _, duplicate := seen[key] + assert.False(t, duplicate, "duplicate v6 subnet: %s", key) + seen[key] = struct{}{} + } +} + +func TestAllocateRandomPeerIPv6(t *testing.T) { + prefix := netip.MustParsePrefix("fd12:3456:7890:abcd::/64") + + ip, err := AllocateRandomPeerIPv6(prefix) + require.NoError(t, err) + + assert.True(t, ip.Is6(), "should be IPv6") + assert.True(t, prefix.Contains(ip), "should be within subnet") + // First 8 bytes (network prefix) should match + b := ip.As16() + prefixBytes := prefix.Addr().As16() + assert.Equal(t, prefixBytes[:8], b[:8], "prefix should match") + // Interface ID should not be all zeros + allZero := true + for _, v := range b[8:] { + if v != 0 { + allZero = false + break + } + } + assert.False(t, allZero, "interface ID should not be all zeros") +} + +func TestAllocateRandomPeerIPv6_VariousPrefixes(t *testing.T) { + tests := []struct { + name string + cidr string + prefix int + }{ + {"standard /64", "fd00:1234:5678:abcd::/64", 64}, + {"small /112", "fd00:1234:5678:abcd::/112", 112}, + {"large /48", "fd00:1234::/48", 48}, + {"non-boundary /60", "fd00:1234:5670::/60", 60}, + {"non-boundary /52", "fd00:1230::/52", 52}, + {"minimum /120", "fd00:1234:5678:abcd::100/120", 120}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + prefix, err := netip.ParsePrefix(tt.cidr) + require.NoError(t, err) + prefix = prefix.Masked() + + assert.Equal(t, tt.prefix, prefix.Bits()) + + for i := 0; i < 50; i++ { + ip, err := AllocateRandomPeerIPv6(prefix) + require.NoError(t, err) + assert.True(t, prefix.Contains(ip), "IP %s should be within %s", ip, prefix) + } + }) + } +} + +func TestAllocateRandomPeerIPv6_PreservesNetworkBits(t *testing.T) { + // For a /112, bytes 0-13 should be preserved, only bytes 14-15 should vary + prefix := netip.MustParsePrefix("fd00:1234:5678:abcd:ef01:2345:6789:0/112") + + prefixBytes := prefix.Addr().As16() + for i := 0; i < 20; i++ { + ip, err := AllocateRandomPeerIPv6(prefix) + require.NoError(t, err) + // First 14 bytes (112 bits = 14 bytes) must match the network + b := ip.As16() + assert.Equal(t, prefixBytes[:14], b[:14], "network bytes should be preserved for /112") + } +} + +func TestAllocateRandomPeerIPv6_NonByteBoundary(t *testing.T) { + // For a /60, the first 7.5 bytes are network, so byte 7 is partial + prefix := netip.MustParsePrefix("fd00:1234:5678:abc0::/60") + + prefixBytes := prefix.Addr().As16() + for i := 0; i < 50; i++ { + ip, err := AllocateRandomPeerIPv6(prefix) + require.NoError(t, err) + b := ip.As16() + assert.True(t, prefix.Contains(ip), "IP %s should be within %s", ip, prefix) + // First 7 bytes must match exactly + assert.Equal(t, prefixBytes[:7], b[:7], "full network bytes should match for /60") + // Byte 7: top 4 bits (0xc = 1100) must be preserved + assert.Equal(t, prefixBytes[7]&0xf0, b[7]&0xf0, "partial byte network bits should be preserved for /60") + } +} diff --git a/shared/management/types/policy.go b/management/server/types/policy.go similarity index 58% rename from shared/management/types/policy.go rename to management/server/types/policy.go index b8f605b94..0f7298d18 100644 --- a/shared/management/types/policy.go +++ b/management/server/types/policy.go @@ -1,32 +1,5 @@ package types -import ( - "errors" - "fmt" - "strconv" - "strings" -) - -const ( - // PolicyTrafficActionAccept indicates that the traffic is accepted - PolicyTrafficActionAccept = PolicyTrafficActionType("accept") - // PolicyTrafficActionDrop indicates that the traffic is dropped - PolicyTrafficActionDrop = PolicyTrafficActionType("drop") -) - -const ( - // PolicyRuleProtocolALL type of traffic - PolicyRuleProtocolALL = PolicyRuleProtocolType("all") - // PolicyRuleProtocolTCP type of traffic - PolicyRuleProtocolTCP = PolicyRuleProtocolType("tcp") - // PolicyRuleProtocolUDP type of traffic - PolicyRuleProtocolUDP = PolicyRuleProtocolType("udp") - // PolicyRuleProtocolICMP type of traffic - PolicyRuleProtocolICMP = PolicyRuleProtocolType("icmp") - // PolicyRuleProtocolNetbirdSSH type of traffic - PolicyRuleProtocolNetbirdSSH = PolicyRuleProtocolType("netbird-ssh") -) - const ( // PolicyRuleFlowDirect allows traffic from source to destination PolicyRuleFlowDirect = PolicyRuleDirection("direct") @@ -184,85 +157,3 @@ func (p *Policy) SourceGroups() []string { return groupIDs } - -func ParseRuleString(rule string) (PolicyRuleProtocolType, RulePortRange, error) { - rule = strings.TrimSpace(strings.ToLower(rule)) - if rule == "all" { - return PolicyRuleProtocolALL, RulePortRange{}, nil - } - if rule == "icmp" { - return PolicyRuleProtocolICMP, RulePortRange{}, nil - } - - split := strings.Split(rule, "/") - if len(split) != 2 { - return "", RulePortRange{}, errors.New("invalid rule format: expected protocol/port or protocol/port-range") - } - - protoStr := strings.TrimSpace(split[0]) - portStr := strings.TrimSpace(split[1]) - - var protocol PolicyRuleProtocolType - switch protoStr { - case "tcp": - protocol = PolicyRuleProtocolTCP - case "udp": - protocol = PolicyRuleProtocolUDP - case "icmp": - return "", RulePortRange{}, errors.New("icmp does not accept ports; use 'icmp' without '/…'") - case "netbird-ssh": - return PolicyRuleProtocolNetbirdSSH, RulePortRange{Start: nativeSSHPortNumber, End: nativeSSHPortNumber}, nil - default: - return "", RulePortRange{}, fmt.Errorf("invalid protocol: %q", protoStr) - } - - portRange, err := parsePortRange(portStr) - if err != nil { - return "", RulePortRange{}, err - } - - return protocol, portRange, nil -} - -func parsePortRange(portStr string) (RulePortRange, error) { - if strings.Contains(portStr, "-") { - rangeParts := strings.Split(portStr, "-") - if len(rangeParts) != 2 { - return RulePortRange{}, fmt.Errorf("invalid port range %q", portStr) - } - start, err := parsePort(strings.TrimSpace(rangeParts[0])) - if err != nil { - return RulePortRange{}, err - } - end, err := parsePort(strings.TrimSpace(rangeParts[1])) - if err != nil { - return RulePortRange{}, err - } - if start > end { - return RulePortRange{}, fmt.Errorf("invalid port range: start %d > end %d", start, end) - } - return RulePortRange{Start: uint16(start), End: uint16(end)}, nil - } - - p, err := parsePort(portStr) - if err != nil { - return RulePortRange{}, err - } - - return RulePortRange{Start: uint16(p), End: uint16(p)}, nil -} - -func parsePort(portStr string) (int, error) { - - if portStr == "" { - return 0, errors.New("empty port") - } - p, err := strconv.Atoi(portStr) - if err != nil { - return 0, fmt.Errorf("invalid port %q: %w", portStr, err) - } - if p < 1 || p > 65535 { - return 0, fmt.Errorf("port out of range (1–65535): %d", p) - } - return p, nil -} diff --git a/management/server/types/policyrule.go b/management/server/types/policyrule.go new file mode 100644 index 000000000..87905f005 --- /dev/null +++ b/management/server/types/policyrule.go @@ -0,0 +1,196 @@ +package types + +import ( + "slices" +) + +// PolicyUpdateOperationType operation type +type PolicyUpdateOperationType int + +// PolicyRuleDirection direction of traffic +type PolicyRuleDirection string + +// PolicyRule is the metadata of the policy +type PolicyRule struct { + // ID of the policy rule + ID string `gorm:"primaryKey"` + + // PolicyID is a reference to Policy that this object belongs + PolicyID string `json:"-" gorm:"index"` + + // Name of the rule visible in the UI + Name string + + // Description of the rule visible in the UI + Description string + + // Enabled status of rule in the system + Enabled bool + + // Action policy accept or drops packets + Action PolicyTrafficActionType + + // Destinations policy destination groups + Destinations []string `gorm:"serializer:json"` + + // DestinationResource policy destination resource that the rule is applied to + DestinationResource Resource `gorm:"serializer:json"` + + // Sources policy source groups + Sources []string `gorm:"serializer:json"` + + // SourceResource policy source resource that the rule is applied to + SourceResource Resource `gorm:"serializer:json"` + + // Bidirectional define if the rule is applicable in both directions, sources, and destinations + Bidirectional bool + + // Protocol type of the traffic + Protocol PolicyRuleProtocolType + + // Ports or it ranges list + Ports []string `gorm:"serializer:json"` + + // PortRanges a list of port ranges. + PortRanges []RulePortRange `gorm:"serializer:json"` + + // AuthorizedGroups is a map of groupIDs and their respective access to local users via ssh + AuthorizedGroups map[string][]string `gorm:"serializer:json"` + + // AuthorizedUser is a list of userIDs that are authorized to access local resources via ssh + AuthorizedUser string +} + +// Copy returns a copy of a policy rule +func (pm *PolicyRule) Copy() *PolicyRule { + rule := &PolicyRule{ + ID: pm.ID, + PolicyID: pm.PolicyID, + Name: pm.Name, + Description: pm.Description, + Enabled: pm.Enabled, + Action: pm.Action, + Destinations: make([]string, len(pm.Destinations)), + DestinationResource: pm.DestinationResource, + Sources: make([]string, len(pm.Sources)), + SourceResource: pm.SourceResource, + Bidirectional: pm.Bidirectional, + Protocol: pm.Protocol, + Ports: make([]string, len(pm.Ports)), + PortRanges: make([]RulePortRange, len(pm.PortRanges)), + AuthorizedGroups: make(map[string][]string, len(pm.AuthorizedGroups)), + AuthorizedUser: pm.AuthorizedUser, + } + copy(rule.Destinations, pm.Destinations) + copy(rule.Sources, pm.Sources) + copy(rule.Ports, pm.Ports) + copy(rule.PortRanges, pm.PortRanges) + for k, v := range pm.AuthorizedGroups { + rule.AuthorizedGroups[k] = make([]string, len(v)) + copy(rule.AuthorizedGroups[k], v) + } + return rule +} + +func (pm *PolicyRule) Equal(other *PolicyRule) bool { + if pm == nil || other == nil { + return pm == other + } + + if pm.ID != other.ID || + pm.PolicyID != other.PolicyID || + pm.Name != other.Name || + pm.Description != other.Description || + pm.Enabled != other.Enabled || + pm.Action != other.Action || + pm.Bidirectional != other.Bidirectional || + pm.Protocol != other.Protocol || + pm.SourceResource != other.SourceResource || + pm.DestinationResource != other.DestinationResource || + pm.AuthorizedUser != other.AuthorizedUser { + return false + } + + if !stringSlicesEqualUnordered(pm.Sources, other.Sources) { + return false + } + if !stringSlicesEqualUnordered(pm.Destinations, other.Destinations) { + return false + } + if !stringSlicesEqualUnordered(pm.Ports, other.Ports) { + return false + } + if !portRangeSlicesEqualUnordered(pm.PortRanges, other.PortRanges) { + return false + } + if !authorizedGroupsEqual(pm.AuthorizedGroups, other.AuthorizedGroups) { + return false + } + + return true +} + +func stringSlicesEqualUnordered(a, b []string) bool { + if len(a) != len(b) { + return false + } + if len(a) == 0 { + return true + } + sorted1 := make([]string, len(a)) + sorted2 := make([]string, len(b)) + copy(sorted1, a) + copy(sorted2, b) + slices.Sort(sorted1) + slices.Sort(sorted2) + return slices.Equal(sorted1, sorted2) +} + +func portRangeSlicesEqualUnordered(a, b []RulePortRange) bool { + if len(a) != len(b) { + return false + } + if len(a) == 0 { + return true + } + cmp := func(x, y RulePortRange) int { + if x.Start != y.Start { + if x.Start < y.Start { + return -1 + } + return 1 + } + if x.End != y.End { + if x.End < y.End { + return -1 + } + return 1 + } + return 0 + } + sorted1 := make([]RulePortRange, len(a)) + sorted2 := make([]RulePortRange, len(b)) + copy(sorted1, a) + copy(sorted2, b) + slices.SortFunc(sorted1, cmp) + slices.SortFunc(sorted2, cmp) + return slices.EqualFunc(sorted1, sorted2, func(x, y RulePortRange) bool { + return x.Start == y.Start && x.End == y.End + }) +} + +func authorizedGroupsEqual(a, b map[string][]string) bool { + if len(a) != len(b) { + return false + } + for k, va := range a { + vb, ok := b[k] + if !ok { + return false + } + if !stringSlicesEqualUnordered(va, vb) { + return false + } + } + return true +} diff --git a/management/server/types/resource.go b/management/server/types/resource.go new file mode 100644 index 000000000..0f065c850 --- /dev/null +++ b/management/server/types/resource.go @@ -0,0 +1,30 @@ +package types + +import ( + "github.com/netbirdio/netbird/shared/management/http/api" +) + +type Resource struct { + ID string + Type ResourceType +} + +func (r *Resource) ToAPIResponse() *api.Resource { + if r.ID == "" && r.Type == "" { + return nil + } + + return &api.Resource{ + Id: r.ID, + Type: api.ResourceType(r.Type), + } +} + +func (r *Resource) FromAPIRequest(req *api.Resource) { + if req == nil { + return + } + + r.ID = req.Id + r.Type = ResourceType(req.Type) +} diff --git a/management/server/types/user.go b/management/server/types/user.go index dc601e15b..2e975809c 100644 --- a/management/server/types/user.go +++ b/management/server/types/user.go @@ -6,7 +6,7 @@ import ( "time" "github.com/netbirdio/netbird/management/server/idp" - "github.com/netbirdio/netbird/management/server/integration_reference" + "github.com/netbirdio/netbird/shared/management/integration_reference" "github.com/netbirdio/netbird/util/crypt" ) diff --git a/management/server/user_test.go b/management/server/user_test.go index a2e71616a..3a2414540 100644 --- a/management/server/user_test.go +++ b/management/server/user_test.go @@ -33,7 +33,7 @@ import ( "github.com/netbirdio/netbird/idp/dex" "github.com/netbirdio/netbird/management/server/activity" "github.com/netbirdio/netbird/management/server/idp" - "github.com/netbirdio/netbird/management/server/integration_reference" + "github.com/netbirdio/netbird/shared/management/integration_reference" ) const ( 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/management/server/util/util_test.go b/management/server/util/util_test.go deleted file mode 100644 index 5c928b369..000000000 --- a/management/server/util/util_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package util - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -type testObject struct { - value int -} - -func (t testObject) Equal(other testObject) bool { - return t.value == other.value -} - -func Test_MergeUniqueArraysWithoutDuplicates(t *testing.T) { - arr1 := []testObject{{value: 1}, {value: 2}} - arr2 := []testObject{{value: 2}, {value: 3}} - result := MergeUnique(arr1, arr2) - assert.Len(t, result, 3) - assert.Contains(t, result, testObject{value: 1}) - assert.Contains(t, result, testObject{value: 2}) - assert.Contains(t, result, testObject{value: 3}) -} - -func Test_MergeUniqueHandlesEmptyArrays(t *testing.T) { - arr1 := []testObject{} - arr2 := []testObject{} - 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) - assert.Len(t, result, 2) - assert.Contains(t, result, testObject{value: 1}) - assert.Contains(t, result, testObject{value: 2}) -} diff --git a/management/server/integration_reference/integration_reference.go b/shared/management/integration_reference/integration_reference.go similarity index 100% rename from management/server/integration_reference/integration_reference.go rename to shared/management/integration_reference/integration_reference.go diff --git a/shared/management/networkmap/decode.go b/shared/management/networkmap/decode.go index bfc444440..df1031a04 100644 --- a/shared/management/networkmap/decode.go +++ b/shared/management/networkmap/decode.go @@ -12,7 +12,6 @@ import ( log "github.com/sirupsen/logrus" - nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/shared/management/domain" "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" "github.com/netbirdio/netbird/shared/management/proto" @@ -122,7 +121,7 @@ func DecodeEnvelope(ctx context.Context, env *proto.NetworkMapEnvelope) (*types. Resources: fromCompactResources(), } if gc.IsAll { - group.Name = types.GroupAllName + group.Name = nmdata.GroupAllName } c.Groups[groupID] = group } @@ -327,10 +326,10 @@ func decodeAccountSettings(as *proto.AccountSettingsCompact) *nmdata.AccountSett func decodePeerCompact(pc *proto.PeerCompact, peerID string) *nmdata.Peer { var caps []int32 if pc.SupportsSourcePrefixes { - caps = append(caps, nbpeer.PeerCapabilitySourcePrefixes) + caps = append(caps, nmdata.PeerCapabilitySourcePrefixes) } if pc.SupportsIpv6 { - caps = append(caps, nbpeer.PeerCapabilityIPv6Overlay) + caps = append(caps, nmdata.PeerCapabilityIPv6Overlay) } peer := &nmdata.Peer{ ID: peerID, diff --git a/shared/management/networkmap/nmdata/group.go b/shared/management/networkmap/nmdata/group.go index 70fa6d3dc..1cd2cd15e 100644 --- a/shared/management/networkmap/nmdata/group.go +++ b/shared/management/networkmap/nmdata/group.go @@ -2,7 +2,9 @@ package nmdata import "slices" -const groupAllName = "All" +// GroupAllName is the reserved name of the default group that contains every +// peer in an account. +const GroupAllName = "All" // Group is the slim twin of types.Group. type Group struct { @@ -14,7 +16,7 @@ type Group struct { } func (g *Group) IsGroupAll() bool { - return g.Name == groupAllName + return g.Name == GroupAllName } func (g *Group) Copy() *Group { diff --git a/shared/management/networkmap/nmdata/peer.go b/shared/management/networkmap/nmdata/peer.go index b50655c11..3b40a935b 100644 --- a/shared/management/networkmap/nmdata/peer.go +++ b/shared/management/networkmap/nmdata/peer.go @@ -7,9 +7,11 @@ import ( "time" ) +// Peer capability constants mirror the proto enum values. const ( - peerCapabilitySourcePrefixes int32 = 1 - peerCapabilityIPv6Overlay int32 = 2 + PeerCapabilitySourcePrefixes int32 = 1 + PeerCapabilityIPv6Overlay int32 = 2 + PeerCapabilityComponentNetworkMap int32 = 3 ) // Peer is the slim twin of peer.Peer. @@ -78,11 +80,11 @@ func (p *Peer) HasCapability(capability int32) bool { } func (p *Peer) SupportsIPv6() bool { - return !p.Meta.Flags.DisableIPv6 && p.HasCapability(peerCapabilityIPv6Overlay) + return !p.Meta.Flags.DisableIPv6 && p.HasCapability(PeerCapabilityIPv6Overlay) } func (p *Peer) SupportsSourcePrefixes() bool { - return p.HasCapability(peerCapabilitySourcePrefixes) + return p.HasCapability(PeerCapabilitySourcePrefixes) } func (p *Peer) AddedWithSSOLogin() bool { diff --git a/shared/management/types/firewall_helpers.go b/shared/management/types/firewall_helpers.go index 6969f8147..9357d24a9 100644 --- a/shared/management/types/firewall_helpers.go +++ b/shared/management/types/firewall_helpers.go @@ -3,7 +3,6 @@ package types import ( "strconv" - "github.com/netbirdio/netbird/management/server/posture" "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" "github.com/netbirdio/netbird/version" ) @@ -25,28 +24,6 @@ type supportedFeatures struct { type LookupMap map[string]struct{} -func PolicyRuleImpliesLegacySSH(rule *PolicyRule) bool { - return rule.Protocol == PolicyRuleProtocolALL || (rule.Protocol == PolicyRuleProtocolTCP && (portsIncludesSSH(rule.Ports) || portRangeIncludesSSH(rule.PortRanges))) -} - -func portRangeIncludesSSH(portRanges []RulePortRange) bool { - for _, pr := range portRanges { - if (pr.Start <= defaultSSHPortNumber && pr.End >= defaultSSHPortNumber) || (pr.Start <= nativeSSHPortNumber && pr.End >= nativeSSHPortNumber) { - return true - } - } - return false -} - -func portsIncludesSSH(ports []string) bool { - for _, port := range ports { - if port == defaultSSHPortString || port == nativeSSHPortString { - return true - } - } - return false -} - // ExpandPortsAndRanges expands Ports and PortRanges of a rule into individual firewall rules. func ExpandPortsAndRanges(base FirewallRule, rule *nmdata.PolicyRule, peer *nmdata.Peer) []*FirewallRule { features := peerSupportedFirewallFeatures(peer.Meta.WtVersion) @@ -117,13 +94,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/network.go b/shared/management/types/network.go index 2e4528b4e..1269bac4c 100644 --- a/shared/management/types/network.go +++ b/shared/management/types/network.go @@ -1,40 +1,20 @@ package types import ( - "encoding/binary" - "fmt" - "math/rand" "net" - "net/netip" - "slices" - "sync" - "time" - "github.com/c-robinson/iplib" - "github.com/rs/xid" "golang.org/x/exp/maps" nbdns "github.com/netbirdio/netbird/dns" - "github.com/netbirdio/netbird/management/server/util" "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" "github.com/netbirdio/netbird/shared/management/proto" - "github.com/netbirdio/netbird/shared/management/status" ) const ( - // SubnetSize is a size of the subnet of the global network, e.g. 100.77.0.0/16 - SubnetSize = 16 - // NetSize is a global network size 100.64.0.0/10 - NetSize = 10 - // AllowedIPsFormat generates Wireguard AllowedIPs format (e.g. 100.64.30.1/32) AllowedIPsFormat = "%s/32" // AllowedIPsV6Format generates AllowedIPs format for v6 (e.g. fd12:3456:7890::1/128) AllowedIPsV6Format = "%s/128" - - // IPv6SubnetSize is the prefix length of per-account IPv6 subnets. - // Each account gets a /64 from its unique /48 ULA prefix. - IPv6SubnetSize = 64 ) type NetworkMap struct { @@ -56,11 +36,11 @@ 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) nm.ForceRoutingPeerDNSResolution = nm.ForceRoutingPeerDNSResolution || other.ForceRoutingPeerDNSResolution } @@ -121,245 +101,33 @@ func ipToBytes(ip net.IP) []byte { return ip.To16() } -type Network struct { - Identifier string `json:"id"` - Net net.IPNet `gorm:"serializer:json"` - // NetV6 is the IPv6 ULA subnet for this account's overlay. Empty if not yet allocated. - NetV6 net.IPNet `gorm:"serializer:json"` - Dns string - // Serial is an ID that increments by 1 when any change to the network happened (e.g. new peer has been added). - // Used to synchronize state to the client apps. - Serial uint64 - - Mu sync.Mutex `json:"-" gorm:"-"` +type comparableObject[T any] interface { + Equal(other T) bool } -// NewNetwork creates a new Network initializing it with a Serial=0 -// It takes a random /16 subnet from 100.64.0.0/10 (64 different subnets) -// and a random /64 subnet from fd00:4e42::/32 for IPv6. -func NewNetwork() *Network { - n := iplib.NewNet4(net.ParseIP("100.64.0.0"), NetSize) - sub, _ := n.Subnet(SubnetSize) +func mergeUnique[T comparableObject[T]](arr1, arr2 []T) []T { + var result []T - s := rand.NewSource(time.Now().UnixNano()) - r := rand.New(s) - intn := r.Intn(len(sub)) - - return &Network{ - Identifier: xid.New().String(), - Net: sub[intn].IPNet, - NetV6: AllocateIPv6Subnet(r), - Dns: "", - Serial: 0, - } -} - -// AllocateIPv6Subnet generates a random RFC 4193 ULA /64 prefix. -// The format follows RFC 4193 section 3.1: fd + 40-bit Global ID + 16-bit Subnet ID. -// The Global ID and Subnet ID are randomized (simplified from the SHA-1 algorithm -// in section 3.2.2), giving 2^56 possible /64 subnets across all accounts. -func AllocateIPv6Subnet(r *rand.Rand) net.IPNet { - ip := make(net.IP, 16) - ip[0] = 0xfd - // Bytes 1-5: 40-bit random Global ID - ip[1] = byte(r.Intn(256)) - ip[2] = byte(r.Intn(256)) - ip[3] = byte(r.Intn(256)) - ip[4] = byte(r.Intn(256)) - ip[5] = byte(r.Intn(256)) - // Bytes 6-7: 16-bit random Subnet ID - ip[6] = byte(r.Intn(256)) - ip[7] = byte(r.Intn(256)) - - return net.IPNet{ - IP: ip, - Mask: net.CIDRMask(IPv6SubnetSize, 128), - } -} - -// IncSerial increments Serial by 1 reflecting that the network state has been changed -func (n *Network) IncSerial() { - n.Mu.Lock() - defer n.Mu.Unlock() - n.Serial++ -} - -// CurrentSerial returns the Network.Serial of the network (latest state id) -func (n *Network) CurrentSerial() uint64 { - n.Mu.Lock() - defer n.Mu.Unlock() - return n.Serial -} - -func (n *Network) Copy() *Network { - n.Mu.Lock() - defer n.Mu.Unlock() - return &Network{ - Identifier: n.Identifier, - Net: n.Net, - NetV6: n.NetV6, - Dns: n.Dns, - Serial: n.Serial, - } -} - -// AllocatePeerIP picks an available IP from a netip.Prefix. -// This method considers already taken IPs and reuses IPs if there are gaps in takenIps. -// E.g. if prefix=100.30.0.0/16 and takenIps=[100.30.0.1, 100.30.0.4] then the result would be 100.30.0.2 or 100.30.0.3. -func AllocatePeerIP(prefix netip.Prefix, takenIps []netip.Addr) (netip.Addr, error) { - b := prefix.Masked().Addr().As4() - baseIP := binary.BigEndian.Uint32(b[:]) - hostBits := 32 - prefix.Bits() - totalIPs := uint32(1 << hostBits) - - taken := make(map[uint32]struct{}, len(takenIps)+1) - taken[baseIP] = struct{}{} // reserve network IP - taken[baseIP+totalIPs-1] = struct{}{} // reserve broadcast IP - - for _, ip := range takenIps { - ab := ip.As4() - taken[binary.BigEndian.Uint32(ab[:])] = struct{}{} - } - - rng := rand.New(rand.NewSource(time.Now().UnixNano())) - maxAttempts := (int(totalIPs) - len(taken)) / 100 - - for i := 0; i < maxAttempts; i++ { - offset := uint32(rng.Intn(int(totalIPs-2))) + 1 - candidate := baseIP + offset - if _, exists := taken[candidate]; !exists { - return uint32ToIP(candidate), nil + for _, item := range arr1 { + if !containsEqual(result, item) { + result = append(result, item) } } - for offset := uint32(1); offset < totalIPs-1; offset++ { - candidate := baseIP + offset - if _, exists := taken[candidate]; !exists { - return uint32ToIP(candidate), nil + for _, item := range arr2 { + if !containsEqual(result, item) { + result = append(result, item) } } - return netip.Addr{}, status.Errorf(status.PreconditionFailed, "network %s is out of IPs", prefix.String()) + return result } -// AllocateRandomPeerIP picks a random available IP from a netip.Prefix. -func AllocateRandomPeerIP(prefix netip.Prefix) (netip.Addr, error) { - b := prefix.Masked().Addr().As4() - baseIP := binary.BigEndian.Uint32(b[:]) - hostBits := 32 - prefix.Bits() - totalIPs := uint32(1 << hostBits) - - rng := rand.New(rand.NewSource(time.Now().UnixNano())) - offset := uint32(rng.Intn(int(totalIPs-2))) + 1 - - candidate := baseIP + offset - return uint32ToIP(candidate), nil -} - -// AllocateRandomPeerIPv6 picks a random host address within the given IPv6 prefix. -// Only the host bits (after the prefix length) are randomized. -func AllocateRandomPeerIPv6(prefix netip.Prefix) (netip.Addr, error) { - ones := prefix.Bits() - if ones == 0 || ones > 126 || !prefix.Addr().Is6() { - return netip.Addr{}, fmt.Errorf("invalid IPv6 subnet: %s", prefix.String()) - } - - ip := prefix.Addr().As16() - - rng := rand.New(rand.NewSource(time.Now().UnixNano())) - - // Determine which byte the host bits start in - firstHostByte := ones / 8 - // If the prefix doesn't end on a byte boundary, handle the partial byte - partialBits := ones % 8 - - if partialBits > 0 { - // Keep the network bits in the partial byte, randomize the rest - hostMask := byte(0xff >> partialBits) - ip[firstHostByte] = (ip[firstHostByte] & ^hostMask) | (byte(rng.Intn(256)) & hostMask) - firstHostByte++ - } - - // Randomize remaining full host bytes - for i := firstHostByte; i < 16; i++ { - ip[i] = byte(rng.Intn(256)) - } - - // Avoid all-zeros and all-ones host parts by checking only host bits. - if isHostAllZeroOrOnes(ip[:], ones) { - ip = prefix.Masked().Addr().As16() - ip[15] |= 0x01 - } - - return netip.AddrFrom16(ip).Unmap(), nil -} - -// isHostAllZeroOrOnes checks whether all host bits (after prefixLen) are zero or all ones. -func isHostAllZeroOrOnes(ip []byte, prefixLen int) bool { - hostStart := prefixLen / 8 - partialBits := prefixLen % 8 - - hostSlice := slices.Clone(ip[hostStart:]) - if partialBits > 0 { - hostSlice[0] &= 0xff >> partialBits - } - - allZero := !slices.ContainsFunc(hostSlice, func(v byte) bool { return v != 0 }) - if allZero { - return true - } - - // Build the all-ones mask for host bits - onesMask := make([]byte, len(hostSlice)) - for i := range onesMask { - onesMask[i] = 0xff - } - if partialBits > 0 { - onesMask[0] = 0xff >> partialBits - } - - return slices.Equal(hostSlice, onesMask) -} - -func uint32ToIP(n uint32) netip.Addr { - var b [4]byte - binary.BigEndian.PutUint32(b[:], n) - return netip.AddrFrom4(b) -} - -// generateIPs generates a list of all possible IPs of the given network excluding IPs specified in the exclusion list -func generateIPs(ipNet *net.IPNet, exclusions map[string]struct{}) ([]net.IP, int) { - - var ips []net.IP - for ip := ipNet.IP.Mask(ipNet.Mask); ipNet.Contains(ip); incIP(ip) { - if _, ok := exclusions[ip.String()]; !ok && ip[3] != 0 { - ips = append(ips, copyIP(ip)) - } - } - - // remove network address, broadcast and Fake DNS resolver address - lenIPs := len(ips) - switch { - case lenIPs < 2: - return ips, lenIPs - case lenIPs < 3: - return ips[1 : len(ips)-1], lenIPs - 2 - default: - return ips[1 : len(ips)-2], lenIPs - 3 - } -} - -func copyIP(ip net.IP) net.IP { - dup := make(net.IP, len(ip)) - copy(dup, ip) - return dup -} - -func incIP(ip net.IP) { - for j := len(ip) - 1; j >= 0; j-- { - ip[j]++ - if ip[j] > 0 { - break +func containsEqual[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/types/network_test.go b/shared/management/types/network_test.go index d8a06dbbc..631f38836 100644 --- a/shared/management/types/network_test.go +++ b/shared/management/types/network_test.go @@ -1,264 +1,41 @@ package types import ( - "encoding/binary" - "net" - "net/netip" "testing" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -func TestNewNetwork(t *testing.T) { - network := NewNetwork() - - // generated net should be a subnet of a larger 100.64.0.0/10 net - ipNet := net.IPNet{IP: net.ParseIP("100.64.0.0"), Mask: net.IPMask{255, 192, 0, 0}} - assert.Equal(t, ipNet.Contains(network.Net.IP), true) +type mergeTestObject struct { + value int } -func TestAllocatePeerIP(t *testing.T) { - prefix := netip.MustParsePrefix("100.64.0.0/24") - var ips []netip.Addr - for i := 0; i < 252; i++ { - ip, err := AllocatePeerIP(prefix, ips) - if err != nil { - t.Fatal(err) - } - ips = append(ips, ip) - } - - assert.Len(t, ips, 252) - - uniq := make(map[string]struct{}) - for _, ip := range ips { - if _, ok := uniq[ip.String()]; !ok { - uniq[ip.String()] = struct{}{} - } else { - t.Errorf("found duplicate IP %s", ip.String()) - } - } +func (t mergeTestObject) Equal(other mergeTestObject) bool { + return t.value == other.value } -func TestAllocatePeerIPSmallSubnet(t *testing.T) { - // Test /27 network (10.0.0.0/27) - should only have 30 usable IPs (10.0.0.1 to 10.0.0.30) - prefix := netip.MustParsePrefix("10.0.0.0/27") - var ips []netip.Addr - - // Allocate all available IPs in the /27 network - for i := 0; i < 30; i++ { - ip, err := AllocatePeerIP(prefix, ips) - if err != nil { - t.Fatal(err) - } - - // Verify IP is within the correct range - if !prefix.Contains(ip) { - t.Errorf("allocated IP %s is not within network %s", ip.String(), prefix.String()) - } - - ips = append(ips, ip) - } - - assert.Len(t, ips, 30) - - // Verify all IPs are unique - uniq := make(map[string]struct{}) - for _, ip := range ips { - if _, ok := uniq[ip.String()]; !ok { - uniq[ip.String()] = struct{}{} - } else { - t.Errorf("found duplicate IP %s", ip.String()) - } - } - - // Try to allocate one more IP - should fail as network is full - _, err := AllocatePeerIP(prefix, ips) - if err == nil { - t.Error("expected error when network is full, but got none") - } +func Test_MergeUniqueArraysWithoutDuplicates(t *testing.T) { + arr1 := []mergeTestObject{{value: 1}, {value: 2}} + arr2 := []mergeTestObject{{value: 2}, {value: 3}} + result := mergeUnique(arr1, arr2) + assert.Len(t, result, 3) + assert.Contains(t, result, mergeTestObject{value: 1}) + assert.Contains(t, result, mergeTestObject{value: 2}) + assert.Contains(t, result, mergeTestObject{value: 3}) } -func TestAllocatePeerIPVariousCIDRs(t *testing.T) { - testCases := []struct { - name string - cidr string - expectedUsable int - }{ - {"/30 network", "192.168.1.0/30", 2}, // 4 total - 2 reserved = 2 usable - {"/29 network", "192.168.1.0/29", 6}, // 8 total - 2 reserved = 6 usable - {"/28 network", "192.168.1.0/28", 14}, // 16 total - 2 reserved = 14 usable - {"/27 network", "192.168.1.0/27", 30}, // 32 total - 2 reserved = 30 usable - {"/26 network", "192.168.1.0/26", 62}, // 64 total - 2 reserved = 62 usable - {"/25 network", "192.168.1.0/25", 126}, // 128 total - 2 reserved = 126 usable - {"/16 network", "10.0.0.0/16", 65534}, // 65536 total - 2 reserved = 65534 usable - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - prefix, err := netip.ParsePrefix(tc.cidr) - require.NoError(t, err) - prefix = prefix.Masked() - - var ips []netip.Addr - - // For larger networks, test only a subset to avoid long test runs - testCount := tc.expectedUsable - if testCount > 1000 { - testCount = 1000 - } - - // Allocate IPs and verify they're within the correct range - for i := 0; i < testCount; i++ { - ip, err := AllocatePeerIP(prefix, ips) - require.NoError(t, err, "failed to allocate IP %d", i) - - // Verify IP is within the correct range - assert.True(t, prefix.Contains(ip), "allocated IP %s is not within network %s", ip.String(), prefix.String()) - - // Verify IP is not network or broadcast address - networkAddr := prefix.Masked().Addr() - hostBits := 32 - prefix.Bits() - b := networkAddr.As4() - baseIP := binary.BigEndian.Uint32(b[:]) - broadcastIP := uint32ToIP(baseIP + (1 << hostBits) - 1) - - assert.NotEqual(t, networkAddr, ip, "allocated network address %s", ip.String()) - assert.NotEqual(t, broadcastIP, ip, "allocated broadcast address %s", ip.String()) - - ips = append(ips, ip) - } - - assert.Len(t, ips, testCount) - - // Verify all IPs are unique - uniq := make(map[string]struct{}) - for _, ip := range ips { - ipStr := ip.String() - assert.NotContains(t, uniq, ipStr, "found duplicate IP %s", ipStr) - uniq[ipStr] = struct{}{} - } - }) - } +func Test_MergeUniqueHandlesEmptyArrays(t *testing.T) { + arr1 := []mergeTestObject{} + arr2 := []mergeTestObject{} + result := mergeUnique(arr1, arr2) + assert.Empty(t, result) } -func TestGenerateIPs(t *testing.T) { - ipNet := net.IPNet{IP: net.ParseIP("100.64.0.0"), Mask: net.IPMask{255, 255, 255, 0}} - ips, ipsLen := generateIPs(&ipNet, map[string]struct{}{"100.64.0.0": {}}) - if ipsLen != 252 { - t.Errorf("expected 252 ips, got %d", len(ips)) - return - } - if ips[len(ips)-1].String() != "100.64.0.253" { - t.Errorf("expected last ip to be: 100.64.0.253, got %s", ips[len(ips)-1].String()) - } -} - -func TestNewNetworkHasIPv6(t *testing.T) { - network := NewNetwork() - - assert.NotNil(t, network.NetV6.IP, "v6 subnet should be allocated") - assert.True(t, network.NetV6.IP.To4() == nil, "v6 subnet should be IPv6") - assert.Equal(t, byte(0xfd), network.NetV6.IP[0], "v6 subnet should be ULA (fd prefix)") - - ones, bits := network.NetV6.Mask.Size() - assert.Equal(t, 64, ones, "v6 subnet should be /64") - assert.Equal(t, 128, bits) -} - -func TestAllocateIPv6SubnetUniqueness(t *testing.T) { - seen := make(map[string]struct{}) - for i := 0; i < 100; i++ { - network := NewNetwork() - key := network.NetV6.IP.String() - _, duplicate := seen[key] - assert.False(t, duplicate, "duplicate v6 subnet: %s", key) - seen[key] = struct{}{} - } -} - -func TestAllocateRandomPeerIPv6(t *testing.T) { - prefix := netip.MustParsePrefix("fd12:3456:7890:abcd::/64") - - ip, err := AllocateRandomPeerIPv6(prefix) - require.NoError(t, err) - - assert.True(t, ip.Is6(), "should be IPv6") - assert.True(t, prefix.Contains(ip), "should be within subnet") - // First 8 bytes (network prefix) should match - b := ip.As16() - prefixBytes := prefix.Addr().As16() - assert.Equal(t, prefixBytes[:8], b[:8], "prefix should match") - // Interface ID should not be all zeros - allZero := true - for _, v := range b[8:] { - if v != 0 { - allZero = false - break - } - } - assert.False(t, allZero, "interface ID should not be all zeros") -} - -func TestAllocateRandomPeerIPv6_VariousPrefixes(t *testing.T) { - tests := []struct { - name string - cidr string - prefix int - }{ - {"standard /64", "fd00:1234:5678:abcd::/64", 64}, - {"small /112", "fd00:1234:5678:abcd::/112", 112}, - {"large /48", "fd00:1234::/48", 48}, - {"non-boundary /60", "fd00:1234:5670::/60", 60}, - {"non-boundary /52", "fd00:1230::/52", 52}, - {"minimum /120", "fd00:1234:5678:abcd::100/120", 120}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - prefix, err := netip.ParsePrefix(tt.cidr) - require.NoError(t, err) - prefix = prefix.Masked() - - assert.Equal(t, tt.prefix, prefix.Bits()) - - for i := 0; i < 50; i++ { - ip, err := AllocateRandomPeerIPv6(prefix) - require.NoError(t, err) - assert.True(t, prefix.Contains(ip), "IP %s should be within %s", ip, prefix) - } - }) - } -} - -func TestAllocateRandomPeerIPv6_PreservesNetworkBits(t *testing.T) { - // For a /112, bytes 0-13 should be preserved, only bytes 14-15 should vary - prefix := netip.MustParsePrefix("fd00:1234:5678:abcd:ef01:2345:6789:0/112") - - prefixBytes := prefix.Addr().As16() - for i := 0; i < 20; i++ { - ip, err := AllocateRandomPeerIPv6(prefix) - require.NoError(t, err) - // First 14 bytes (112 bits = 14 bytes) must match the network - b := ip.As16() - assert.Equal(t, prefixBytes[:14], b[:14], "network bytes should be preserved for /112") - } -} - -func TestAllocateRandomPeerIPv6_NonByteBoundary(t *testing.T) { - // For a /60, the first 7.5 bytes are network, so byte 7 is partial - prefix := netip.MustParsePrefix("fd00:1234:5678:abc0::/60") - - prefixBytes := prefix.Addr().As16() - for i := 0; i < 50; i++ { - ip, err := AllocateRandomPeerIPv6(prefix) - require.NoError(t, err) - b := ip.As16() - assert.True(t, prefix.Contains(ip), "IP %s should be within %s", ip, prefix) - // First 7 bytes must match exactly - assert.Equal(t, prefixBytes[:7], b[:7], "full network bytes should match for /60") - // Byte 7: top 4 bits (0xc = 1100) must be preserved - assert.Equal(t, prefixBytes[7]&0xf0, b[7]&0xf0, "partial byte network bits should be preserved for /60") - } +func Test_MergeUniqueHandlesOneEmptyArray(t *testing.T) { + arr1 := []mergeTestObject{{value: 1}, {value: 2}} + arr2 := []mergeTestObject{} + result := mergeUnique(arr1, arr2) + assert.Len(t, result, 2) + assert.Contains(t, result, mergeTestObject{value: 1}) + assert.Contains(t, result, mergeTestObject{value: 2}) } diff --git a/shared/management/types/networkmap_components.go b/shared/management/types/networkmap_components.go index 948af6169..5d38c33ca 100644 --- a/shared/management/types/networkmap_components.go +++ b/shared/management/types/networkmap_components.go @@ -12,7 +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" "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/domain" "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" @@ -868,7 +867,7 @@ func (c *NetworkMapComponents) networkResourceToRoute(resource *nmdata.NetworkRe Description: resource.Description, } - if resource.Type == string(resourceTypes.Host) || resource.Type == string(resourceTypes.Subnet) { + if resource.Type == string(ResourceTypeHost) || resource.Type == string(ResourceTypeSubnet) { r.Network = resource.Prefix r.NetworkType = nmdata.NetworkTypeIPv4 @@ -877,7 +876,7 @@ func (c *NetworkMapComponents) networkResourceToRoute(resource *nmdata.NetworkRe } } - if resource.Type == string(resourceTypes.Domain) { + if resource.Type == string(ResourceTypeDomain) { domainList, err := domain.FromStringList([]string{resource.Domain}) if err == nil { r.Domains = domainList diff --git a/shared/management/types/policyrule.go b/shared/management/types/policyrule.go index 52c494a6a..c951b1487 100644 --- a/shared/management/types/policyrule.go +++ b/shared/management/types/policyrule.go @@ -1,22 +1,39 @@ package types import ( - "slices" + "errors" + "fmt" + "strconv" + "strings" "github.com/netbirdio/netbird/shared/management/proto" ) -// PolicyUpdateOperationType operation type -type PolicyUpdateOperationType int - // PolicyTrafficActionType action type for the firewall type PolicyTrafficActionType string // PolicyRuleProtocolType type of traffic type PolicyRuleProtocolType string -// PolicyRuleDirection direction of traffic -type PolicyRuleDirection string +const ( + // PolicyTrafficActionAccept indicates that the traffic is accepted + PolicyTrafficActionAccept = PolicyTrafficActionType("accept") + // PolicyTrafficActionDrop indicates that the traffic is dropped + PolicyTrafficActionDrop = PolicyTrafficActionType("drop") +) + +const ( + // PolicyRuleProtocolALL type of traffic + PolicyRuleProtocolALL = PolicyRuleProtocolType("all") + // PolicyRuleProtocolTCP type of traffic + PolicyRuleProtocolTCP = PolicyRuleProtocolType("tcp") + // PolicyRuleProtocolUDP type of traffic + PolicyRuleProtocolUDP = PolicyRuleProtocolType("udp") + // PolicyRuleProtocolICMP type of traffic + PolicyRuleProtocolICMP = PolicyRuleProtocolType("icmp") + // PolicyRuleProtocolNetbirdSSH type of traffic + PolicyRuleProtocolNetbirdSSH = PolicyRuleProtocolType("netbird-ssh") +) // RulePortRange represents a range of ports for a firewall rule. type RulePortRange struct { @@ -39,187 +56,84 @@ func (r *RulePortRange) Equal(other *RulePortRange) bool { return r.Start == other.Start && r.End == other.End } -// PolicyRule is the metadata of the policy -type PolicyRule struct { - // ID of the policy rule - ID string `gorm:"primaryKey"` +func ParseRuleString(rule string) (PolicyRuleProtocolType, RulePortRange, error) { + rule = strings.TrimSpace(strings.ToLower(rule)) + if rule == "all" { + return PolicyRuleProtocolALL, RulePortRange{}, nil + } + if rule == "icmp" { + return PolicyRuleProtocolICMP, RulePortRange{}, nil + } - // PolicyID is a reference to Policy that this object belongs - PolicyID string `json:"-" gorm:"index"` + split := strings.Split(rule, "/") + if len(split) != 2 { + return "", RulePortRange{}, errors.New("invalid rule format: expected protocol/port or protocol/port-range") + } - // Name of the rule visible in the UI - Name string + protoStr := strings.TrimSpace(split[0]) + portStr := strings.TrimSpace(split[1]) - // Description of the rule visible in the UI - Description string + var protocol PolicyRuleProtocolType + switch protoStr { + case "tcp": + protocol = PolicyRuleProtocolTCP + case "udp": + protocol = PolicyRuleProtocolUDP + case "icmp": + return "", RulePortRange{}, errors.New("icmp does not accept ports; use 'icmp' without '/…'") + case "netbird-ssh": + return PolicyRuleProtocolNetbirdSSH, RulePortRange{Start: nativeSSHPortNumber, End: nativeSSHPortNumber}, nil + default: + return "", RulePortRange{}, fmt.Errorf("invalid protocol: %q", protoStr) + } - // Enabled status of rule in the system - Enabled bool + portRange, err := parsePortRange(portStr) + if err != nil { + return "", RulePortRange{}, err + } - // Action policy accept or drops packets - Action PolicyTrafficActionType - - // Destinations policy destination groups - Destinations []string `gorm:"serializer:json"` - - // DestinationResource policy destination resource that the rule is applied to - DestinationResource Resource `gorm:"serializer:json"` - - // Sources policy source groups - Sources []string `gorm:"serializer:json"` - - // SourceResource policy source resource that the rule is applied to - SourceResource Resource `gorm:"serializer:json"` - - // Bidirectional define if the rule is applicable in both directions, sources, and destinations - Bidirectional bool - - // Protocol type of the traffic - Protocol PolicyRuleProtocolType - - // Ports or it ranges list - Ports []string `gorm:"serializer:json"` - - // PortRanges a list of port ranges. - PortRanges []RulePortRange `gorm:"serializer:json"` - - // AuthorizedGroups is a map of groupIDs and their respective access to local users via ssh - AuthorizedGroups map[string][]string `gorm:"serializer:json"` - - // AuthorizedUser is a list of userIDs that are authorized to access local resources via ssh - AuthorizedUser string + return protocol, portRange, nil } -// Copy returns a copy of a policy rule -func (pm *PolicyRule) Copy() *PolicyRule { - rule := &PolicyRule{ - ID: pm.ID, - PolicyID: pm.PolicyID, - Name: pm.Name, - Description: pm.Description, - Enabled: pm.Enabled, - Action: pm.Action, - Destinations: make([]string, len(pm.Destinations)), - DestinationResource: pm.DestinationResource, - Sources: make([]string, len(pm.Sources)), - SourceResource: pm.SourceResource, - Bidirectional: pm.Bidirectional, - Protocol: pm.Protocol, - Ports: make([]string, len(pm.Ports)), - PortRanges: make([]RulePortRange, len(pm.PortRanges)), - AuthorizedGroups: make(map[string][]string, len(pm.AuthorizedGroups)), - AuthorizedUser: pm.AuthorizedUser, - } - copy(rule.Destinations, pm.Destinations) - copy(rule.Sources, pm.Sources) - copy(rule.Ports, pm.Ports) - copy(rule.PortRanges, pm.PortRanges) - for k, v := range pm.AuthorizedGroups { - rule.AuthorizedGroups[k] = make([]string, len(v)) - copy(rule.AuthorizedGroups[k], v) - } - return rule -} - -func (pm *PolicyRule) Equal(other *PolicyRule) bool { - if pm == nil || other == nil { - return pm == other - } - - if pm.ID != other.ID || - pm.PolicyID != other.PolicyID || - pm.Name != other.Name || - pm.Description != other.Description || - pm.Enabled != other.Enabled || - pm.Action != other.Action || - pm.Bidirectional != other.Bidirectional || - pm.Protocol != other.Protocol || - pm.SourceResource != other.SourceResource || - pm.DestinationResource != other.DestinationResource || - pm.AuthorizedUser != other.AuthorizedUser { - return false - } - - if !stringSlicesEqualUnordered(pm.Sources, other.Sources) { - return false - } - if !stringSlicesEqualUnordered(pm.Destinations, other.Destinations) { - return false - } - if !stringSlicesEqualUnordered(pm.Ports, other.Ports) { - return false - } - if !portRangeSlicesEqualUnordered(pm.PortRanges, other.PortRanges) { - return false - } - if !authorizedGroupsEqual(pm.AuthorizedGroups, other.AuthorizedGroups) { - return false - } - - return true -} - -func stringSlicesEqualUnordered(a, b []string) bool { - if len(a) != len(b) { - return false - } - if len(a) == 0 { - return true - } - sorted1 := make([]string, len(a)) - sorted2 := make([]string, len(b)) - copy(sorted1, a) - copy(sorted2, b) - slices.Sort(sorted1) - slices.Sort(sorted2) - return slices.Equal(sorted1, sorted2) -} - -func portRangeSlicesEqualUnordered(a, b []RulePortRange) bool { - if len(a) != len(b) { - return false - } - if len(a) == 0 { - return true - } - cmp := func(x, y RulePortRange) int { - if x.Start != y.Start { - if x.Start < y.Start { - return -1 - } - return 1 +func parsePortRange(portStr string) (RulePortRange, error) { + if strings.Contains(portStr, "-") { + rangeParts := strings.Split(portStr, "-") + if len(rangeParts) != 2 { + return RulePortRange{}, fmt.Errorf("invalid port range %q", portStr) } - if x.End != y.End { - if x.End < y.End { - return -1 - } - return 1 + start, err := parsePort(strings.TrimSpace(rangeParts[0])) + if err != nil { + return RulePortRange{}, err } - return 0 + end, err := parsePort(strings.TrimSpace(rangeParts[1])) + if err != nil { + return RulePortRange{}, err + } + if start > end { + return RulePortRange{}, fmt.Errorf("invalid port range: start %d > end %d", start, end) + } + return RulePortRange{Start: uint16(start), End: uint16(end)}, nil } - sorted1 := make([]RulePortRange, len(a)) - sorted2 := make([]RulePortRange, len(b)) - copy(sorted1, a) - copy(sorted2, b) - slices.SortFunc(sorted1, cmp) - slices.SortFunc(sorted2, cmp) - return slices.EqualFunc(sorted1, sorted2, func(x, y RulePortRange) bool { - return x.Start == y.Start && x.End == y.End - }) + + p, err := parsePort(portStr) + if err != nil { + return RulePortRange{}, err + } + + return RulePortRange{Start: uint16(p), End: uint16(p)}, nil } -func authorizedGroupsEqual(a, b map[string][]string) bool { - if len(a) != len(b) { - return false +func parsePort(portStr string) (int, error) { + + if portStr == "" { + return 0, errors.New("empty port") } - for k, va := range a { - vb, ok := b[k] - if !ok { - return false - } - if !stringSlicesEqualUnordered(va, vb) { - return false - } + p, err := strconv.Atoi(portStr) + if err != nil { + return 0, fmt.Errorf("invalid port %q: %w", portStr, err) } - return true + if p < 1 || p > 65535 { + return 0, fmt.Errorf("port out of range (1–65535): %d", p) + } + return p, nil } diff --git a/shared/management/types/resource.go b/shared/management/types/resource.go index 8347d8c03..c44370730 100644 --- a/shared/management/types/resource.go +++ b/shared/management/types/resource.go @@ -1,9 +1,5 @@ package types -import ( - "github.com/netbirdio/netbird/shared/management/http/api" -) - type ResourceType string const ( @@ -12,28 +8,3 @@ const ( ResourceTypeHost ResourceType = "host" ResourceTypeSubnet ResourceType = "subnet" ) - -type Resource struct { - ID string - Type ResourceType -} - -func (r *Resource) ToAPIResponse() *api.Resource { - if r.ID == "" && r.Type == "" { - return nil - } - - return &api.Resource{ - Id: r.ID, - Type: api.ResourceType(r.Type), - } -} - -func (r *Resource) FromAPIRequest(req *api.Resource) { - if req == nil { - return - } - - r.ID = req.Id - r.Type = ResourceType(req.Type) -} diff --git a/version/compare.go b/version/compare.go new file mode 100644 index 000000000..e7868f35a --- /dev/null +++ b/version/compare.go @@ -0,0 +1,31 @@ +package version + +import ( + "strings" + + v "github.com/hashicorp/go-version" +) + +// 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 +} diff --git a/version/compare_test.go b/version/compare_test.go new file mode 100644 index 000000000..9f3c7f323 --- /dev/null +++ b/version/compare_test.go @@ -0,0 +1,72 @@ +package version + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +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) + }) + } +}