[management] Add IPv6 overlay addressing and capability gating (#5698)

This commit is contained in:
Viktor Liu
2026-04-08 22:40:51 +08:00
committed by GitHub
parent 86f1b53bd4
commit a1e7db2713
51 changed files with 2622 additions and 394 deletions

View File

@@ -3,7 +3,6 @@ package types
import (
"context"
"fmt"
"net"
"net/netip"
"slices"
"strconv"
@@ -315,8 +314,9 @@ func (a *Account) GetPeerNetworkMap(
peersToConnect = append(peersToConnect, p)
}
routesUpdate := a.GetRoutesToSync(ctx, peerID, peersToConnect, peerGroups)
routesFirewallRules := a.GetPeerRoutesFirewallRules(ctx, peerID, validatedPeersMap)
includeIPv6 := peer.SupportsIPv6() && peer.IPv6.IsValid()
routesUpdate := filterAndExpandRoutes(a.GetRoutesToSync(ctx, peerID, peersToConnect, peerGroups), includeIPv6)
routesFirewallRules := a.GetPeerRoutesFirewallRules(ctx, peerID, validatedPeersMap, includeIPv6)
isRouter, networkResourcesRoutes, sourcePeers := a.GetNetworkResourcesRoutesToSync(ctx, peerID, resourcePolicies, routers)
var networkResourcesFirewallRules []*RouteFirewallRule
if isRouter {
@@ -350,7 +350,7 @@ func (a *Account) GetPeerNetworkMap(
nm := &NetworkMap{
Peers: peersToConnectIncludingRouters,
Network: a.Network.Copy(),
Routes: slices.Concat(networkResourcesRoutes, routesUpdate),
Routes: slices.Concat(filterAndExpandRoutes(networkResourcesRoutes, includeIPv6), routesUpdate),
DNSConfig: dnsUpdate,
OfflinePeers: expiredPeers,
FirewallRules: firewallRules,
@@ -445,7 +445,7 @@ func getPeerNSGroups(account *Account, peerID string) []*nbdns.NameServerGroup {
// peerIsNameserver returns true if the peer is a nameserver for a nsGroup
func peerIsNameserver(peer *nbpeer.Peer, nsGroup *nbdns.NameServerGroup) bool {
for _, ns := range nsGroup.NameServers {
if peer.IP.Equal(ns.IP.AsSlice()) {
if peer.IP == ns.IP {
return true
}
}
@@ -512,6 +512,8 @@ func (a *Account) GetPeersCustomZone(ctx context.Context, dnsDomain string) nbdn
domainSuffix := "." + dnsDomain
ipv6AllowedPeers := a.peerIPv6AllowedSet()
var sb strings.Builder
for _, peer := range a.Peers {
if peer.DNSLabel == "" {
@@ -523,13 +525,32 @@ func (a *Account) GetPeersCustomZone(ctx context.Context, dnsDomain string) nbdn
sb.WriteString(peer.DNSLabel)
sb.WriteString(domainSuffix)
fqdn := sb.String()
customZone.Records = append(customZone.Records, nbdns.SimpleRecord{
Name: sb.String(),
Name: fqdn,
Type: int(dns.TypeA),
Class: nbdns.DefaultClass,
TTL: defaultTTL,
RData: peer.IP.String(),
})
// Only advertise AAAA for peers that have a valid IPv6, whose client supports it,
// and that belong to an IPv6-enabled group. Old clients don't configure v6 on their
// WireGuard interface, so resolving their AAAA causes connections to hang.
// Edge case: toggling --disable-ipv6 on a peer without a version change does not
// propagate to other peers, so AAAA records can be stale until the next full sync.
// This is accepted because v4 connectivity is unaffected. Can be fixed by adding
// capability-change detection to the SyncPeer propagation condition.
_, peerAllowed := ipv6AllowedPeers[peer.ID]
hasIPv6 := peer.IPv6.IsValid() && peer.SupportsIPv6() && peerAllowed
if hasIPv6 {
customZone.Records = append(customZone.Records, nbdns.SimpleRecord{
Name: fqdn,
Type: int(dns.TypeAAAA),
Class: nbdns.DefaultClass,
TTL: defaultTTL,
RData: peer.IPv6.String(),
})
}
sb.Reset()
for _, extraLabel := range peer.ExtraDNSLabels {
@@ -537,13 +558,23 @@ func (a *Account) GetPeersCustomZone(ctx context.Context, dnsDomain string) nbdn
sb.WriteString(extraLabel)
sb.WriteString(domainSuffix)
extraFqdn := sb.String()
customZone.Records = append(customZone.Records, nbdns.SimpleRecord{
Name: sb.String(),
Name: extraFqdn,
Type: int(dns.TypeA),
Class: nbdns.DefaultClass,
TTL: defaultTTL,
RData: peer.IP.String(),
})
if hasIPv6 {
customZone.Records = append(customZone.Records, nbdns.SimpleRecord{
Name: extraFqdn,
Type: int(dns.TypeAAAA),
Class: nbdns.DefaultClass,
TTL: defaultTTL,
RData: peer.IPv6.String(),
})
}
sb.Reset()
}
@@ -824,8 +855,43 @@ func (a *Account) GetPeerGroups(peerID string) LookupMap {
return groupList
}
func (a *Account) GetTakenIPs() []net.IP {
var takenIps []net.IP
// PeerIPv6Allowed reports whether the given peer is in any of the account's IPv6 enabled groups.
// Returns false if IPv6 is disabled or no groups are configured.
func (a *Account) PeerIPv6Allowed(peerID string) bool {
if len(a.Settings.IPv6EnabledGroups) == 0 {
return false
}
for _, groupID := range a.Settings.IPv6EnabledGroups {
group, ok := a.Groups[groupID]
if !ok {
continue
}
if slices.Contains(group.Peers, peerID) {
return true
}
}
return false
}
// peerIPv6AllowedSet returns a set of peer IDs that belong to any IPv6-enabled group.
func (a *Account) peerIPv6AllowedSet() map[string]struct{} {
result := make(map[string]struct{})
for _, groupID := range a.Settings.IPv6EnabledGroups {
group, ok := a.Groups[groupID]
if !ok {
continue
}
for _, peerID := range group.Peers {
result[peerID] = struct{}{}
}
}
return result
}
// GetTakenIPs returns all peer IP addresses currently allocated in the account.
func (a *Account) GetTakenIPs() []netip.Addr {
takenIps := make([]netip.Addr, 0, len(a.Peers))
for _, existingPeer := range a.Peers {
takenIps = append(takenIps, existingPeer.IP)
}
@@ -1178,10 +1244,17 @@ func (a *Account) connResourcesGenerator(ctx context.Context, targetPeer *nbpeer
if len(rule.Ports) == 0 && len(rule.PortRanges) == 0 {
rules = append(rules, &fr)
continue
} else {
rules = append(rules, expandPortsAndRanges(fr, rule, targetPeer)...)
}
rules = append(rules, expandPortsAndRanges(fr, rule, targetPeer)...)
rules = appendIPv6FirewallRule(rules, rulesExists, peer, targetPeer, rule, firewallRuleContext{
direction: direction,
dirStr: strconv.Itoa(direction),
protocolStr: string(protocol),
actionStr: string(rule.Action),
portsJoined: strings.Join(rule.Ports, ","),
})
}
}, func() ([]*nbpeer.Peer, []*FirewallRule) {
return peers, rules
@@ -1297,14 +1370,14 @@ func (a *Account) GetPostureChecks(postureChecksID string) *posture.Checks {
}
// GetPeerRoutesFirewallRules gets the routes firewall rules associated with a routing peer ID for the account.
func (a *Account) GetPeerRoutesFirewallRules(ctx context.Context, peerID string, validatedPeersMap map[string]struct{}) []*RouteFirewallRule {
func (a *Account) GetPeerRoutesFirewallRules(ctx context.Context, peerID string, validatedPeersMap map[string]struct{}, includeIPv6 bool) []*RouteFirewallRule {
routesFirewallRules := make([]*RouteFirewallRule, 0, len(a.Routes))
enabledRoutes, _ := a.getRoutingPeerRoutes(ctx, peerID)
for _, route := range enabledRoutes {
// If no access control groups are specified, accept all traffic.
if len(route.AccessControlGroups) == 0 {
defaultPermit := getDefaultPermit(route)
defaultPermit := getDefaultPermit(route, includeIPv6)
routesFirewallRules = append(routesFirewallRules, defaultPermit...)
continue
}
@@ -1313,7 +1386,7 @@ func (a *Account) GetPeerRoutesFirewallRules(ctx context.Context, peerID string,
for _, accessGroup := range route.AccessControlGroups {
policies := GetAllRoutePoliciesFromGroups(a, []string{accessGroup})
rules := a.getRouteFirewallRules(ctx, peerID, policies, route, validatedPeersMap, distributionPeers)
rules := a.getRouteFirewallRules(ctx, peerID, policies, route, validatedPeersMap, distributionPeers, includeIPv6)
routesFirewallRules = append(routesFirewallRules, rules...)
}
}
@@ -1321,7 +1394,7 @@ func (a *Account) GetPeerRoutesFirewallRules(ctx context.Context, peerID string,
return routesFirewallRules
}
func (a *Account) getRouteFirewallRules(ctx context.Context, peerID string, policies []*Policy, route *route.Route, validatedPeersMap map[string]struct{}, distributionPeers map[string]struct{}) []*RouteFirewallRule {
func (a *Account) getRouteFirewallRules(ctx context.Context, peerID string, policies []*Policy, route *route.Route, validatedPeersMap map[string]struct{}, distributionPeers map[string]struct{}, includeIPv6 bool) []*RouteFirewallRule {
var fwRules []*RouteFirewallRule
for _, policy := range policies {
if !policy.Enabled {
@@ -1334,7 +1407,7 @@ func (a *Account) getRouteFirewallRules(ctx context.Context, peerID string, poli
}
rulePeers := a.getRulePeers(rule, policy.SourcePostureChecks, peerID, distributionPeers, validatedPeersMap)
rules := generateRouteFirewallRules(ctx, route, rule, rulePeers, FirewallRuleDirectionIN)
rules := generateRouteFirewallRules(ctx, route, rule, rulePeers, FirewallRuleDirectionIN, includeIPv6)
fwRules = append(fwRules, rules...)
}
}
@@ -1394,8 +1467,10 @@ func (a *Account) getDistributionGroupsPeers(route *route.Route) map[string]stru
return distPeers
}
func getDefaultPermit(route *route.Route) []*RouteFirewallRule {
var rules []*RouteFirewallRule
func getDefaultPermit(route *route.Route, includeIPv6 bool) []*RouteFirewallRule {
if route.Network.Addr().Is6() && !includeIPv6 {
return nil
}
sources := []string{"0.0.0.0/0"}
if route.Network.Addr().Is6() {
@@ -1411,10 +1486,9 @@ func getDefaultPermit(route *route.Route) []*RouteFirewallRule {
RouteID: route.ID,
}
rules = append(rules, &rule)
rules := []*RouteFirewallRule{&rule}
// dynamic routes always contain an IPv4 placeholder as destination, hence we must add IPv6 rules additionally
if route.IsDynamic() {
if includeIPv6 && route.IsDynamic() {
ruleV6 := rule
ruleV6.SourceRanges = []string{"::/0"}
rules = append(rules, &ruleV6)
@@ -1460,7 +1534,7 @@ func (a *Account) GetPeerNetworkResourceFirewallRules(ctx context.Context, peer
resourceAppliedPolicies := resourcePolicies[string(route.GetResourceID())]
distributionPeers := getPoliciesSourcePeers(resourceAppliedPolicies, a.Groups)
rules := a.getRouteFirewallRules(ctx, peer.ID, resourceAppliedPolicies, route, validatedPeersMap, distributionPeers)
rules := a.getRouteFirewallRules(ctx, peer.ID, resourceAppliedPolicies, route, validatedPeersMap, distributionPeers, peer.SupportsIPv6() && peer.IPv6.IsValid())
for _, rule := range rules {
if len(rule.SourceRanges) > 0 {
routesFirewallRules = append(routesFirewallRules, rule)
@@ -1990,24 +2064,32 @@ func peerSupportedFirewallFeatures(peerVer string) supportedFeatures {
}
// filterZoneRecordsForPeers filters DNS records to only include peers to connect.
// AAAA records are excluded when the requesting peer lacks IPv6 capability.
func filterZoneRecordsForPeers(peer *nbpeer.Peer, customZone nbdns.CustomZone, peersToConnect, expiredPeers []*nbpeer.Peer) []nbdns.SimpleRecord {
filteredRecords := make([]nbdns.SimpleRecord, 0, len(customZone.Records))
peerIPs := make(map[string]struct{})
peerIPs := make(map[netip.Addr]struct{}, len(peersToConnect)+len(expiredPeers)+2)
includeIPv6 := peer.SupportsIPv6() && peer.IPv6.IsValid()
// Add peer's own IP to include its own DNS records
peerIPs[peer.IP.String()] = struct{}{}
for _, peerToConnect := range peersToConnect {
peerIPs[peerToConnect.IP.String()] = struct{}{}
addPeerIPs := func(p *nbpeer.Peer) {
peerIPs[p.IP] = struct{}{}
if includeIPv6 && p.IPv6.IsValid() {
peerIPs[p.IPv6] = struct{}{}
}
}
for _, expiredPeer := range expiredPeers {
peerIPs[expiredPeer.IP.String()] = struct{}{}
addPeerIPs(peer)
for _, p := range peersToConnect {
addPeerIPs(p)
}
for _, p := range expiredPeers {
addPeerIPs(p)
}
for _, record := range customZone.Records {
if _, exists := peerIPs[record.RData]; exists {
filteredRecords = append(filteredRecords, record)
if addr, err := netip.ParseAddr(record.RData); err == nil {
if _, exists := peerIPs[addr.Unmap()]; exists {
filteredRecords = append(filteredRecords, record)
}
}
}

View File

@@ -544,10 +544,15 @@ func filterDNSRecordsByPeers(records []nbdns.SimpleRecord, peers map[string]*nbp
return nil
}
peerIPs := make(map[string]struct{}, len(peers))
// Include both v4 and v6 addresses so AAAA records (whose RData is an IPv6
// address) are not filtered out when peers have IPv6 assigned.
peerIPs := make(map[string]struct{}, len(peers)*2)
for _, peer := range peers {
if peer != nil {
peerIPs[peer.IP.String()] = struct{}{}
if peer.IPv6.IsValid() {
peerIPs[peer.IPv6.String()] = struct{}{}
}
}
}

View File

@@ -3,7 +3,6 @@ package types
import (
"context"
"fmt"
"net"
"net/netip"
"slices"
"testing"
@@ -466,9 +465,9 @@ const (
)
var (
accNetResourcePeer1IP = net.IP{192, 168, 1, 1}
accNetResourcePeer2IP = net.IP{192, 168, 1, 2}
accNetResourceRouter1IP = net.IP{192, 168, 1, 3}
accNetResourcePeer1IP = netip.AddrFrom4([4]byte{192, 168, 1, 1})
accNetResourcePeer2IP = netip.AddrFrom4([4]byte{192, 168, 1, 2})
accNetResourceRouter1IP = netip.AddrFrom4([4]byte{192, 168, 1, 3})
accNetResourceValidPeers = map[string]struct{}{accNetResourcePeer1ID: {}, accNetResourcePeer2ID: {}}
)
@@ -832,7 +831,13 @@ func Test_NetworksNetMapGenWithTwoPostureChecks(t *testing.T) {
func Test_NetworksNetMapGenShouldExcludeOtherRouters(t *testing.T) {
account := getBasicAccountsWithResource()
account.Peers["router2Id"] = &nbpeer.Peer{Key: "router2Key", ID: "router2Id", AccountID: accID, IP: net.IP{192, 168, 1, 4}}
account.Peers["router2Id"] = &nbpeer.Peer{
Key: "router2Key",
ID: "router2Id",
AccountID: accID,
IP: netip.AddrFrom4([4]byte{192, 168, 1, 4}),
IPv6: netip.MustParseAddr("fd00::c0a8:104"),
}
account.NetworkRouters = append(account.NetworkRouters, &routerTypes.NetworkRouter{
ID: "router2Id",
NetworkID: network1ID,
@@ -1320,7 +1325,11 @@ func Test_FilterZoneRecordsForPeers(t *testing.T) {
},
peersToConnect: []*nbpeer.Peer{},
expiredPeers: []*nbpeer.Peer{},
peer: &nbpeer.Peer{ID: "router", IP: net.ParseIP("10.0.0.100")},
peer: &nbpeer.Peer{
ID: "router",
IP: netip.MustParseAddr("10.0.0.100"),
IPv6: netip.MustParseAddr("fd00::a00:64"),
},
expectedRecords: []nbdns.SimpleRecord{
{Name: "router.netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.100"},
},
@@ -1347,14 +1356,19 @@ func Test_FilterZoneRecordsForPeers(t *testing.T) {
var peers []*nbpeer.Peer
for _, i := range []int{1, 5, 10, 25, 50, 75, 100} {
peers = append(peers, &nbpeer.Peer{
ID: fmt.Sprintf("peer%d", i),
IP: net.ParseIP(fmt.Sprintf("10.0.%d.%d", i/256, i%256)),
ID: fmt.Sprintf("peer%d", i),
IP: netip.MustParseAddr(fmt.Sprintf("10.0.%d.%d", i/256, i%256)),
IPv6: netip.MustParseAddr(fmt.Sprintf("fd00::%d", i)),
})
}
return peers
}(),
expiredPeers: []*nbpeer.Peer{},
peer: &nbpeer.Peer{ID: "router", IP: net.ParseIP("10.0.0.100")},
peer: &nbpeer.Peer{
ID: "router",
IP: netip.MustParseAddr("10.0.0.100"),
IPv6: netip.MustParseAddr("fd00::a00:64"),
},
expectedRecords: func() []nbdns.SimpleRecord {
var records []nbdns.SimpleRecord
for _, i := range []int{1, 5, 10, 25, 50, 75, 100} {
@@ -1385,11 +1399,27 @@ func Test_FilterZoneRecordsForPeers(t *testing.T) {
},
},
peersToConnect: []*nbpeer.Peer{
{ID: "peer1", IP: net.ParseIP("10.0.0.1"), DNSLabel: "peer1", ExtraDNSLabels: []string{"peer1-alt", "peer1-backup"}},
{ID: "peer2", IP: net.ParseIP("10.0.0.2"), DNSLabel: "peer2", ExtraDNSLabels: []string{"peer2-service"}},
{
ID: "peer1",
IP: netip.MustParseAddr("10.0.0.1"),
IPv6: netip.MustParseAddr("fd00::a00:1"),
DNSLabel: "peer1",
ExtraDNSLabels: []string{"peer1-alt", "peer1-backup"},
},
{
ID: "peer2",
IP: netip.MustParseAddr("10.0.0.2"),
IPv6: netip.MustParseAddr("fd00::a00:2"),
DNSLabel: "peer2",
ExtraDNSLabels: []string{"peer2-service"},
},
},
expiredPeers: []*nbpeer.Peer{},
peer: &nbpeer.Peer{ID: "router", IP: net.ParseIP("10.0.0.100")},
peer: &nbpeer.Peer{
ID: "router",
IP: netip.MustParseAddr("10.0.0.100"),
IPv6: netip.MustParseAddr("fd00::a00:64"),
},
expectedRecords: []nbdns.SimpleRecord{
{Name: "peer1.netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"},
{Name: "peer1-alt.netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"},
@@ -1411,12 +1441,24 @@ func Test_FilterZoneRecordsForPeers(t *testing.T) {
},
},
peersToConnect: []*nbpeer.Peer{
{ID: "peer1", IP: net.ParseIP("10.0.0.1")},
{
ID: "peer1",
IP: netip.MustParseAddr("10.0.0.1"),
IPv6: netip.MustParseAddr("fd00::a00:1"),
},
},
expiredPeers: []*nbpeer.Peer{
{ID: "expired-peer", IP: net.ParseIP("10.0.0.99")},
{
ID: "expired-peer",
IP: netip.MustParseAddr("10.0.0.99"),
IPv6: netip.MustParseAddr("fd00::a00:63"),
},
},
peer: &nbpeer.Peer{
ID: "router",
IP: netip.MustParseAddr("10.0.0.100"),
IPv6: netip.MustParseAddr("fd00::a00:64"),
},
peer: &nbpeer.Peer{ID: "router", IP: net.ParseIP("10.0.0.100")},
expectedRecords: []nbdns.SimpleRecord{
{Name: "peer1.netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"},
{Name: "expired-peer.netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.99"},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -322,7 +322,7 @@ func createTestAccount() *Account {
for i := range numPeers {
peerID := fmt.Sprintf("peer-%d", i)
ip := net.IP{100, 64, 0, byte(i + 1)}
ip := netip.AddrFrom4([4]byte{100, 64, 0, byte(i + 1)})
wtVersion := "0.25.0"
if i%2 == 0 {
wtVersion = "0.40.0"

View File

@@ -3,7 +3,6 @@ package types
import (
"context"
"maps"
"net"
"net/netip"
"slices"
"strconv"
@@ -116,13 +115,17 @@ func (c *NetworkMapComponents) Calculate(ctx context.Context) *NetworkMap {
peersToConnect, expiredPeers := c.filterPeersByLoginExpiration(aclPeers)
routesUpdate := c.getRoutesToSync(targetPeerID, peersToConnect, peerGroups)
routesFirewallRules := c.getPeerRoutesFirewallRules(ctx, targetPeerID)
includeIPv6 := false
if p := c.Peers[targetPeerID]; p != nil {
includeIPv6 = p.SupportsIPv6() && p.IPv6.IsValid()
}
routesUpdate := filterAndExpandRoutes(c.getRoutesToSync(targetPeerID, peersToConnect, peerGroups), includeIPv6)
routesFirewallRules := c.getPeerRoutesFirewallRules(ctx, targetPeerID, includeIPv6)
isRouter, networkResourcesRoutes, sourcePeers := c.getNetworkResourcesRoutesToSync(targetPeerID)
var networkResourcesFirewallRules []*RouteFirewallRule
if isRouter {
networkResourcesFirewallRules = c.getPeerNetworkResourceFirewallRules(ctx, targetPeerID, networkResourcesRoutes)
networkResourcesFirewallRules = c.getPeerNetworkResourceFirewallRules(ctx, targetPeerID, networkResourcesRoutes, includeIPv6)
}
peersToConnectIncludingRouters := c.addNetworksRoutingPeers(
@@ -158,7 +161,7 @@ func (c *NetworkMapComponents) Calculate(ctx context.Context) *NetworkMap {
return &NetworkMap{
Peers: peersToConnectIncludingRouters,
Network: c.Network.Copy(),
Routes: append(networkResourcesRoutes, routesUpdate...),
Routes: append(filterAndExpandRoutes(networkResourcesRoutes, includeIPv6), routesUpdate...),
DNSConfig: dnsUpdate,
OfflinePeers: expiredPeers,
FirewallRules: firewallRules,
@@ -298,7 +301,7 @@ func (c *NetworkMapComponents) connResourcesGenerator(targetPeer *nbpeer.Peer) (
peersExists[peer.ID] = struct{}{}
}
peerIP := net.IP(peer.IP).String()
peerIP := peer.IP.String()
fr := FirewallRule{
PolicyID: rule.ID,
@@ -317,10 +320,17 @@ func (c *NetworkMapComponents) connResourcesGenerator(targetPeer *nbpeer.Peer) (
if len(rule.Ports) == 0 && len(rule.PortRanges) == 0 {
rules = append(rules, &fr)
continue
} else {
rules = append(rules, expandPortsAndRanges(fr, rule, targetPeer)...)
}
rules = append(rules, expandPortsAndRanges(fr, rule, targetPeer)...)
rules = appendIPv6FirewallRule(rules, rulesExists, peer, targetPeer, rule, firewallRuleContext{
direction: direction,
dirStr: dirStr,
protocolStr: protocolStr,
actionStr: actionStr,
portsJoined: portsJoined,
})
}
}, func() ([]*nbpeer.Peer, []*FirewallRule) {
return peers, rules
@@ -456,6 +466,29 @@ func (c *NetworkMapComponents) peerIsNameserver(peerIPStr string, nsGroup *nbdns
return false
}
// filterAndExpandRoutes drops v6 routes for non-capable peers and duplicates
// the default v4 route (0.0.0.0/0) as ::/0 for v6-capable peers.
// TODO: the "-v6" suffix on IDs could collide with user-supplied route IDs.
func filterAndExpandRoutes(routes []*route.Route, includeIPv6 bool) []*route.Route {
filtered := make([]*route.Route, 0, len(routes))
for _, r := range routes {
if !includeIPv6 && r.Network.Addr().Is6() {
continue
}
filtered = append(filtered, r)
if includeIPv6 && r.Network.Bits() == 0 && r.Network.Addr().Is4() {
v6 := r.Copy()
v6.ID = r.ID + "-v6-default"
v6.NetID = r.NetID + "-v6"
v6.Network = netip.MustParsePrefix("::/0")
v6.NetworkType = route.IPv6Network
filtered = append(filtered, v6)
}
}
return filtered
}
func (c *NetworkMapComponents) getRoutesToSync(peerID string, aclPeers []*nbpeer.Peer, peerGroups LookupMap) []*route.Route {
routes, peerDisabledRoutes := c.getRoutingPeerRoutes(peerID)
peerRoutesMembership := make(LookupMap)
@@ -526,7 +559,6 @@ func (c *NetworkMapComponents) getRoutingPeerRoutes(peerID string) (enabledRoute
return enabledRoutes, disabledRoutes
}
func (c *NetworkMapComponents) filterRoutesByGroups(routes []*route.Route, groupListMap LookupMap) []*route.Route {
var filteredRoutes []*route.Route
for _, r := range routes {
@@ -552,13 +584,13 @@ func (c *NetworkMapComponents) filterRoutesFromPeersOfSameHAGroup(routes []*rout
return filteredRoutes
}
func (c *NetworkMapComponents) getPeerRoutesFirewallRules(ctx context.Context, peerID string) []*RouteFirewallRule {
func (c *NetworkMapComponents) getPeerRoutesFirewallRules(ctx context.Context, peerID string, includeIPv6 bool) []*RouteFirewallRule {
routesFirewallRules := make([]*RouteFirewallRule, 0)
enabledRoutes, _ := c.getRoutingPeerRoutes(peerID)
for _, r := range enabledRoutes {
if len(r.AccessControlGroups) == 0 {
defaultPermit := c.getDefaultPermit(r)
defaultPermit := c.getDefaultPermit(r, includeIPv6)
routesFirewallRules = append(routesFirewallRules, defaultPermit...)
continue
}
@@ -567,7 +599,7 @@ func (c *NetworkMapComponents) getPeerRoutesFirewallRules(ctx context.Context, p
for _, accessGroup := range r.AccessControlGroups {
policies := c.getAllRoutePoliciesFromGroups([]string{accessGroup})
rules := c.getRouteFirewallRules(ctx, peerID, policies, r, distributionPeers)
rules := c.getRouteFirewallRules(ctx, peerID, policies, r, distributionPeers, includeIPv6)
routesFirewallRules = append(routesFirewallRules, rules...)
}
}
@@ -575,8 +607,10 @@ func (c *NetworkMapComponents) getPeerRoutesFirewallRules(ctx context.Context, p
return routesFirewallRules
}
func (c *NetworkMapComponents) getDefaultPermit(r *route.Route) []*RouteFirewallRule {
var rules []*RouteFirewallRule
func (c *NetworkMapComponents) getDefaultPermit(r *route.Route, includeIPv6 bool) []*RouteFirewallRule {
if r.Network.Addr().Is6() && !includeIPv6 {
return nil
}
sources := []string{"0.0.0.0/0"}
if r.Network.Addr().Is6() {
@@ -593,9 +627,9 @@ func (c *NetworkMapComponents) getDefaultPermit(r *route.Route) []*RouteFirewall
RouteID: r.ID,
}
rules = append(rules, &rule)
rules := []*RouteFirewallRule{&rule}
if r.IsDynamic() {
if includeIPv6 && r.IsDynamic() {
ruleV6 := rule
ruleV6.SourceRanges = []string{"::/0"}
rules = append(rules, &ruleV6)
@@ -634,7 +668,7 @@ func (c *NetworkMapComponents) getAllRoutePoliciesFromGroups(accessControlGroups
return routePolicies
}
func (c *NetworkMapComponents) getRouteFirewallRules(ctx context.Context, peerID string, policies []*Policy, route *route.Route, distributionPeers map[string]struct{}) []*RouteFirewallRule {
func (c *NetworkMapComponents) getRouteFirewallRules(ctx context.Context, peerID string, policies []*Policy, route *route.Route, distributionPeers map[string]struct{}, includeIPv6 bool) []*RouteFirewallRule {
var fwRules []*RouteFirewallRule
for _, policy := range policies {
if !policy.Enabled {
@@ -647,7 +681,7 @@ func (c *NetworkMapComponents) getRouteFirewallRules(ctx context.Context, peerID
}
rulePeers := c.getRulePeers(rule, policy.SourcePostureChecks, peerID, distributionPeers)
rules := generateRouteFirewallRules(ctx, route, rule, rulePeers, FirewallRuleDirectionIN)
rules := generateRouteFirewallRules(ctx, route, rule, rulePeers, FirewallRuleDirectionIN, includeIPv6)
fwRules = append(fwRules, rules...)
}
}
@@ -798,7 +832,7 @@ func (c *NetworkMapComponents) getPostureValidPeers(inputPeers []string, posture
return dest
}
func (c *NetworkMapComponents) getPeerNetworkResourceFirewallRules(ctx context.Context, peerID string, routes []*route.Route) []*RouteFirewallRule {
func (c *NetworkMapComponents) getPeerNetworkResourceFirewallRules(ctx context.Context, peerID string, routes []*route.Route, includeIPv6 bool) []*RouteFirewallRule {
routesFirewallRules := make([]*RouteFirewallRule, 0)
peerInfo := c.GetPeerInfo(peerID)
@@ -815,7 +849,7 @@ func (c *NetworkMapComponents) getPeerNetworkResourceFirewallRules(ctx context.C
resourcePolicies := c.ResourcePoliciesMap[resourceID]
distributionPeers := c.getPoliciesSourcePeers(resourcePolicies)
rules := c.getRouteFirewallRules(ctx, peerID, resourcePolicies, r, distributionPeers)
rules := c.getRouteFirewallRules(ctx, peerID, resourcePolicies, r, distributionPeers, includeIPv6)
for _, rule := range rules {
if len(rule.SourceRanges) > 0 {
routesFirewallRules = append(routesFirewallRules, rule)
@@ -899,3 +933,36 @@ func (c *NetworkMapComponents) addNetworksRoutingPeers(
return peersToConnect
}
type firewallRuleContext struct {
direction int
dirStr string
protocolStr string
actionStr string
portsJoined string
}
func appendIPv6FirewallRule(rules []*FirewallRule, rulesExists map[string]struct{}, peer, targetPeer *nbpeer.Peer, rule *PolicyRule, rc firewallRuleContext) []*FirewallRule {
if !peer.IPv6.IsValid() || !targetPeer.SupportsIPv6() || !targetPeer.IPv6.IsValid() {
return rules
}
v6IP := peer.IPv6.String()
v6RuleID := rule.ID + v6IP + rc.dirStr + rc.protocolStr + rc.actionStr + rc.portsJoined
if _, ok := rulesExists[v6RuleID]; ok {
return rules
}
rulesExists[v6RuleID] = struct{}{}
v6fr := FirewallRule{
PolicyID: rule.ID,
PeerIP: v6IP,
Direction: rc.direction,
Action: rc.actionStr,
Protocol: rc.protocolStr,
}
if len(rule.Ports) == 0 && len(rule.PortRanges) == 0 {
return append(rules, &v6fr)
}
return append(rules, expandPortsAndRanges(v6fr, rule, targetPeer)...)
}

View File

@@ -147,15 +147,16 @@ func TestGetPeerNetworkMap_Golden_WithNewPeer(t *testing.T) {
builder := types.NewNetworkMapBuilder(account, validatedPeersMap)
newPeerID := "peer-new-101"
newPeerIP := net.IP{100, 64, 1, 1}
newPeerIP := netip.MustParseAddr("100.64.1.1")
newPeer := &nbpeer.Peer{
ID: newPeerID,
IP: newPeerIP,
IPv6: netip.MustParseAddr("fd00:1234:5678::101"),
Key: fmt.Sprintf("key-%s", newPeerID),
DNSLabel: "peernew101",
Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now()},
UserID: "user-admin",
Meta: nbpeer.PeerSystemMeta{WtVersion: "0.26.0", GoOS: "linux"},
Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0", GoOS: "linux"},
LastLogin: func() *time.Time { t := time.Now(); return &t }(),
}
@@ -224,12 +225,13 @@ func BenchmarkGetPeerNetworkMap_AfterPeerAdded(b *testing.B) {
newPeerID := "peer-new-101"
newPeer := &nbpeer.Peer{
ID: newPeerID,
IP: net.IP{100, 64, 1, 1},
IP: netip.MustParseAddr("100.64.1.1"),
IPv6: netip.MustParseAddr("fd00:1234:5678::101"),
Key: fmt.Sprintf("key-%s", newPeerID),
DNSLabel: "peernew101",
Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now()},
UserID: "user-admin",
Meta: nbpeer.PeerSystemMeta{WtVersion: "0.26.0", GoOS: "linux"},
Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0", GoOS: "linux"},
}
account.Peers[newPeerID] = newPeer
@@ -273,15 +275,16 @@ func TestGetPeerNetworkMap_Golden_WithNewRoutingPeer(t *testing.T) {
builder := types.NewNetworkMapBuilder(account, validatedPeersMap)
newRouterID := "peer-new-router-102"
newRouterIP := net.IP{100, 64, 1, 2}
newRouterIP := netip.MustParseAddr("100.64.1.2")
newRouter := &nbpeer.Peer{
ID: newRouterID,
IP: newRouterIP,
IPv6: netip.MustParseAddr("fd00:1234:5678::102"),
Key: fmt.Sprintf("key-%s", newRouterID),
DNSLabel: "newrouter102",
Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now()},
UserID: "user-admin",
Meta: nbpeer.PeerSystemMeta{WtVersion: "0.26.0", GoOS: "linux"},
Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0", GoOS: "linux"},
LastLogin: func() *time.Time { t := time.Now(); return &t }(),
}
@@ -362,15 +365,16 @@ func BenchmarkGetPeerNetworkMap_AfterRouterPeerAdded(b *testing.B) {
}
builder := types.NewNetworkMapBuilder(account, validatedPeersMap)
newRouterID := "peer-new-router-102"
newRouterIP := net.IP{100, 64, 1, 2}
newRouterIP := netip.MustParseAddr("100.64.1.2")
newRouter := &nbpeer.Peer{
ID: newRouterID,
IP: newRouterIP,
IPv6: netip.MustParseAddr("fd00:1234:5678::102"),
Key: fmt.Sprintf("key-%s", newRouterID),
DNSLabel: "newrouter102",
Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now()},
UserID: "user-admin",
Meta: nbpeer.PeerSystemMeta{WtVersion: "0.26.0", GoOS: "linux"},
Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0", GoOS: "linux"},
LastLogin: func() *time.Time { t := time.Now(); return &t }(),
}
@@ -729,16 +733,21 @@ func createTestAccountWithEntities() *types.Account {
for i := range numPeers {
peerID := fmt.Sprintf("peer-%d", i)
ip := net.IP{100, 64, 0, byte(i + 1)}
ip := netip.MustParseAddr(fmt.Sprintf("100.64.0.%d", i+1))
ipv6 := netip.MustParseAddr(fmt.Sprintf("fd00:1234:5678::%d", i+1))
wtVersion := "0.25.0"
if i%2 == 0 {
wtVersion = "0.40.0"
}
p := &nbpeer.Peer{
ID: peerID, IP: ip, Key: fmt.Sprintf("key-%s", peerID), DNSLabel: fmt.Sprintf("peer%d", i+1),
Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now()},
UserID: "user-admin", Meta: nbpeer.PeerSystemMeta{WtVersion: wtVersion, GoOS: "linux"},
ID: peerID,
IP: ip,
IPv6: ipv6,
Key: fmt.Sprintf("key-%s", peerID),
DNSLabel: fmt.Sprintf("peer%d", i+1),
Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now()},
UserID: "user-admin", Meta: nbpeer.PeerSystemMeta{WtVersion: wtVersion, GoOS: "linux"},
}
if peerID == expiredPeerID {
@@ -850,7 +859,10 @@ func createTestAccountWithEntities() *types.Account {
Id: testAccountID, Peers: peers, Groups: groups, Policies: policies, Routes: routes,
Users: users,
Network: &types.Network{
Identifier: "net-golden-test", Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(16, 32)}, Serial: 1,
Identifier: "net-golden-test",
Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(16, 32)},
NetV6: net.IPNet{IP: net.ParseIP("fd00:1234:5678::"), Mask: net.CIDRMask(64, 128)},
Serial: 1,
},
DNSSettings: types.DNSSettings{DisabledManagementGroups: []string{opsGroupID}},
NameServerGroups: map[string]*dns.NameServerGroup{
@@ -871,7 +883,7 @@ func createTestAccountWithEntities() *types.Account {
NetworkRouters: []*routerTypes.NetworkRouter{
{ID: networkRouterID, NetworkID: networkID, Peer: routingPeerID, Enabled: true, AccountID: testAccountID},
},
Settings: &types.Settings{PeerLoginExpirationEnabled: true, PeerLoginExpiration: 1 * time.Hour},
Settings: &types.Settings{PeerLoginExpirationEnabled: true, PeerLoginExpiration: 1 * time.Hour, IPv6EnabledGroups: []string{allGroupID}},
}
for _, p := range account.Policies {
@@ -900,15 +912,16 @@ func TestGetPeerNetworkMap_Golden_New_WithOnPeerAddedRouter_Batched(t *testing.T
builder := types.NewNetworkMapBuilder(account, validatedPeersMap)
newRouterID := "peer-new-router-102"
newRouterIP := net.IP{100, 64, 1, 2}
newRouterIP := netip.MustParseAddr("100.64.1.2")
newRouter := &nbpeer.Peer{
ID: newRouterID,
IP: newRouterIP,
IPv6: netip.MustParseAddr("fd00:1234:5678::102"),
Key: fmt.Sprintf("key-%s", newRouterID),
DNSLabel: "newrouter102",
Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now()},
UserID: "user-admin",
Meta: nbpeer.PeerSystemMeta{WtVersion: "0.26.0", GoOS: "linux"},
Meta: nbpeer.PeerSystemMeta{WtVersion: "0.40.0", GoOS: "linux"},
LastLogin: func() *time.Time { t := time.Now(); return &t }(),
}

View File

@@ -521,10 +521,17 @@ func (b *NetworkMapBuilder) generateResourcescached(
if len(rule.Ports) == 0 && len(rule.PortRanges) == 0 {
*rules = append(*rules, &fr)
continue
} else {
*rules = append(*rules, expandPortsAndRanges(fr, rule, targetPeer)...)
}
*rules = append(*rules, expandPortsAndRanges(fr, rule, targetPeer)...)
*rules = appendIPv6FirewallRule(*rules, rulesExists, peer, targetPeer, rule, firewallRuleContext{
direction: direction,
dirStr: strconv.Itoa(direction),
protocolStr: firewallRuleProtocol(rule.Protocol),
actionStr: string(rule.Action),
portsJoined: strings.Join(rule.Ports, ","),
})
}
}
@@ -720,7 +727,7 @@ func (b *NetworkMapBuilder) buildPeerRoutesView(account *Account, peerID string)
allRoutes := slices.Concat(enabledRoutes, networkResourcesRoutes)
b.updateACGIndexForPeer(peerID, allRoutes)
routeFirewallRules := b.getPeerRoutesFirewallRules(account, peerID, b.validatedPeers)
routeFirewallRules := b.getPeerRoutesFirewallRules(account, peerID, b.validatedPeers, peer.SupportsIPv6() && peer.IPv6.IsValid())
for _, rule := range routeFirewallRules {
ruleID := b.generateRouteFirewallRuleID(rule)
view.RouteFirewallRuleIDs = append(view.RouteFirewallRuleIDs, ruleID)
@@ -823,13 +830,13 @@ func (b *NetworkMapBuilder) getRoutingPeerRoutes(peerID string) (enabledRoutes [
return enabledRoutes, disabledRoutes
}
func (b *NetworkMapBuilder) getPeerRoutesFirewallRules(account *Account, peerID string, validatedPeersMap map[string]struct{}) []*RouteFirewallRule {
func (b *NetworkMapBuilder) getPeerRoutesFirewallRules(account *Account, peerID string, validatedPeersMap map[string]struct{}, includeIPv6 bool) []*RouteFirewallRule {
routesFirewallRules := make([]*RouteFirewallRule, 0)
enabledRoutes, _ := b.getRoutingPeerRoutes(peerID)
for _, route := range enabledRoutes {
if len(route.AccessControlGroups) == 0 {
defaultPermit := getDefaultPermit(route)
defaultPermit := getDefaultPermit(route, includeIPv6)
routesFirewallRules = append(routesFirewallRules, defaultPermit...)
continue
}
@@ -839,7 +846,7 @@ func (b *NetworkMapBuilder) getPeerRoutesFirewallRules(account *Account, peerID
for _, accessGroup := range route.AccessControlGroups {
policies := b.getAllRoutePoliciesFromGroups([]string{accessGroup})
rules := b.getRouteFirewallRules(peerID, policies, route, validatedPeersMap, distributionPeers, account)
rules := b.getRouteFirewallRules(peerID, policies, route, validatedPeersMap, distributionPeers, account, includeIPv6)
routesFirewallRules = append(routesFirewallRules, rules...)
}
}
@@ -887,7 +894,7 @@ func (b *NetworkMapBuilder) getAllRoutePoliciesFromGroups(accessControlGroups []
func (b *NetworkMapBuilder) getRouteFirewallRules(
peerID string, policies []*Policy, route *route.Route, validatedPeersMap map[string]struct{},
distributionPeers map[string]struct{}, account *Account,
distributionPeers map[string]struct{}, account *Account, includeIPv6 bool,
) []*RouteFirewallRule {
ctx := context.Background()
var fwRules []*RouteFirewallRule
@@ -903,7 +910,7 @@ func (b *NetworkMapBuilder) getRouteFirewallRules(
rulePeers := b.getRulePeers(rule, policy.SourcePostureChecks, peerID, distributionPeers, validatedPeersMap, account)
rules := generateRouteFirewallRules(ctx, route, rule, rulePeers, FirewallRuleDirectionIN)
rules := generateRouteFirewallRules(ctx, route, rule, rulePeers, FirewallRuleDirectionIN, includeIPv6)
fwRules = append(fwRules, rules...)
}
}
@@ -1100,14 +1107,17 @@ func (b *NetworkMapBuilder) assembleNetworkMap(
}
}
var routes []*route.Route
includeIPv6 := peer.SupportsIPv6() && peer.IPv6.IsValid()
var rawRoutes []*route.Route
allRouteIDs := slices.Concat(routesView.OwnRouteIDs, routesView.NetworkResourceIDs, routesView.InheritedRouteIDs)
for _, routeID := range allRouteIDs {
if route := b.cache.globalRoutes[routeID]; route != nil {
routes = append(routes, route)
rawRoutes = append(rawRoutes, route)
}
}
routes := filterAndExpandRoutes(rawRoutes, includeIPv6)
var firewallRules []*FirewallRule
for _, ruleID := range aclView.FirewallRuleIDs {
@@ -1654,12 +1664,20 @@ func (b *NetworkMapBuilder) calculateRouteFirewallUpdates(
) {
processedPeerRoutes := make(map[string]map[route.ID]struct{})
peerV6 := ""
if newPeer.IPv6.IsValid() {
peerV6 = newPeer.IPv6.String()
}
for routeID, info := range b.cache.noACGRoutes {
if info.PeerID == newPeerID {
continue
}
b.addRouteFirewallUpdate(updates, info.PeerID, string(routeID), newPeer.IP.String())
if peerV6 != "" {
b.addRouteFirewallUpdate(updates, info.PeerID, string(routeID), peerV6)
}
if processedPeerRoutes[info.PeerID] == nil {
processedPeerRoutes[info.PeerID] = make(map[route.ID]struct{})
@@ -1685,6 +1703,9 @@ func (b *NetworkMapBuilder) calculateRouteFirewallUpdates(
}
b.addRouteFirewallUpdate(updates, info.PeerID, string(routeID), newPeer.IP.String())
if peerV6 != "" {
b.addRouteFirewallUpdate(updates, info.PeerID, string(routeID), peerV6)
}
if processedPeerRoutes[info.PeerID] == nil {
processedPeerRoutes[info.PeerID] = make(map[route.ID]struct{})
@@ -1875,6 +1896,18 @@ func (b *NetworkMapBuilder) addUpdateForPeersInGroups(
Action: string(rule.Action),
Protocol: firewallRuleProtocol(rule.Protocol),
}
var v6fr *FirewallRule
if newPeer.IPv6.IsValid() {
v6fr = &FirewallRule{
PolicyID: rule.ID,
PeerIP: newPeer.IPv6.String(),
Direction: direction,
Action: string(rule.Action),
Protocol: firewallRuleProtocol(rule.Protocol),
}
}
for _, peerID := range peers {
if peerID == newPeerID {
continue
@@ -1893,6 +1926,14 @@ func (b *NetworkMapBuilder) addUpdateForPeersInGroups(
}
b.addOrUpdateFirewallRuleInDelta(updates, peerID, newPeerID, rule, direction, fr, peerIPForRule, targetPeer)
if v6fr != nil && targetPeer.SupportsIPv6() && targetPeer.IPv6.IsValid() {
v6PeerIP := v6fr.PeerIP
if all {
v6PeerIP = "::"
}
b.addOrUpdateFirewallRuleInDelta(updates, peerID, newPeerID, rule, direction, v6fr, v6PeerIP, targetPeer)
}
}
}
}
@@ -1928,6 +1969,17 @@ func (b *NetworkMapBuilder) addUpdateForDirectPeerResource(
}
b.addOrUpdateFirewallRuleInDelta(updates, targetPeerID, newPeerID, rule, direction, fr, fr.PeerIP, targetPeer)
if newPeer.IPv6.IsValid() && targetPeer.SupportsIPv6() && targetPeer.IPv6.IsValid() {
v6fr := &FirewallRule{
PolicyID: rule.ID,
PeerIP: newPeer.IPv6.String(),
Direction: direction,
Action: string(rule.Action),
Protocol: firewallRuleProtocol(rule.Protocol),
}
b.addOrUpdateFirewallRuleInDelta(updates, targetPeerID, newPeerID, rule, direction, v6fr, v6fr.PeerIP, targetPeer)
}
}
func (b *NetworkMapBuilder) addOrUpdateFirewallRuleInDelta(
@@ -2002,34 +2054,46 @@ func (b *NetworkMapBuilder) applyDeltaToPeer(account *Account, peerID string, de
func (b *NetworkMapBuilder) updateRouteFirewallRules(routesView *PeerRoutesView, updates []*RouteFirewallRuleUpdate) {
for _, update := range updates {
isV6Source := strings.Contains(update.AddSourceIP, ":")
for _, ruleID := range routesView.RouteFirewallRuleIDs {
rule := b.cache.globalRouteRules[ruleID]
if rule == nil {
continue
}
if string(rule.RouteID) == update.RuleID {
if hasWildcard := slices.Contains(rule.SourceRanges, allWildcard) || slices.Contains(rule.SourceRanges, v6AllWildcard); hasWildcard {
break
}
if string(rule.RouteID) != update.RuleID {
continue
}
sourceIP := update.AddSourceIP
// Dynamic routes share the same RouteID for v4 and v6 rules.
// Match the source IP family to the rule's destination family.
isV6Rule := strings.Contains(rule.Destination, ":")
if isV6Source != isV6Rule {
continue
}
if strings.Contains(sourceIP, ":") {
sourceIP += "/128" // IPv6
} else {
sourceIP += "/32" // IPv4
}
if !slices.Contains(rule.SourceRanges, sourceIP) {
rule.SourceRanges = append(rule.SourceRanges, sourceIP)
}
if slices.Contains(rule.SourceRanges, allWildcard) || slices.Contains(rule.SourceRanges, v6AllWildcard) {
break
}
sourceIP := update.AddSourceIP
if isV6Source {
sourceIP += "/128"
} else {
sourceIP += "/32"
}
if !slices.Contains(rule.SourceRanges, sourceIP) {
rule.SourceRanges = append(rule.SourceRanges, sourceIP)
}
break
}
}
}
func (b *NetworkMapBuilder) OnPeerDeleted(acc *Account, peerID string) error {
b.cache.mu.Lock()
defer b.cache.mu.Unlock()

View File

@@ -0,0 +1,142 @@
package types
import (
"net/netip"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
nbpeer "github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/route"
)
func newTestBuilder() *NetworkMapBuilder {
return &NetworkMapBuilder{
cache: &NetworkMapCache{
globalRouteRules: make(map[string]*RouteFirewallRule),
noACGRoutes: make(map[route.ID]*RouteOwnerInfo),
acgToRoutes: make(map[string]map[route.ID]*RouteOwnerInfo),
peerRoutes: make(map[string]*PeerRoutesView),
},
}
}
func TestUpdateRouteFirewallRules_FamilyMatching(t *testing.T) {
b := newTestBuilder()
// Simulate a dynamic route with both v4 and v6 rules sharing the same RouteID.
b.cache.globalRouteRules["rule-v4"] = &RouteFirewallRule{
RouteID: "route-dynamic",
SourceRanges: []string{"100.64.0.1/32"},
Destination: "0.0.0.0/0",
}
b.cache.globalRouteRules["rule-v6"] = &RouteFirewallRule{
RouteID: "route-dynamic",
SourceRanges: []string{"fd00::1/128"},
Destination: "::/0",
}
view := &PeerRoutesView{
RouteFirewallRuleIDs: []string{"rule-v4", "rule-v6"},
}
// Add a v4 source: should only go to the v4 rule.
b.updateRouteFirewallRules(view, []*RouteFirewallRuleUpdate{
{RuleID: "route-dynamic", AddSourceIP: "100.64.0.2"},
})
assert.Contains(t, b.cache.globalRouteRules["rule-v4"].SourceRanges, "100.64.0.2/32")
assert.NotContains(t, b.cache.globalRouteRules["rule-v6"].SourceRanges, "100.64.0.2/32",
"v4 source should not leak into v6 rule")
// Add a v6 source: should only go to the v6 rule.
b.updateRouteFirewallRules(view, []*RouteFirewallRuleUpdate{
{RuleID: "route-dynamic", AddSourceIP: "fd00::2"},
})
assert.Contains(t, b.cache.globalRouteRules["rule-v6"].SourceRanges, "fd00::2/128")
assert.NotContains(t, b.cache.globalRouteRules["rule-v4"].SourceRanges, "fd00::2/128",
"v6 source should not leak into v4 rule")
}
func TestUpdateRouteFirewallRules_WildcardSkip(t *testing.T) {
b := newTestBuilder()
b.cache.globalRouteRules["rule-wildcard"] = &RouteFirewallRule{
RouteID: "route-1",
SourceRanges: []string{"0.0.0.0/0"},
Destination: "10.0.0.0/8",
}
view := &PeerRoutesView{
RouteFirewallRuleIDs: []string{"rule-wildcard"},
}
b.updateRouteFirewallRules(view, []*RouteFirewallRuleUpdate{
{RuleID: "route-1", AddSourceIP: "100.64.0.5"},
})
assert.Equal(t, []string{"0.0.0.0/0"}, b.cache.globalRouteRules["rule-wildcard"].SourceRanges,
"wildcard rule should not get individual sources appended")
}
func TestCalculateRouteFirewallUpdates_DualStack(t *testing.T) {
b := newTestBuilder()
// Routing peer "router-1" owns a no-ACG route.
b.cache.noACGRoutes["route-exit"] = &RouteOwnerInfo{
PeerID: "router-1",
RouteID: "route-exit",
}
b.cache.peerRoutes["router-1"] = &PeerRoutesView{}
newPeer := &nbpeer.Peer{
ID: "new-peer",
IP: netip.MustParseAddr("100.64.0.5"),
IPv6: netip.MustParseAddr("fd00::5"),
}
updates := make(map[string]*PeerUpdateDelta)
b.calculateRouteFirewallUpdates("new-peer", newPeer, nil, updates)
require.Contains(t, updates, "router-1")
delta := updates["router-1"]
var v4Found, v6Found bool
for _, u := range delta.UpdateRouteFirewallRules {
if u.RuleID == "route-exit" && u.AddSourceIP == "100.64.0.5" {
v4Found = true
}
if u.RuleID == "route-exit" && u.AddSourceIP == "fd00::5" {
v6Found = true
}
}
assert.True(t, v4Found, "v4 source should be enqueued")
assert.True(t, v6Found, "v6 source should be enqueued")
}
func TestCalculateRouteFirewallUpdates_V4Only(t *testing.T) {
b := newTestBuilder()
b.cache.noACGRoutes["route-1"] = &RouteOwnerInfo{
PeerID: "router-1",
RouteID: "route-1",
}
b.cache.peerRoutes["router-1"] = &PeerRoutesView{}
// Peer without IPv6.
newPeer := &nbpeer.Peer{
ID: "new-peer",
IP: netip.MustParseAddr("100.64.0.5"),
}
updates := make(map[string]*PeerUpdateDelta)
b.calculateRouteFirewallUpdates("new-peer", newPeer, nil, updates)
require.Contains(t, updates, "router-1")
delta := updates["router-1"]
require.Len(t, delta.UpdateRouteFirewallRules, 1)
assert.Equal(t, "100.64.0.5", delta.UpdateRouteFirewallRules[0].AddSourceIP)
}

View File

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