mirror of
https://github.com/juanfont/headscale.git
synced 2026-04-11 03:27:20 +02:00
policy/v2: fix via grants in BuildPeerMap, MatchersForNode, and ViaRoutesForPeer
Use per-node compilation path for via grants in BuildPeerMap and MatchersForNode to ensure via-granted nodes appear in peer maps. Fix ViaRoutesForPeer golden test route inference to correctly resolve via grant effects. Updates #2180
This commit is contained in:
@@ -379,8 +379,10 @@ func (pm *PolicyManager) BuildPeerMap(nodes views.Slice[types.NodeView]) map[typ
|
|||||||
pm.mu.Lock()
|
pm.mu.Lock()
|
||||||
defer pm.mu.Unlock()
|
defer pm.mu.Unlock()
|
||||||
|
|
||||||
// If we have a global filter, use it for all nodes (normal case)
|
// If we have a global filter, use it for all nodes (normal case).
|
||||||
if !pm.usesAutogroupSelf {
|
// Via grants require the per-node path because the global filter
|
||||||
|
// skips via grants (compileFilterRules: if len(grant.Via) > 0 { continue }).
|
||||||
|
if !pm.needsPerNodeFilter {
|
||||||
ret := make(map[types.NodeID][]types.NodeView, nodes.Len())
|
ret := make(map[types.NodeID][]types.NodeView, nodes.Len())
|
||||||
|
|
||||||
// Build the map of all peers according to the matchers.
|
// Build the map of all peers according to the matchers.
|
||||||
@@ -403,7 +405,7 @@ func (pm *PolicyManager) BuildPeerMap(nodes views.Slice[types.NodeView]) map[typ
|
|||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
// For autogroup:self (empty global filter), build per-node peer relationships
|
// For autogroup:self or via grants, build per-node peer relationships
|
||||||
ret := make(map[types.NodeID][]types.NodeView, nodes.Len())
|
ret := make(map[types.NodeID][]types.NodeView, nodes.Len())
|
||||||
|
|
||||||
// Pre-compute per-node matchers using unreduced compiled rules
|
// Pre-compute per-node matchers using unreduced compiled rules
|
||||||
@@ -438,15 +440,21 @@ func (pm *PolicyManager) BuildPeerMap(nodes views.Slice[types.NodeView]) map[typ
|
|||||||
nodeJ := nodes.At(j)
|
nodeJ := nodes.At(j)
|
||||||
matchersJ, hasFilterJ := nodeMatchers[nodeJ.ID()]
|
matchersJ, hasFilterJ := nodeMatchers[nodeJ.ID()]
|
||||||
|
|
||||||
// If either node can access the other, both should see each other as peers.
|
// Check all access directions for symmetric peer visibility.
|
||||||
// This symmetric visibility is required for proper network operation:
|
// For via grants, filter rules exist on the via-designated node
|
||||||
// - Admin with *:* rule should see tagged servers (even if servers
|
// (e.g., router-a) with sources being the client (group-a).
|
||||||
// can't access admin)
|
// We need to check BOTH:
|
||||||
// - Servers should see admin so they can respond to admin's connections
|
// 1. nodeI.CanAccess(matchersI, nodeJ) — can nodeI reach nodeJ?
|
||||||
|
// 2. nodeJ.CanAccess(matchersI, nodeI) — can nodeJ reach nodeI
|
||||||
|
// using nodeI's matchers? (reverse direction: the matchers
|
||||||
|
// on the via node accept traffic FROM the source)
|
||||||
|
// Same for matchersJ in both directions.
|
||||||
canIAccessJ := hasFilterI && nodeI.CanAccess(matchersI, nodeJ)
|
canIAccessJ := hasFilterI && nodeI.CanAccess(matchersI, nodeJ)
|
||||||
canJAccessI := hasFilterJ && nodeJ.CanAccess(matchersJ, nodeI)
|
canJAccessI := hasFilterJ && nodeJ.CanAccess(matchersJ, nodeI)
|
||||||
|
canJReachI := hasFilterI && nodeJ.CanAccess(matchersI, nodeI)
|
||||||
|
canIReachJ := hasFilterJ && nodeI.CanAccess(matchersJ, nodeJ)
|
||||||
|
|
||||||
if canIAccessJ || canJAccessI {
|
if canIAccessJ || canJAccessI || canJReachI || canIReachJ {
|
||||||
ret[nodeI.ID()] = append(ret[nodeI.ID()], nodeJ)
|
ret[nodeI.ID()] = append(ret[nodeI.ID()], nodeJ)
|
||||||
ret[nodeJ.ID()] = append(ret[nodeJ.ID()], nodeI)
|
ret[nodeJ.ID()] = append(ret[nodeJ.ID()], nodeI)
|
||||||
}
|
}
|
||||||
@@ -556,12 +564,14 @@ func (pm *PolicyManager) MatchersForNode(node types.NodeView) ([]matcher.Match,
|
|||||||
pm.mu.Lock()
|
pm.mu.Lock()
|
||||||
defer pm.mu.Unlock()
|
defer pm.mu.Unlock()
|
||||||
|
|
||||||
// For global policies, return the shared global matchers
|
// For global policies, return the shared global matchers.
|
||||||
if !pm.usesAutogroupSelf {
|
// Via grants require per-node matchers because the global matchers
|
||||||
|
// are empty for via-grant-only policies.
|
||||||
|
if !pm.needsPerNodeFilter {
|
||||||
return pm.matchers, nil
|
return pm.matchers, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// For autogroup:self, get unreduced compiled rules and create matchers
|
// For autogroup:self or via grants, get unreduced compiled rules and create matchers
|
||||||
compiledRules, err := pm.compileFilterRulesForNodeLocked(node)
|
compiledRules, err := pm.compileFilterRulesForNodeLocked(node)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -889,7 +899,6 @@ func (pm *PolicyManager) ViaRoutesForPeer(viewer, peer types.NodeView) types.Via
|
|||||||
|
|
||||||
// Collect destination prefixes that the peer actually advertises.
|
// Collect destination prefixes that the peer actually advertises.
|
||||||
peerSubnetRoutes := peer.SubnetRoutes()
|
peerSubnetRoutes := peer.SubnetRoutes()
|
||||||
peerExitRoutes := peer.ExitRoutes()
|
|
||||||
|
|
||||||
var matchedPrefixes []netip.Prefix
|
var matchedPrefixes []netip.Prefix
|
||||||
|
|
||||||
@@ -901,9 +910,11 @@ func (pm *PolicyManager) ViaRoutesForPeer(viewer, peer types.NodeView) types.Via
|
|||||||
matchedPrefixes = append(matchedPrefixes, dstPrefix)
|
matchedPrefixes = append(matchedPrefixes, dstPrefix)
|
||||||
}
|
}
|
||||||
case *AutoGroup:
|
case *AutoGroup:
|
||||||
if d.Is(AutoGroupInternet) && len(peerExitRoutes) > 0 {
|
// autogroup:internet via grants do NOT affect AllowedIPs or
|
||||||
matchedPrefixes = append(matchedPrefixes, peerExitRoutes...)
|
// route steering for exit nodes. Tailscale SaaS handles exit
|
||||||
}
|
// traffic forwarding through the client's exit node selection
|
||||||
|
// mechanism, not through AllowedIPs. Verified by golden
|
||||||
|
// captures GRANT-V14 through GRANT-V36.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,25 +48,15 @@ type goldenCapture struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type goldenNetmap struct {
|
type goldenNetmap struct {
|
||||||
SelfNode json.RawMessage `json:"SelfNode"`
|
|
||||||
Peers []goldenPeer `json:"Peers"`
|
Peers []goldenPeer `json:"Peers"`
|
||||||
PacketFilter json.RawMessage `json:"PacketFilter"`
|
|
||||||
PacketFilterRules json.RawMessage `json:"PacketFilterRules"`
|
PacketFilterRules json.RawMessage `json:"PacketFilterRules"`
|
||||||
DNS json.RawMessage `json:"DNS"`
|
|
||||||
SSHPolicy json.RawMessage `json:"SSHPolicy"`
|
|
||||||
Domain string `json:"Domain"`
|
|
||||||
UserProfiles json.RawMessage `json:"UserProfiles"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type goldenPeer struct {
|
type goldenPeer struct {
|
||||||
Name string `json:"Name"`
|
Name string `json:"Name"`
|
||||||
Addresses []string `json:"Addresses"`
|
AllowedIPs []string `json:"AllowedIPs"`
|
||||||
AllowedIPs []string `json:"AllowedIPs"`
|
PrimaryRoutes []string `json:"PrimaryRoutes"`
|
||||||
PrimaryRoutes []string `json:"PrimaryRoutes"`
|
Tags []string `json:"Tags"`
|
||||||
Tags []string `json:"Tags"`
|
|
||||||
ExitNodeOption *bool `json:"ExitNodeOption"`
|
|
||||||
Online *bool `json:"Online"`
|
|
||||||
Cap int `json:"Cap"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type goldenWhois struct {
|
type goldenWhois struct {
|
||||||
@@ -86,13 +76,11 @@ var viaCompatTests = []struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestViaGrantMapCompat loads golden captures from Tailscale SaaS and
|
// TestViaGrantMapCompat loads golden captures from Tailscale SaaS and
|
||||||
// compares headscale's full MapResponse against the captured netmap.
|
// compares headscale's MapResponse structure against the captured netmap.
|
||||||
//
|
//
|
||||||
// For each viewing node, it compares:
|
// The comparison is IP-independent: it validates peer visibility, route
|
||||||
// - Peer set (which peers are visible)
|
// prefixes in AllowedIPs, and PrimaryRoutes — not literal Tailscale IP
|
||||||
// - Per-peer AllowedIPs (via steering changes which routes appear on which peer)
|
// addresses which differ between Tailscale SaaS and headscale allocation.
|
||||||
// - Per-peer PrimaryRoutes (which node is primary for a subnet)
|
|
||||||
// - PacketFilter rule count
|
|
||||||
func TestViaGrantMapCompat(t *testing.T) {
|
func TestViaGrantMapCompat(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -119,9 +107,7 @@ func TestViaGrantMapCompat(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// taggedNodes are the nodes we create in the servertest. User-owned nodes
|
// taggedNodes are the nodes we create in the servertest.
|
||||||
// are excluded because the servertest uses a single user for all tagged
|
|
||||||
// nodes, which doesn't map to the multi-user Tailscale topology.
|
|
||||||
var taggedNodes = []string{
|
var taggedNodes = []string{
|
||||||
"exit-a", "exit-b", "exit-node",
|
"exit-a", "exit-b", "exit-node",
|
||||||
"group-a-client", "group-b-client",
|
"group-a-client", "group-b-client",
|
||||||
@@ -169,46 +155,20 @@ func runViaMapCompat(t *testing.T, gf goldenFile) {
|
|||||||
|
|
||||||
require.NotEmpty(t, clients, "no relevant nodes created")
|
require.NotEmpty(t, clients, "no relevant nodes created")
|
||||||
|
|
||||||
// Compute expected peer counts from golden netmap.
|
// Determine which routes each node should advertise. If the golden
|
||||||
expectedPeerCounts := map[string]int{}
|
// topology has explicit advertised_routes, use those. Otherwise infer
|
||||||
|
// from the policy's autoApprovers.routes: if a node's tags match an
|
||||||
|
// approver tag for a route prefix, the node should advertise it.
|
||||||
|
nodeRoutes := inferNodeRoutes(gf)
|
||||||
|
|
||||||
for viewerName := range clients {
|
// Advertise and approve routes FIRST. Via grants depend on routes
|
||||||
capture := gf.Captures[viewerName]
|
// being advertised for compileViaGrant to produce filter rules.
|
||||||
if capture.Netmap != nil {
|
|
||||||
// Count peers from golden netmap that are in our client set.
|
|
||||||
count := 0
|
|
||||||
|
|
||||||
for _, peer := range capture.Netmap.Peers {
|
|
||||||
peerName := extractHostname(peer.Name)
|
|
||||||
if _, isOurs := clients[peerName]; isOurs {
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
expectedPeerCounts[viewerName] = count
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for expected peers.
|
|
||||||
for name, c := range clients {
|
for name, c := range clients {
|
||||||
expected := expectedPeerCounts[name]
|
routes := nodeRoutes[name]
|
||||||
if expected > 0 {
|
if len(routes) == 0 {
|
||||||
c.WaitForPeers(t, expected, 30*time.Second)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Advertise and approve routes.
|
|
||||||
for name, c := range clients {
|
|
||||||
topoNode := gf.Topology.Nodes[name]
|
|
||||||
if len(topoNode.AdvertisedRoutes) == 0 {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
var routes []netip.Prefix
|
|
||||||
for _, r := range topoNode.AdvertisedRoutes {
|
|
||||||
routes = append(routes, netip.MustParsePrefix(r))
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Direct().SetHostinfo(&tailcfg.Hostinfo{
|
c.Direct().SetHostinfo(&tailcfg.Hostinfo{
|
||||||
BackendLogID: "servertest-" + name,
|
BackendLogID: "servertest-" + name,
|
||||||
Hostname: name,
|
Hostname: name,
|
||||||
@@ -226,9 +186,31 @@ func runViaMapCompat(t *testing.T, gf goldenFile) {
|
|||||||
srv.App.Change(routeChange)
|
srv.App.Change(routeChange)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for route propagation.
|
// Wait for peers based on golden netmap expected counts.
|
||||||
for _, c := range clients {
|
for viewerName, c := range clients {
|
||||||
c.WaitForCondition(t, "routes settled", 15*time.Second,
|
capture := gf.Captures[viewerName]
|
||||||
|
if capture.Netmap == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
expected := 0
|
||||||
|
|
||||||
|
for _, peer := range capture.Netmap.Peers {
|
||||||
|
peerName := extractHostname(peer.Name)
|
||||||
|
if _, isOurs := clients[peerName]; isOurs {
|
||||||
|
expected++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if expected > 0 {
|
||||||
|
c.WaitForPeers(t, expected, 30*time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure all nodes have received at least one MapResponse,
|
||||||
|
// including nodes with 0 expected peers that skipped WaitForPeers.
|
||||||
|
for name, c := range clients {
|
||||||
|
c.WaitForCondition(t, name+" initial netmap", 15*time.Second,
|
||||||
func(nm *netmap.NetworkMap) bool {
|
func(nm *netmap.NetworkMap) bool {
|
||||||
return nm != nil
|
return nm != nil
|
||||||
})
|
})
|
||||||
@@ -245,14 +227,20 @@ func runViaMapCompat(t *testing.T, gf goldenFile) {
|
|||||||
nm := c.Netmap()
|
nm := c.Netmap()
|
||||||
require.NotNil(t, nm, "netmap is nil")
|
require.NotNil(t, nm, "netmap is nil")
|
||||||
|
|
||||||
compareNetmap(t, viewerName, nm, capture.Netmap, clients)
|
compareNetmap(t, nm, capture.Netmap, clients)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// compareNetmap compares the headscale MapResponse against the golden
|
||||||
|
// netmap data in an IP-independent way. It validates:
|
||||||
|
// - Peer visibility (which peers are present, by hostname)
|
||||||
|
// - Route prefixes in AllowedIPs (non-Tailscale-IP entries like 10.44.0.0/16)
|
||||||
|
// - Number of Tailscale IPs per peer (should be 2: one v4 + one v6)
|
||||||
|
// - PrimaryRoutes per peer
|
||||||
|
// - PacketFilter rule count
|
||||||
func compareNetmap(
|
func compareNetmap(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
_ string, // viewerName unused but kept for signature clarity
|
|
||||||
got *netmap.NetworkMap,
|
got *netmap.NetworkMap,
|
||||||
want *goldenNetmap,
|
want *goldenNetmap,
|
||||||
clients map[string]*servertest.TestClient,
|
clients map[string]*servertest.TestClient,
|
||||||
@@ -293,12 +281,22 @@ func compareNetmap(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
var aips []string
|
// Separate AllowedIPs into Tailscale IPs (node addresses)
|
||||||
|
// and route prefixes (subnets, exit routes).
|
||||||
|
var tsIPs []netip.Prefix
|
||||||
|
|
||||||
|
var routePrefixes []string
|
||||||
|
|
||||||
for i := range peer.AllowedIPs().Len() {
|
for i := range peer.AllowedIPs().Len() {
|
||||||
aips = append(aips, peer.AllowedIPs().At(i).String())
|
prefix := peer.AllowedIPs().At(i)
|
||||||
|
if isTailscaleIP(prefix) {
|
||||||
|
tsIPs = append(tsIPs, prefix)
|
||||||
|
} else {
|
||||||
|
routePrefixes = append(routePrefixes, prefix.String())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
slices.Sort(aips)
|
slices.Sort(routePrefixes)
|
||||||
|
|
||||||
var proutes []string
|
var proutes []string
|
||||||
for i := range peer.PrimaryRoutes().Len() {
|
for i := range peer.PrimaryRoutes().Len() {
|
||||||
@@ -308,28 +306,55 @@ func compareNetmap(
|
|||||||
slices.Sort(proutes)
|
slices.Sort(proutes)
|
||||||
|
|
||||||
gotPeers[name] = peerSummary{
|
gotPeers[name] = peerSummary{
|
||||||
AllowedIPs: aips,
|
TailscaleIPs: tsIPs,
|
||||||
|
RoutePrefixes: routePrefixes,
|
||||||
PrimaryRoutes: proutes,
|
PrimaryRoutes: proutes,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compare peer visibility.
|
// Compare peer visibility: golden peers must be present.
|
||||||
for name, wantPeer := range wantPeers {
|
for name, wantPeer := range wantPeers {
|
||||||
gotPeer, visible := gotPeers[name]
|
gotPeer, visible := gotPeers[name]
|
||||||
if !visible {
|
if !visible {
|
||||||
t.Errorf("peer %s: visible in Tailscale SaaS (AllowedIPs=%v), missing in headscale",
|
wantRoutes := extractRoutePrefixes(wantPeer.AllowedIPs)
|
||||||
name, wantPeer.AllowedIPs)
|
t.Errorf("peer %s: visible in Tailscale SaaS (routes=%v), missing in headscale",
|
||||||
|
name, wantRoutes)
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compare AllowedIPs.
|
// Compare route prefixes in AllowedIPs (IP-independent).
|
||||||
wantAIPs := make([]string, len(wantPeer.AllowedIPs))
|
wantRoutes := extractRoutePrefixes(wantPeer.AllowedIPs)
|
||||||
copy(wantAIPs, wantPeer.AllowedIPs)
|
slices.Sort(wantRoutes)
|
||||||
slices.Sort(wantAIPs)
|
|
||||||
|
|
||||||
assert.Equalf(t, wantAIPs, gotPeer.AllowedIPs,
|
assert.Equalf(t, wantRoutes, gotPeer.RoutePrefixes,
|
||||||
"peer %s: AllowedIPs mismatch", name)
|
"peer %s: route prefixes in AllowedIPs mismatch", name)
|
||||||
|
|
||||||
|
// Tailscale IPs: count should match, and they must belong to
|
||||||
|
// this peer (not some other node's IPs).
|
||||||
|
wantTSIPCount := countTailscaleIPs(wantPeer.AllowedIPs)
|
||||||
|
|
||||||
|
assert.Lenf(t, gotPeer.TailscaleIPs, wantTSIPCount,
|
||||||
|
"peer %s: Tailscale IP count mismatch", name)
|
||||||
|
|
||||||
|
// Verify the Tailscale IPs are actually this peer's addresses.
|
||||||
|
if peerClient, ok := clients[name]; ok {
|
||||||
|
peerNM := peerClient.Netmap()
|
||||||
|
if peerNM != nil && peerNM.SelfNode.Valid() {
|
||||||
|
peerAddrs := map[netip.Prefix]bool{}
|
||||||
|
|
||||||
|
addrs := peerNM.SelfNode.Addresses()
|
||||||
|
for i := range addrs.Len() {
|
||||||
|
peerAddrs[addrs.At(i)] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tsIP := range gotPeer.TailscaleIPs {
|
||||||
|
assert.Truef(t, peerAddrs[tsIP],
|
||||||
|
"peer %s: AllowedIPs contains Tailscale IP %s which is NOT this peer's address (peer has %v)",
|
||||||
|
name, tsIP, peerAddrs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Compare PrimaryRoutes.
|
// Compare PrimaryRoutes.
|
||||||
assert.ElementsMatchf(t, wantPeer.PrimaryRoutes, gotPeer.PrimaryRoutes,
|
assert.ElementsMatchf(t, wantPeer.PrimaryRoutes, gotPeer.PrimaryRoutes,
|
||||||
@@ -355,8 +380,117 @@ func compareNetmap(
|
|||||||
}
|
}
|
||||||
|
|
||||||
type peerSummary struct {
|
type peerSummary struct {
|
||||||
AllowedIPs []string
|
TailscaleIPs []netip.Prefix // Tailscale address entries from AllowedIPs
|
||||||
PrimaryRoutes []string
|
RoutePrefixes []string // non-Tailscale-IP AllowedIPs (sorted)
|
||||||
|
PrimaryRoutes []string // sorted
|
||||||
|
}
|
||||||
|
|
||||||
|
// isTailscaleIP returns true if the prefix is a single-host Tailscale
|
||||||
|
// address (/32 for IPv4 in CGNAT range, /128 for IPv6 in Tailscale ULA).
|
||||||
|
func isTailscaleIP(prefix netip.Prefix) bool {
|
||||||
|
addr := prefix.Addr()
|
||||||
|
|
||||||
|
if addr.Is4() && prefix.Bits() == 32 {
|
||||||
|
// CGNAT range 100.64.0.0/10
|
||||||
|
return addr.As4()[0] == 100 && (addr.As4()[1]&0xC0) == 64
|
||||||
|
}
|
||||||
|
|
||||||
|
if addr.Is6() && prefix.Bits() == 128 {
|
||||||
|
// Tailscale ULA fd7a:115c:a1e0::/48
|
||||||
|
b := addr.As16()
|
||||||
|
|
||||||
|
return b[0] == 0xfd && b[1] == 0x7a && b[2] == 0x11 && b[3] == 0x5c //nolint:gosec // As16 returns [16]byte, indexing [0..3] is safe
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractRoutePrefixes returns the non-Tailscale-IP entries from an
|
||||||
|
// AllowedIPs list (subnet routes, exit routes, etc.).
|
||||||
|
func extractRoutePrefixes(allowedIPs []string) []string {
|
||||||
|
var routes []string
|
||||||
|
|
||||||
|
for _, aip := range allowedIPs {
|
||||||
|
prefix, err := netip.ParsePrefix(aip)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isTailscaleIP(prefix) {
|
||||||
|
routes = append(routes, aip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return routes
|
||||||
|
}
|
||||||
|
|
||||||
|
// countTailscaleIPs returns the number of Tailscale IP entries in an
|
||||||
|
// AllowedIPs list.
|
||||||
|
func countTailscaleIPs(allowedIPs []string) int {
|
||||||
|
count := 0
|
||||||
|
|
||||||
|
for _, aip := range allowedIPs {
|
||||||
|
prefix, err := netip.ParsePrefix(aip)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if isTailscaleIP(prefix) {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
// inferNodeRoutes determines which routes each node should advertise.
|
||||||
|
// If the golden topology has explicit advertised_routes, those are used.
|
||||||
|
// Otherwise, routes are inferred from the golden netmap data: if a node
|
||||||
|
// appears as a peer with route prefixes in AllowedIPs, it should
|
||||||
|
// advertise those routes.
|
||||||
|
func inferNodeRoutes(gf goldenFile) map[string][]netip.Prefix {
|
||||||
|
result := map[string][]netip.Prefix{}
|
||||||
|
|
||||||
|
// First use explicit advertised_routes from topology.
|
||||||
|
for name, node := range gf.Topology.Nodes {
|
||||||
|
for _, r := range node.AdvertisedRoutes {
|
||||||
|
result[name] = append(result[name], netip.MustParsePrefix(r))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If any node already has routes, the topology is populated — use as-is.
|
||||||
|
for _, routes := range result {
|
||||||
|
if len(routes) > 0 {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Infer from the golden netmap: scan all captures for peers with
|
||||||
|
// route prefixes in AllowedIPs. If node X appears as a peer with
|
||||||
|
// route prefix 10.44.0.0/16, then X should advertise that route.
|
||||||
|
for _, capture := range gf.Captures {
|
||||||
|
if capture.Netmap == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, peer := range capture.Netmap.Peers {
|
||||||
|
peerName := extractHostname(peer.Name)
|
||||||
|
routes := extractRoutePrefixes(peer.AllowedIPs)
|
||||||
|
|
||||||
|
for _, r := range routes {
|
||||||
|
prefix, err := netip.ParsePrefix(r)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Contains(result[peerName], prefix) {
|
||||||
|
result[peerName] = append(result[peerName], prefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractHostname extracts the hostname from a Tailscale FQDN like
|
// extractHostname extracts the hostname from a Tailscale FQDN like
|
||||||
|
|||||||
@@ -693,7 +693,11 @@ func (s *State) ListPeers(nodeID types.NodeID, peerIDs ...types.NodeID) views.Sl
|
|||||||
return s.nodeStore.ListPeers(nodeID)
|
return s.nodeStore.ListPeers(nodeID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// For specific peerIDs, filter from all nodes
|
// For specific peerIDs, filter from all nodes.
|
||||||
|
// This path is used for incremental updates (NodeAdded, NodeChanged)
|
||||||
|
// where the caller already knows which peer IDs are involved.
|
||||||
|
// The peer visibility filtering happens in the mapper's buildTailPeers
|
||||||
|
// via MatchersForNode/ReduceNodes.
|
||||||
allNodes := s.nodeStore.ListNodes()
|
allNodes := s.nodeStore.ListNodes()
|
||||||
|
|
||||||
nodeIDSet := make(map[types.NodeID]struct{}, len(peerIDs))
|
nodeIDSet := make(map[types.NodeID]struct{}, len(peerIDs))
|
||||||
|
|||||||
Reference in New Issue
Block a user