// This file implements data-driven via grant compatibility tests using // golden data captured from Tailscale SaaS. These scenarios exercise via // grant steering with peer connectivity, cross-subnet forwarding and // exit nodes. // // Test data source: ../policy/v2/testdata/grant_results/, see viaCompatTests // Source format: github.com/juanfont/headscale/hscontrol/types/testcapture package servertest_test import ( "context" "fmt" "maps" "net/netip" "path/filepath" "slices" "strings" "testing" "time" "github.com/juanfont/headscale/hscontrol/servertest" "github.com/juanfont/headscale/hscontrol/types/testcapture" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go4.org/netipx" "tailscale.com/tailcfg" "tailscale.com/tailcfg/peercap" "tailscale.com/types/netmap" ) // viaCompatTests lists golden captures that exercise via grant steering. var viaCompatTests = []struct { id string desc string }{ {"grant-v13", "group src steers subnet via router to members only"}, {"via-grant-v29", "crossed subnet steering: group-a via router-a, group-b via router-b"}, {"via-grant-v30", "crossed mixed: subnet via router-a/b, exit via exit-b/a"}, {"via-grant-v31", "peer connectivity + via exit A/B steering"}, {"via-grant-v33", "single via grant + HA primary election"}, {"via-grant-v35", "via grant with unadvertised destination"}, {"via-grant-v36", "full complex: peer connectivity + crossed subnet + crossed exit"}, {"via-grant-v52", "members reach the internet only via tag:exit, admins reach everything"}, {"via-grant-v53", "via exit steering plus a member rule to a tagged host port"}, {"via-grant-v54", "via exit steering plus a member rule to autogroup:self"}, {"via-grant-v55", "via exit steering plus a member rule to a private subnet"}, {"via-grant-v56", "via exit steering plus a plain member rule to autogroup:internet"}, {"via-grant-v57", "via exit steering plus a member rule to a non-via exit node"}, {"via-grant-v58", "no via grant: approved exit nodes plus a member rule to a tagged host port"}, {"via-grant-v59", "via exit steering for tagged clients; members have an unrelated rule"}, {"via-grant-v60", "user-owned exit node outside the via tag; members have an autogroup:self rule"}, {"via-grant-v61", "via exit steering plus a member capability grant to a tagged host"}, {"via-grant-v62", "via exit steering plus a member rule to autogroup:internet on one port"}, } // TestViaGrantMapCompat loads golden captures from Tailscale SaaS and // compares headscale's [tailcfg.MapResponse] structure against the captured [netmap.NetworkMap]. // // The comparison is IP-independent: it validates peer visibility, route // prefixes in AllowedIPs, and PrimaryRoutes — not literal Tailscale IP // addresses which differ between Tailscale SaaS and headscale allocation. // // CROSS-DEPENDENCY WARNING: // This test reads golden files from ../policy/v2/testdata/grant_results/ // (the ones listed in viaCompatTests). These files are shared // with TestGrantsCompat in the policy/v2 package. Any changes to the // file format, field structure, or naming must be coordinated with // BOTH tests. // // Fields consumed by this test (but NOT by TestGrantsCompat): // - captures[name].netmap (Peers, AllowedIPs, PrimaryRoutes, PacketFilterRules) // - topology.nodes[name].tags and .user (used for servertest node creation) // // Fields consumed by TestGrantsCompat (but NOT by this test): // - captures[name].packet_filter_rules (golden filter rule comparison) // - input.api_response_code/body (error case handling) func TestViaGrantMapCompat(t *testing.T) { t.Parallel() for _, tc := range viaCompatTests { t.Run(tc.id, func(t *testing.T) { t.Parallel() path := filepath.Join( "..", "policy", "v2", "testdata", "grant_results", tc.id+".hujson", ) c, err := testcapture.Read(path) require.NoError(t, err, "failed to read %s", path) if c.Error { t.Skipf("test %s is an error case", tc.id) return } runViaMapCompat(t, c) }) } } func runViaMapCompat(t *testing.T, c *testcapture.Capture) { t.Helper() srv := servertest.NewServer(t) tagUser := srv.CreateUser(t, "tag-user") users := createCaptureUsers(t, srv) policyJSON := convertCapturePolicy(t, c) changed, err := srv.State().SetPolicy(policyJSON) require.NoError(t, err, "failed to set policy") if changed { changes, err := srv.State().ReloadPolicy() require.NoError(t, err) srv.App.Change(changes...) } // Create clients matching the golden topology: tagged nodes under // tag-user, user-owned nodes under their capture user. // Nodes are created in SaaS registration order so headscale assigns // sequential DB IDs in the same relative order. This matters for // PrimaryRoutes election which uses lowest-node-ID-wins — the // tiebreaker must pick the same node as SaaS. clients := map[string]*servertest.TestClient{} order := captureNodeOrder(t, c) for _, name := range order { topoNode, exists := c.Topology.Nodes[name] if !exists { continue } if _, inCaptures := c.Captures[name]; !inCaptures { continue } owner := tagUser if len(topoNode.Tags) == 0 { require.Containsf(t, users, topoNode.User, "node %s: owner %q is not a capture user", name, topoNode.User) owner = users[topoNode.User] } clients[name] = servertest.NewClient(t, srv, name, servertest.WithUser(owner), servertest.WithTags(topoNode.Tags...), ) } require.NotEmpty(t, clients, "no relevant nodes created") // Determine which routes each node should advertise. If the golden // topology has explicit routable_ips, use those. Otherwise infer // from the [netmap.NetworkMap] peer AllowedIPs and packet filter dst prefixes. nodeRoutes := inferNodeRoutes(t, c) // Build approved routes from topology. The topology's approved_routes // field records what SaaS actually approved (which may be a subset of // routable_ips). Using this instead of approving all advertised routes // ensures exit routes are only approved when SaaS approved them. nodeApproved := map[string][]netip.Prefix{} for name, node := range c.Topology.Nodes { for _, r := range node.ApprovedRoutes { nodeApproved[name] = append( nodeApproved[name], netip.MustParsePrefix(r), ) } } // Advertise and approve routes in SaaS registration order. Via // grants depend on routes being advertised for compileViaGrant to // produce filter rules. The order matters because PrimaryRoutes // election is sticky — the first node to register a prefix becomes // primary. for _, name := range order { cl, ok := clients[name] if !ok { continue } routes := nodeRoutes[name] if len(routes) == 0 { continue } cl.Direct().SetHostinfo(&tailcfg.Hostinfo{ BackendLogID: "servertest-" + name, Hostname: name, RoutableIPs: routes, }) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) require.NoError(t, cl.Direct().SendUpdate(ctx), "route advertisement for %s should succeed", name) cancel() nodeID := findNodeID(t, srv, name) _, routeChange, err := srv.State().SetApprovedRoutes( nodeID, nodeApproved[name], ) require.NoError(t, err) srv.App.Change(routeChange) } // Wait for peers based on golden [netmap.NetworkMap] expected counts. for viewerName, cl := range clients { capture := c.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 { cl.WaitForPeers(t, expected, 30*time.Second) } } // Ensure all nodes have received at least one [tailcfg.MapResponse], // including nodes with 0 expected peers that skipped [TestClient.WaitForPeers]. for name, cl := range clients { cl.WaitForCondition(t, name+" initial netmap", 15*time.Second, func(nm *netmap.NetworkMap) bool { return nm != nil }) } saasAddrs := saasAddrsByNode(c, clients) // Compare each viewer's [tailcfg.MapResponse] against the golden [netmap.NetworkMap]. // Route approvals reach clients asynchronously, so the peer-count wait // above says nothing about routes, primaries or filters: retry the // whole comparison until the viewer's netmap has caught up. for viewerName, cl := range clients { capture := c.Captures[viewerName] if capture.Netmap == nil { continue } t.Run(viewerName, func(t *testing.T) { requireNetmapHolds(t, func(tt require.TestingT) { nm := cl.Netmap() require.NotNil(tt, nm, "netmap is nil") // Routes were approved on live sessions: verify that approval // reached SelfNode throughout the stable comparison window. require.Equal(tt, selfOffersExit(capture.Netmap), selfOffersExit(nm), "SelfNode exit routes should match SaaS") compareNetmap(tt, nm, capture, clients, saasAddrs) }) }) } } // requireNetmapHolds retries check until it passes on several consecutive // ticks. One passing tick is not enough: a netmap that matches and then // drifts, such as a primary flipping away, is the bug these oracles guard. func requireNetmapHolds(t *testing.T, check func(require.TestingT)) { t.Helper() // Long enough for a batch still in flight to land. const holdTicks = 5 held := 0 require.EventuallyWithT(t, func(collect *assert.CollectT) { tick := &failRecorder{CollectT: collect} // Deferred because FailNow exits the goroutine. defer func() { if tick.failed { held = 0 return } held++ if held < holdTicks { collect.Errorf("netmap held for %d of %d ticks", held, holdTicks) } }() check(tick) }, 30*time.Second, 100*time.Millisecond) } // failRecorder notes whether a tick failed, which [assert.CollectT] does // not expose. type failRecorder struct { *assert.CollectT failed bool } func (r *failRecorder) Errorf(format string, args ...any) { r.failed = true r.CollectT.Errorf(format, args...) } func (r *failRecorder) FailNow() { r.failed = true r.CollectT.FailNow() } // compareNetmap compares the headscale [tailcfg.MapResponse] against the // captured [netmap.NetworkMap] 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 (source, destination, ports) triples // // It takes [require.TestingT] so it can run inside [requireNetmapHolds]. func compareNetmap( t require.TestingT, got *netmap.NetworkMap, want testcapture.Node, clients map[string]*servertest.TestClient, saasAddrs map[netip.Addr]string, ) { if h, ok := t.(interface{ Helper() }); ok { h.Helper() } require.NotNil(t, want.Netmap, "golden Netmap is nil") // Build golden peer map (only peers in our client set). wantPeers := map[string]tailcfg.NodeView{} for _, p := range want.Netmap.Peers { name := extractHostname(p.Name()) if _, isOurs := clients[name]; isOurs { wantPeers[name] = p } } // Build headscale peer map. gotPeers := map[string]peerSummary{} for _, peer := range got.Peers { name := "" if peer.Hostinfo().Valid() { name = peer.Hostinfo().Hostname() } if name == "" { for n := range clients { if strings.Contains(peer.Name(), n+".") { name = n break } } } if name == "" { continue } // 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() { prefix := peer.AllowedIPs().At(i) if isTailscaleIP(prefix) { tsIPs = append(tsIPs, prefix) } else { routePrefixes = append(routePrefixes, prefix.String()) } } slices.Sort(routePrefixes) var proutes []string for i := range peer.PrimaryRoutes().Len() { proutes = append(proutes, peer.PrimaryRoutes().At(i).String()) } slices.Sort(proutes) gotPeers[name] = peerSummary{ TailscaleIPs: tsIPs, RoutePrefixes: routePrefixes, PrimaryRoutes: proutes, } } // Compare peer visibility: golden peers must be present. for name, wantPeer := range wantPeers { gotPeer, visible := gotPeers[name] if !visible { wantRoutes := extractRoutePrefixesView(wantPeer.AllowedIPs()) t.Errorf("peer %s: visible in Tailscale SaaS (routes=%v), missing in headscale", name, wantRoutes) continue } // Compare route prefixes in AllowedIPs (IP-independent). wantRoutes := extractRoutePrefixesView(wantPeer.AllowedIPs()) slices.Sort(wantRoutes) assert.Equalf(t, wantRoutes, gotPeer.RoutePrefixes, "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 := countTailscaleIPsView(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. var wantPRoutes []string for i := range wantPeer.PrimaryRoutes().Len() { wantPRoutes = append(wantPRoutes, wantPeer.PrimaryRoutes().At(i).String()) } assert.ElementsMatchf(t, wantPRoutes, gotPeer.PrimaryRoutes, "peer %s: PrimaryRoutes mismatch", name) } // Check for extra peers headscale shows that Tailscale SaaS doesn't. for name := range gotPeers { if _, expected := wantPeers[name]; !expected { t.Errorf("peer %s: visible in headscale but NOT in Tailscale SaaS", name) } } comparePacketFilter(t, got, want, clients, saasAddrs) } // comparePacketFilter compares PacketFilter rules as a set of (source, // destination, ports or capability) triples. Headscale merges rules // that share sources while SaaS keeps one rule per policy entry, so rule // count and order differ without changing what the filter allows. // Tailscale IPs differ between SaaS and headscale allocation, so both // sides are re-keyed by peer identity. func comparePacketFilter( t require.TestingT, got *netmap.NetworkMap, want testcapture.Node, clients map[string]*servertest.TestClient, saasAddrs map[netip.Addr]string, ) { if h, ok := t.(interface{ Helper() }); ok { h.Helper() } hsAddrs := hsAddrsByPeer(clients) wantTriples := map[string]struct{}{} for i, rule := range want.PacketFilterRules { srcs := strings.Join(canonicaliseSrcStrings(t, rule.SrcIPs, saasAddrs, i), ",") for _, dp := range rule.DstPorts { pfxs, err := parseDstPrefixes(dp.IP) require.NoErrorf(t, err, "golden DstPorts[%d].IP %q should parse as prefix, addr or range", i, dp.IP) for _, pfx := range pfxs { for _, dst := range peerIdents(t, pfx, saasAddrs, i) { wantTriples[filterTriple(srcs, dst, dp.Ports.First, dp.Ports.Last)] = struct{}{} } } } for _, cg := range rule.CapGrant { caps := slices.Collect(maps.Keys(cg.CapMap)) caps = append(caps, cg.Caps...) for _, pfx := range cg.Dsts { for _, dst := range peerIdents(t, pfx, saasAddrs, i) { for _, c := range caps { wantTriples[capTriple(srcs, dst, c)] = struct{}{} } } } } } gotTriples := map[string]struct{}{} for i, match := range got.PacketFilter { srcs := strings.Join(canonicaliseSrcPrefixes(t, match.Srcs, hsAddrs, i), ",") for _, dp := range match.Dsts { for _, dst := range peerIdents(t, dp.Net, hsAddrs, i) { gotTriples[filterTriple(srcs, dst, dp.Ports.First, dp.Ports.Last)] = struct{}{} } } for _, cm := range match.Caps { for _, dst := range peerIdents(t, cm.Dst, hsAddrs, i) { gotTriples[capTriple(srcs, dst, cm.Cap)] = struct{}{} } } } assert.ElementsMatchf(t, sortedKeys(wantTriples), sortedKeys(gotTriples), "PacketFilter (source, destination, ports) mismatch") } func filterTriple(srcs, dst string, first, last uint16) string { return fmt.Sprintf("%s => %s:%d-%d", srcs, dst, first, last) } func capTriple(srcs, dst string, c peercap.Cap) string { return fmt.Sprintf("%s => %s cap %s", srcs, dst, c) } // peerIdents resolves a prefix into sorted canonical identity tokens, // see [addIdentsForSrc]. func peerIdents( t require.TestingT, pfx netip.Prefix, addrToPeer map[netip.Addr]string, ruleIndex int, ) []string { if h, ok := t.(interface{ Helper() }); ok { h.Helper() } seen := map[string]struct{}{} addIdentsForSrc(t, pfx, addrToPeer, ruleIndex, seen) return sortedKeys(seen) } // saasAddrsByNode maps each SaaS Tailscale address to its node's // hostname using every captured node's own addresses. A rule may name // sources that are not the viewer's peers, so peer lists are not // enough. Nodes not in clients are skipped. func saasAddrsByNode( c *testcapture.Capture, clients map[string]*servertest.TestClient, ) map[netip.Addr]string { out := map[netip.Addr]string{} for name, node := range c.Captures { if _, isOurs := clients[name]; !isOurs { continue } if node.Netmap == nil || !node.Netmap.SelfNode.Valid() { continue } addrs := node.Netmap.SelfNode.Addresses() for i := range addrs.Len() { pfx := addrs.At(i) if isTailscaleIP(pfx) { out[pfx.Addr()] = name } } } return out } // hsAddrsByPeer builds a map from headscale Tailscale address to peer // hostname by walking each live client's self addresses. func hsAddrsByPeer(clients map[string]*servertest.TestClient) map[netip.Addr]string { out := map[netip.Addr]string{} for name, cl := range clients { nm := cl.Netmap() if nm == nil || !nm.SelfNode.Valid() { continue } addrs := nm.SelfNode.Addresses() for i := range addrs.Len() { pfx := addrs.At(i) if isTailscaleIP(pfx) { out[pfx.Addr()] = name } } } return out } // canonicaliseSrcStrings converts a SrcIPs slice (as produced by the // SaaS wire format) into a sorted list of canonical identifiers: "*" // for wildcard, "peer:" for each Tailscale address or prefix // that resolves to a known peer, or the raw CIDR string for // non-Tailscale prefixes. A Tailscale prefix wider than /32 (IPv4) // or /128 (IPv6) expands to the union of its contained peers. // Unresolvable Tailscale-range sources fail the test. func canonicaliseSrcStrings( t require.TestingT, srcs []string, addrToPeer map[netip.Addr]string, ruleIndex int, ) []string { if h, ok := t.(interface{ Helper() }); ok { h.Helper() } seen := map[string]struct{}{} for _, src := range srcs { if src == "*" { seen["*"] = struct{}{} continue } pfx, err := parsePrefixOrAddr(src) require.NoErrorf(t, err, "PacketFilter[%d]: unparseable SrcIP %q", ruleIndex, src) addIdentsForSrc(t, pfx, addrToPeer, ruleIndex, seen) } return sortedKeys(seen) } // canonicaliseSrcPrefixes is the headscale-side counterpart of // [canonicaliseSrcStrings], reading already-parsed [netip.Prefix] values // from [tailcfg.Match.Srcs]. func canonicaliseSrcPrefixes( t require.TestingT, srcs []netip.Prefix, addrToPeer map[netip.Addr]string, ruleIndex int, ) []string { if h, ok := t.(interface{ Helper() }); ok { h.Helper() } seen := map[string]struct{}{} for _, pfx := range srcs { if pfx.Bits() == 0 && pfx.Addr().IsUnspecified() { seen["*"] = struct{}{} continue } addIdentsForSrc(t, pfx, addrToPeer, ruleIndex, seen) } return sortedKeys(seen) } // addIdentsForSrc resolves one source prefix into canonical identity // tokens and inserts them into seen. A non-Tailscale prefix passes // through literally; a Tailscale-range prefix expands to the union // of peer names whose addresses fall within it. func addIdentsForSrc( t require.TestingT, pfx netip.Prefix, addrToPeer map[netip.Addr]string, ruleIndex int, seen map[string]struct{}, ) { if h, ok := t.(interface{ Helper() }); ok { h.Helper() } if !prefixInTailscaleRange(pfx) { seen[pfx.String()] = struct{}{} return } matched := false for addr, name := range addrToPeer { if pfx.Contains(addr) { seen["peer:"+name] = struct{}{} matched = true } } require.Truef(t, matched, "PacketFilter[%d]: Tailscale-range SrcIP %s does not cover any known peer; addrToPeer=%v", ruleIndex, pfx, addrToPeer) } // prefixInTailscaleRange reports whether a prefix lies entirely // within the Tailscale CGNAT range (100.64.0.0/10) or Tailscale ULA // range (fd7a:115c:a1e0::/48), regardless of prefix length. func prefixInTailscaleRange(p netip.Prefix) bool { addr := p.Addr() if addr.Is4() { return addr.As4()[0] == 100 && (addr.As4()[1]&0xC0) == 64 } if addr.Is6() { 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 } func sortedKeys(m map[string]struct{}) []string { out := make([]string, 0, len(m)) for k := range m { out = append(out, k) } slices.Sort(out) return out } type peerSummary struct { TailscaleIPs []netip.Prefix // Tailscale address entries from AllowedIPs RoutePrefixes []string // non-Tailscale-IP AllowedIPs (sorted) PrimaryRoutes []string // sorted } // parsePrefixOrAddr parses a string as a [netip.Prefix]. If the string // is a bare IP address (no slash), it is converted to a single-host // prefix (/32 for IPv4, /128 for IPv6). Golden data DstPorts.IP can // contain either form. func parsePrefixOrAddr(s string) (netip.Prefix, error) { pfx, err := netip.ParsePrefix(s) if err == nil { return pfx, nil } addr, addrErr := netip.ParseAddr(s) if addrErr != nil { return netip.Prefix{}, err // return original prefix error } return netip.PrefixFrom(addr, addr.BitLen()), nil } // parseDstPrefixes parses a golden DstPorts.IP into the prefixes a client // derives from it. Besides a prefix or bare address, SaaS writes "*" for // every address and an address range for autogroup:internet // ("0.0.0.0-9.255.255.255"). func parseDstPrefixes(s string) ([]netip.Prefix, error) { if s == "*" { return []netip.Prefix{ netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"), }, nil } if strings.Contains(s, "-") { r, err := netipx.ParseIPRange(s) if err != nil { return nil, err } return r.Prefixes(), nil } pfx, err := parsePrefixOrAddr(s) if err != nil { return nil, err } return []netip.Prefix{pfx}, nil } // 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 } // extractRoutePrefixesView returns the non-Tailscale-IP entries from // a typed AllowedIPs view (subnet routes, exit routes, etc.). func extractRoutePrefixesView(allowedIPs interface { Len() int At(i int) netip.Prefix }, ) []string { var routes []string for i := range allowedIPs.Len() { pfx := allowedIPs.At(i) if !isTailscaleIP(pfx) { routes = append(routes, pfx.String()) } } return routes } // countTailscaleIPsView returns the number of Tailscale IP entries // in a typed AllowedIPs view. func countTailscaleIPsView(allowedIPs interface { Len() int At(i int) netip.Prefix }, ) int { count := 0 for i := range allowedIPs.Len() { if isTailscaleIP(allowedIPs.At(i)) { count++ } } return count } // inferNodeRoutes determines which routes each node should advertise. // If the topology has explicit routable_ips, those are used. Otherwise // routes are inferred from the [netmap.NetworkMap] peer AllowedIPs and packet // filter destination prefixes. func inferNodeRoutes(t *testing.T, c *testcapture.Capture) map[string][]netip.Prefix { t.Helper() result := map[string][]netip.Prefix{} // First use explicit routable_ips from topology. for name, node := range c.Topology.Nodes { for _, r := range node.RoutableIPs { 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 } } // Tier 2: infer from each capture's [netmap.NetworkMap] — scan 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 _, node := range c.Captures { if node.Netmap == nil { continue } for _, peer := range node.Netmap.Peers { peerName := extractHostname(peer.Name()) for i := range peer.AllowedIPs().Len() { pfx := peer.AllowedIPs().At(i) if isTailscaleIP(pfx) { continue } if !slices.Contains(result[peerName], pfx) { result[peerName] = append(result[peerName], pfx) } } } } // Tier 3: infer from packet_filter_rules DstPorts — secondary HA // routers whose routes don't appear in AllowedIPs (only the // primary gets the route in AllowedIPs) DO receive filter rules // for those routes. for nodeName, node := range c.Captures { for _, rule := range node.PacketFilterRules { for _, dp := range rule.DstPorts { if dp.IP == "*" { continue } prefix, err := parsePrefixOrAddr(dp.IP) require.NoErrorf(t, err, "golden DstPorts.IP %q for %s unparseable", dp.IP, nodeName) if isTailscaleIP(prefix) { continue } if !slices.Contains(result[nodeName], prefix) { result[nodeName] = append( result[nodeName], prefix, ) } } } } return result } // extractHostname extracts the hostname from a Tailscale FQDN like // "router-a.tail78f774.ts.net.". func extractHostname(fqdn string) string { if before, _, ok := strings.Cut(fqdn, "."); ok { return before } return fqdn }