Merge remote-tracking branch 'origin/revert/component-types' into revert/component-types

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
This commit is contained in:
Dmitri Dolguikh
2026-08-05 14:58:36 +02:00
32 changed files with 1048 additions and 1095 deletions

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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.

View File

@@ -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
}

View File

@@ -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)
})
}
}

View File

@@ -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.

View File

@@ -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
}

View File

@@ -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
)

View File

@@ -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}
}

View File

@@ -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
)

View File

@@ -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
}
}
}

View File

@@ -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")
}
}

View File

@@ -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 (165535): %d", p)
}
return p, nil
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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"
)

View File

@@ -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 (

View File

@@ -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
}

View File

@@ -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})
}

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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})
}

View File

@@ -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

View File

@@ -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 (165535): %d", p)
}
return p, nil
}

View File

@@ -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)
}

31
version/compare.go Normal file
View File

@@ -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
}

72
version/compare_test.go Normal file
View File

@@ -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)
})
}
}