mirror of
https://github.com/netbirdio/netbird.git
synced 2026-04-19 00:36:38 +00:00
[management] Add IPv6 overlay addressing and capability gating (#5698)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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)
|
||||
|
||||
197
management/server/types/firewall_rule_test.go
Normal file
197
management/server/types/firewall_rule_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
234
management/server/types/ipv6_groups_test.go
Normal file
234
management/server/types/ipv6_groups_test.go
Normal 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")
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)...)
|
||||
}
|
||||
|
||||
@@ -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 }(),
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
142
management/server/types/networkmapbuilder_route_fw_test.go
Normal file
142
management/server/types/networkmapbuilder_route_fw_test.go
Normal 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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user