mirror of
https://github.com/juanfont/headscale.git
synced 2026-10-01 04:20:53 +09:00
9fbf7b9b60
Fixes #3408
0.29's BuildPeerMap returns node views, not node IDs, so the peer map
assertion compares IDs read off the views.
(cherry picked from commit e48bc46cc6)
836 lines
22 KiB
Go
836 lines
22 KiB
Go
// 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"
|
|
"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/types/netmap"
|
|
)
|
|
|
|
// viaCompatTests lists golden captures that exercise via grant steering.
|
|
var viaCompatTests = []struct {
|
|
id string
|
|
desc string
|
|
}{
|
|
{"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"},
|
|
}
|
|
|
|
// 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
|
|
})
|
|
}
|
|
|
|
// Compare each viewer's [tailcfg.MapResponse] against the golden [netmap.NetworkMap].
|
|
for viewerName, cl := range clients {
|
|
capture := c.Captures[viewerName]
|
|
if capture.Netmap == nil {
|
|
continue
|
|
}
|
|
|
|
t.Run(viewerName, func(t *testing.T) {
|
|
nm := cl.Netmap()
|
|
require.NotNil(t, nm, "netmap is nil")
|
|
|
|
compareNetmap(t, nm, capture, clients)
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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 rule count and non-Tailscale dst prefixes
|
|
func compareNetmap(
|
|
t *testing.T,
|
|
got *netmap.NetworkMap,
|
|
want testcapture.Node,
|
|
clients map[string]*servertest.TestClient,
|
|
) {
|
|
t.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)
|
|
}
|
|
}
|
|
|
|
// Compare PacketFilter rules (IP-independent).
|
|
wantFilterRules := want.PacketFilterRules
|
|
|
|
if !assert.Lenf(t, got.PacketFilter, len(wantFilterRules),
|
|
"PacketFilter rule count mismatch") {
|
|
return
|
|
}
|
|
|
|
// 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 {
|
|
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 {
|
|
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 [tailcfg.NodeView.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.NetworkMap].
|
|
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 [tailcfg.NodeView] 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:<name>" 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 {
|
|
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
|
|
}
|