From 7d104b8c8dde06435460cd812f45efa959b436fe Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 15 Apr 2026 08:32:17 +0000 Subject: [PATCH] servertest: add via grant map compat tests End-to-end exercise of via-grant compilation against SaaS captures: peer visibility, AllowedIPs, PrimaryRoutes, and per-rule src/dst reachability from each viewer's perspective. Updates #3157 --- hscontrol/servertest/via_compat_test.go | 598 +++++++++++++++++------- hscontrol/state/state.go | 70 ++- 2 files changed, 478 insertions(+), 190 deletions(-) diff --git a/hscontrol/servertest/via_compat_test.go b/hscontrol/servertest/via_compat_test.go index b1a7972f..b87f520f 100644 --- a/hscontrol/servertest/via_compat_test.go +++ b/hscontrol/servertest/via_compat_test.go @@ -1,10 +1,15 @@ +// This file implements data-driven via grant compatibility tests using +// golden data captured from Tailscale SaaS (v29, v30, v31, v33, v35, +// v36). These scenarios exercise via grant steering with peer +// connectivity and cross-subnet forwarding. +// +// Test data source: ../policy/v2/testdata/grant_results/via-grant-v{29,30,31,33,35,36}.hujson +// Source format: github.com/juanfont/headscale/hscontrol/types/testcapture package servertest_test import ( "context" - "encoding/json" "net/netip" - "os" "path/filepath" "slices" "strings" @@ -12,68 +17,24 @@ import ( "time" "github.com/juanfont/headscale/hscontrol/servertest" + "github.com/juanfont/headscale/hscontrol/types/testcapture" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/tailscale/hujson" "tailscale.com/tailcfg" "tailscale.com/types/netmap" ) -// goldenFile represents a golden capture from Tailscale SaaS with full -// netmap data per node. -type goldenFile struct { - TestID string `json:"test_id"` - Error bool `json:"error"` - Input struct { - FullPolicy json.RawMessage `json:"full_policy"` - } `json:"input"` - Topology struct { - Nodes map[string]goldenNode `json:"nodes"` - } `json:"topology"` - Captures map[string]goldenCapture `json:"captures"` -} - -type goldenNode struct { - Hostname string `json:"hostname"` - Tags []string `json:"tags"` - IPv4 string `json:"ipv4"` - IPv6 string `json:"ipv6"` - AdvertisedRoutes []string `json:"advertised_routes"` - IsExitNode bool `json:"is_exit_node"` -} - -type goldenCapture struct { - PacketFilterRules json.RawMessage `json:"packet_filter_rules"` - Netmap *goldenNetmap `json:"netmap"` - Whois map[string]goldenWhois `json:"whois"` -} - -type goldenNetmap struct { - Peers []goldenPeer `json:"Peers"` - PacketFilterRules json.RawMessage `json:"PacketFilterRules"` -} - -type goldenPeer struct { - Name string `json:"Name"` - AllowedIPs []string `json:"AllowedIPs"` - PrimaryRoutes []string `json:"PrimaryRoutes"` - Tags []string `json:"Tags"` -} - -type goldenWhois struct { - PeerName string `json:"peer_name"` - Response *json.RawMessage `json:"response"` -} - // viaCompatTests lists golden captures that exercise via grant steering. var viaCompatTests = []struct { id string desc string }{ - {"GRANT-V29", "crossed subnet steering: group-a via router-a, group-b via router-b"}, - {"GRANT-V30", "crossed mixed: subnet via router-a/b, exit via exit-b/a"}, - {"GRANT-V31", "peer connectivity + via exit A/B steering"}, - {"GRANT-V36", "full complex: peer connectivity + crossed subnet + crossed exit"}, + {"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"}, } // TestViaGrantMapCompat loads golden captures from Tailscale SaaS and @@ -85,9 +46,10 @@ var viaCompatTests = []struct { // // CROSS-DEPENDENCY WARNING: // This test reads golden files from ../policy/v2/testdata/grant_results/ -// (specifically GRANT-V29, V30, V31, V36). 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. +// (specifically via-grant-v29, v30, v31, v33, v35, v36). 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) @@ -106,43 +68,28 @@ func TestViaGrantMapCompat(t *testing.T) { path := filepath.Join( "..", "policy", "v2", "testdata", "grant_results", tc.id+".hujson", ) - data, err := os.ReadFile(path) - require.NoError(t, err, "failed to read golden file %s", path) - ast, err := hujson.Parse(data) - require.NoError(t, err, "failed to parse HuJSON in %s", path) - ast.Standardize() + c, err := testcapture.Read(path) + require.NoError(t, err, "failed to read %s", path) - var gf goldenFile - require.NoError(t, json.Unmarshal(ast.Pack(), &gf)) - - if gf.Error { + if c.Error { t.Skipf("test %s is an error case", tc.id) + return } - runViaMapCompat(t, gf) + runViaMapCompat(t, c) }) } } -// taggedNodes are the nodes we create in the servertest. -var taggedNodes = []string{ - "exit-a", "exit-b", "exit-node", - "group-a-client", "group-b-client", - "router-a", "router-b", - "subnet-router", "tagged-client", - "tagged-server", "tagged-prod", - "multi-exit-router", -} - -func runViaMapCompat(t *testing.T, gf goldenFile) { +func runViaMapCompat(t *testing.T, c *testcapture.Capture) { t.Helper() srv := servertest.NewServer(t) tagUser := srv.CreateUser(t, "tag-user") - policyJSON := convertViaPolicy(gf.Input.FullPolicy) + policyJSON := convertCapturePolicy(t, c) changed, err := srv.State().SetPolicy(policyJSON) require.NoError(t, err, "failed to set policy") @@ -154,15 +101,20 @@ func runViaMapCompat(t *testing.T, gf goldenFile) { } // Create tagged clients matching the golden topology. + // 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 taggedNodes { - topoNode, exists := gf.Topology.Nodes[name] + for _, name := range order { + topoNode, exists := c.Topology.Nodes[name] if !exists || len(topoNode.Tags) == 0 { continue } - if _, inCaptures := gf.Captures[name]; !inCaptures { + if _, inCaptures := c.Captures[name]; !inCaptures { continue } @@ -175,39 +127,63 @@ func runViaMapCompat(t *testing.T, gf goldenFile) { require.NotEmpty(t, clients, "no relevant nodes created") // Determine which routes each node should advertise. If the golden - // 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) + // topology has explicit routable_ips, use those. Otherwise infer + // from the netmap 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 + } - // Advertise and approve routes FIRST. Via grants depend on routes - // being advertised for compileViaGrant to produce filter rules. - for name, c := range clients { routes := nodeRoutes[name] if len(routes) == 0 { continue } - c.Direct().SetHostinfo(&tailcfg.Hostinfo{ + cl.Direct().SetHostinfo(&tailcfg.Hostinfo{ BackendLogID: "servertest-" + name, Hostname: name, RoutableIPs: routes, }) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - _ = c.Direct().SendUpdate(ctx) + 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, routes) + _, routeChange, err := srv.State().SetApprovedRoutes( + nodeID, nodeApproved[name], + ) require.NoError(t, err) srv.App.Change(routeChange) } // Wait for peers based on golden netmap expected counts. - for viewerName, c := range clients { - capture := gf.Captures[viewerName] + for viewerName, cl := range clients { + capture := c.Captures[viewerName] if capture.Netmap == nil { continue } @@ -215,62 +191,64 @@ func runViaMapCompat(t *testing.T, gf goldenFile) { expected := 0 for _, peer := range capture.Netmap.Peers { - peerName := extractHostname(peer.Name) + peerName := extractHostname(peer.Name()) if _, isOurs := clients[peerName]; isOurs { expected++ } } if expected > 0 { - c.WaitForPeers(t, expected, 30*time.Second) + cl.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, + for name, cl := range clients { + cl.WaitForCondition(t, name+" initial netmap", 15*time.Second, func(nm *netmap.NetworkMap) bool { return nm != nil }) } // Compare each viewer's MapResponse against the golden netmap. - for viewerName, c := range clients { - capture := gf.Captures[viewerName] + for viewerName, cl := range clients { + capture := c.Captures[viewerName] if capture.Netmap == nil { continue } t.Run(viewerName, func(t *testing.T) { - nm := c.Netmap() + nm := cl.Netmap() require.NotNil(t, nm, "netmap is nil") - compareNetmap(t, nm, capture.Netmap, clients) + compareNetmap(t, nm, capture, clients) }) } } -// compareNetmap compares the headscale MapResponse against the golden -// netmap data in an IP-independent way. It validates: +// compareNetmap compares the headscale MapResponse against the +// captured 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 +// - PacketFilter rule count and non-Tailscale dst prefixes func compareNetmap( t *testing.T, got *netmap.NetworkMap, - want *goldenNetmap, + want testcapture.Node, clients map[string]*servertest.TestClient, ) { t.Helper() - // Build golden peer map (only peers in our client set). - wantPeers := map[string]goldenPeer{} + require.NotNil(t, want.Netmap, "golden Netmap is nil") - for _, p := range want.Peers { - name := extractHostname(p.Name) + // 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 } @@ -335,7 +313,7 @@ func compareNetmap( for name, wantPeer := range wantPeers { gotPeer, visible := gotPeers[name] if !visible { - wantRoutes := extractRoutePrefixes(wantPeer.AllowedIPs) + wantRoutes := extractRoutePrefixesView(wantPeer.AllowedIPs()) t.Errorf("peer %s: visible in Tailscale SaaS (routes=%v), missing in headscale", name, wantRoutes) @@ -343,7 +321,7 @@ func compareNetmap( } // Compare route prefixes in AllowedIPs (IP-independent). - wantRoutes := extractRoutePrefixes(wantPeer.AllowedIPs) + wantRoutes := extractRoutePrefixesView(wantPeer.AllowedIPs()) slices.Sort(wantRoutes) assert.Equalf(t, wantRoutes, gotPeer.RoutePrefixes, @@ -351,7 +329,7 @@ func compareNetmap( // Tailscale IPs: count should match, and they must belong to // this peer (not some other node's IPs). - wantTSIPCount := countTailscaleIPs(wantPeer.AllowedIPs) + wantTSIPCount := countTailscaleIPsView(wantPeer.AllowedIPs()) assert.Lenf(t, gotPeer.TailscaleIPs, wantTSIPCount, "peer %s: Tailscale IP count mismatch", name) @@ -376,7 +354,12 @@ func compareNetmap( } // Compare PrimaryRoutes. - assert.ElementsMatchf(t, wantPeer.PrimaryRoutes, gotPeer.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) } @@ -387,15 +370,255 @@ func compareNetmap( } } - // Compare PacketFilter rule count. - var wantFilterRules []tailcfg.FilterRule - if len(want.PacketFilterRules) > 0 && - string(want.PacketFilterRules) != "null" { - _ = json.Unmarshal(want.PacketFilterRules, &wantFilterRules) + // Compare PacketFilter rules (IP-independent). + wantFilterRules := want.PacketFilterRules + + if !assert.Lenf(t, got.PacketFilter, len(wantFilterRules), + "PacketFilter rule count mismatch") { + return } - assert.Lenf(t, got.PacketFilter, len(wantFilterRules), - "PacketFilter rule count mismatch") + // Resolve SaaS IPs → peer name and HS IPs → peer name so we can + // compare rule sources structurally. Tailscale IPs in SaaS vs HS + // allocations never match literally, but each IP belongs to a + // peer with a stable hostname. + saasAddrs := saasAddrsByPeer(want, clients) + hsAddrs := hsAddrsByPeer(clients) + + // Compare destination prefixes per rule — subnet CIDRs like + // 10.44.0.0/16 are stable between Tailscale SaaS and headscale. + // Source IPs are re-keyed per peer identity before comparison. + for i := range wantFilterRules { + wantRule := wantFilterRules[i] + gotMatch := got.PacketFilter[i] + + wantSrcIdents := canonicaliseSrcStrings(t, wantRule.SrcIPs, saasAddrs, i) + gotSrcIdents := canonicaliseSrcPrefixes(t, gotMatch.Srcs, hsAddrs, i) + + assert.Equalf(t, wantSrcIdents, gotSrcIdents, + "PacketFilter[%d]: source peer identities mismatch", i) + + // Destination prefixes: extract non-Tailscale-IP CIDRs + // from both golden and headscale rules and compare. + var wantDstPrefixes []string + + for _, dp := range wantRule.DstPorts { + pfx, err := parsePrefixOrAddr(dp.IP) + require.NoErrorf(t, err, + "golden DstPorts[%d].IP %q should parse as prefix or addr", i, dp.IP) + + if !isTailscaleIP(pfx) { + wantDstPrefixes = append(wantDstPrefixes, pfx.String()) + } + } + + var gotDstPrefixes []string + + for _, dst := range gotMatch.Dsts { + pfx := dst.Net + if !isTailscaleIP(pfx) { + gotDstPrefixes = append(gotDstPrefixes, pfx.String()) + } + } + + slices.Sort(wantDstPrefixes) + slices.Sort(gotDstPrefixes) + + assert.Equalf(t, wantDstPrefixes, gotDstPrefixes, + "PacketFilter[%d]: non-Tailscale destination prefixes mismatch", i) + } +} + +// saasAddrsByPeer builds a map from SaaS Tailscale address to peer +// hostname using each capture's SelfNode.Addresses. Peers not in +// clients are skipped. +func saasAddrsByPeer( + want testcapture.Node, + clients map[string]*servertest.TestClient, +) map[netip.Addr]string { + out := map[netip.Addr]string{} + + if want.Netmap == nil { + return out + } + + // Walk peers listed in this netmap. + for _, peer := range want.Netmap.Peers { + name := extractHostname(peer.Name()) + if _, isOurs := clients[name]; !isOurs { + continue + } + + for i := range peer.Addresses().Len() { + pfx := peer.Addresses().At(i) + if isTailscaleIP(pfx) { + out[pfx.Addr()] = name + } + } + } + + // The viewer's own SelfNode addresses also appear as possible src. + if want.Netmap.SelfNode.Valid() { + name := extractHostname(want.Netmap.SelfNode.Name()) + + if _, isOurs := clients[name]; isOurs { + addrs := want.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 *testing.T, + srcs []string, + addrToPeer map[netip.Addr]string, + ruleIndex int, +) []string { + t.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 *testing.T, + srcs []netip.Prefix, + addrToPeer map[netip.Addr]string, + ruleIndex int, +) []string { + t.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 *testing.T, + pfx netip.Prefix, + addrToPeer map[netip.Addr]string, + ruleIndex int, + seen map[string]struct{}, +) { + t.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 { @@ -404,6 +627,24 @@ type peerSummary struct { 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 +} + // 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 { @@ -424,37 +665,36 @@ func isTailscaleIP(prefix netip.Prefix) bool { return false } -// extractRoutePrefixes returns the non-Tailscale-IP entries from an -// AllowedIPs list (subnet routes, exit routes, etc.). -func extractRoutePrefixes(allowedIPs []string) []string { +// 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 _, aip := range allowedIPs { - prefix, err := netip.ParsePrefix(aip) - if err != nil { - continue - } - - if !isTailscaleIP(prefix) { - routes = append(routes, aip) + for i := range allowedIPs.Len() { + pfx := allowedIPs.At(i) + if !isTailscaleIP(pfx) { + routes = append(routes, pfx.String()) } } return routes } -// countTailscaleIPs returns the number of Tailscale IP entries in an -// AllowedIPs list. -func countTailscaleIPs(allowedIPs []string) int { +// 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 _, aip := range allowedIPs { - prefix, err := netip.ParsePrefix(aip) - if err != nil { - continue - } - - if isTailscaleIP(prefix) { + for i := range allowedIPs.Len() { + if isTailscaleIP(allowedIPs.At(i)) { count++ } } @@ -463,16 +703,17 @@ func countTailscaleIPs(allowedIPs []string) int { } // 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 { +// If the topology has explicit routable_ips, those are used. Otherwise +// routes are inferred from the netmap 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 advertised_routes from topology. - for name, node := range gf.Topology.Nodes { - for _, r := range node.AdvertisedRoutes { + // 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)) } } @@ -484,26 +725,53 @@ func inferNodeRoutes(gf goldenFile) map[string][]netip.Prefix { } } - // Infer from the golden netmap: scan all captures for peers with + // Tier 2: infer from each capture's netmap — 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 _, capture := range gf.Captures { - if capture.Netmap == nil { + for _, node := range c.Captures { + if node.Netmap == nil { continue } - for _, peer := range capture.Netmap.Peers { - peerName := extractHostname(peer.Name) - routes := extractRoutePrefixes(peer.AllowedIPs) + for _, peer := range node.Netmap.Peers { + peerName := extractHostname(peer.Name()) - for _, r := range routes { - prefix, err := netip.ParsePrefix(r) - if err != nil { + for i := range peer.AllowedIPs().Len() { + pfx := peer.AllowedIPs().At(i) + if isTailscaleIP(pfx) { continue } - if !slices.Contains(result[peerName], prefix) { - result[peerName] = append(result[peerName], prefix) + 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, + ) } } } @@ -521,13 +789,3 @@ func extractHostname(fqdn string) string { return fqdn } - -// convertViaPolicy converts Tailscale SaaS policy emails to headscale format. -func convertViaPolicy(raw json.RawMessage) []byte { - s := string(raw) - s = strings.ReplaceAll(s, "kratail2tid@passkey", "tag-user@") - s = strings.ReplaceAll(s, "kristoffer@dalby.cc", "tag-user@") - s = strings.ReplaceAll(s, "monitorpasskeykradalby@passkey", "tag-user@") - - return []byte(s) -} diff --git a/hscontrol/state/state.go b/hscontrol/state/state.go index abd615e9..a40991d1 100644 --- a/hscontrol/state/state.go +++ b/hscontrol/state/state.go @@ -1146,42 +1146,72 @@ func (s *State) GetNodePrimaryRoutes(nodeID types.NodeID) []netip.Prefix { return s.primaryRoutes.PrimaryRoutes(nodeID) } -// RoutesForPeer computes the routes a peer should advertise to a specific viewer, -// applying via grant steering on top of global primary election and exit routes. -// When no via grants apply, this falls back to existing behavior (global primaries + exit routes). +// RoutesForPeer computes the routes a peer should advertise in a viewer's +// AllowedIPs, combining primary routes (from HA election), approved exit +// routes, and via grant steering. +// +// Approved exit routes (0.0.0.0/0, ::/0) are included alongside subnet +// routes — they appear in every peer's AllowedIPs, while unapproved +// ones do not. func (s *State) RoutesForPeer( viewer, peer types.NodeView, matchers []matcher.Match, ) []netip.Prefix { viaResult := s.polMan.ViaRoutesForPeer(viewer, peer) - globalPrimaries := s.primaryRoutes.PrimaryRoutes(peer.ID()) exitRoutes := peer.ExitRoutes() - // Fast path: no via grants affect this pair — existing behavior. + var reduced []netip.Prefix + + // Fast path: no via grants affect this pair. if len(viaResult.Include) == 0 && len(viaResult.Exclude) == 0 { allRoutes := slices.Concat(globalPrimaries, exitRoutes) - return policy.ReduceRoutes(viewer, allRoutes, matchers) - } + reduced = policy.ReduceRoutes(viewer, allRoutes, matchers) + } else { + // Slow path: drop excluded routes, reduce, append via-included. + routes := make([]netip.Prefix, 0, len(globalPrimaries)+len(exitRoutes)) + for _, p := range slices.Concat(globalPrimaries, exitRoutes) { + if !slices.Contains(viaResult.Exclude, p) { + routes = append(routes, p) + } + } - // Remove excluded routes (steered to a different peer for this viewer). - var routes []netip.Prefix + reduced = policy.ReduceRoutes(viewer, routes, matchers) - for _, p := range slices.Concat(globalPrimaries, exitRoutes) { - if !slices.Contains(viaResult.Exclude, p) { - routes = append(routes, p) + // Append via-included routes. The via grant IS the authorization + // (no matcher filter needed), but HA primary election applies + // when a regular (non-via) grant also covers the same prefix. + // + // Rules: + // - Peer is HA primary → always include + // - Peer is NOT primary, no regular grant → include + // (per-viewer via steering) + // - Peer is NOT primary, regular grant exists → exclude + // (HA primary wins) + for _, p := range viaResult.Include { + if slices.Contains(reduced, p) { + continue + } + + if slices.Contains(globalPrimaries, p) { + reduced = append(reduced, p) + } else if !slices.Contains(viaResult.UsePrimary, p) { + reduced = append(reduced, p) + } } } - // Reduce only the non-via routes through matchers. - reduced := policy.ReduceRoutes(viewer, routes, matchers) - - // Append via-included routes directly — the via grant IS the authorization, - // so these must not be filtered by the viewer's matchers. - for _, p := range viaResult.Include { - if !slices.Contains(reduced, p) { - reduced = append(reduced, p) + // Co-router visibility: when the viewer advertises the same prefix + // that the peer is HA primary for, the viewer must see that route + // regardless of matcher authorization. HA secondaries need this to + // know which peer is primary for their shared prefix. + viewerSubnets := viewer.SubnetRoutes() + if len(viewerSubnets) > 0 { + for _, p := range globalPrimaries { + if slices.Contains(viewerSubnets, p) && !slices.Contains(reduced, p) { + reduced = append(reduced, p) + } } }