Recapture all golden test data from Tailscale SaaS with:
- All 19 nodes online (12 routes + 7 grant v2 topology)
- All routes approved via API
- Trimmed netmap data per capture (Peers with AllowedIPs,
PrimaryRoutes, Tags; SelfNode with RoutableIPs)
- 20-30s propagation wait for correct data
Topology expanded from netmap peers to include all 19 nodes that
contribute to filter rule SrcIPs. Each node has routable_ips and
approved_routes derived from netmap SelfNode data.
New files: b11-b16 (autogroup:internet), i8-i9 (IPv6 fd00:1::/64),
pa1-pa2 (partial route approval), V31-SLOW.
Updates #3157
Updates #3169
With the unified compiledGrant infrastructure, there is only one
filter compilation path. The equivalence tests that compared the
old global path against the old per-node path no longer serve a
purpose since both callers now go through the same compileGrants
resolution.
Remove TestACLCompatGlobalEquivalence, TestGrantsCompatGlobalEquivalence,
and TestRoutesCompatGlobalEquivalence.
Updates #3157
Updates #3169
Replace the lenient testACLError handler (t.Logf on mismatch) with
strict validation matching the grant error handler pattern:
- Extract assertACLErrorContains helper with progressive fallbacks:
direct match, mapped equivalent, key-part extraction, then t.Errorf
- Convert HeadscaleDiffers from t.Logf to t.Skipf for visibility
- Add aclErrorMessageMap for known wording differences (empty for now)
All 16 ACL error golden files pass with the strict handler.
Updates #3157
Updates #3169
Add source comments explaining design decisions:
CanAccessRoute (types/node.go): Explain why DestsIsTheInternet is
intentionally absent. Exit routes are handled by RoutesForPeer via
ExitRoutes(), not ACL-based filtering. autogroup:internet is skipped
during filter compilation, so no matchers contain 'the internet'.
Updates #3157
Updates #3169
Store compiledGrants and userNodeIndex on PolicyManager. Both
filterForNodeLocked and BuildPeerMap now derive from the same
compiled grants, eliminating the dual-path architecture.
Delete:
- compileViaGrant (filter.go): replaced by compileViaForNode
- compileGrantWithAutogroupSelf (filter.go): replaced by
compileGrants + compileAutogroupSelf
- compileFilterRulesForNodeLocked (policy.go): replaced by
filterRulesForNodeLocked
- compiledFilterRulesMap cache (policy.go): no longer needed
- usesAutogroupSelf field and method: detection now happens
during grant compilation (grantCategorySelf/grantCategoryVia)
- hasViaGrants method: same
The needsPerNodeFilter flag is now derived from the compiled grants
via hasPerNodeGrants. filterForNodeLocked no longer returns error
since the new path cannot fail.
Updates #3157
Updates #3169
Fix fragile IP indexing in reduceCapGrantRule: replace hard-coded
nodeIPs[0]/nodeIPs[1] access with a range loop, matching the pattern
used by the adjacent broad-prefix branch. The old code would panic
if a node had zero IPs.
Remove redundant SubnetRoutes check in ReduceFilterRules: the check
was strictly redundant with the RoutableIPs check since SubnetRoutes
is always a subset of RoutableIPs. Document that golden data always
has routable_ips == approved_routes so we cannot determine which set
Tailscale actually checks.
Add tests:
- TestReduceFilterRulesCapGrant: 7 cases covering IP narrowing,
non-matching filtered out, subnet route overlap, exit route
skipping, mixed DstPorts+CapGrant, IPv4-only, and zero-IP nodes
- TestReduceFilterRulesPartialApproval: 3 cases documenting behavior
for approved, unapproved-but-advertised, and non-advertised routes
Updates #3157
Updates #3169
TestRoutesCompatNoPeersBeyondCaptures extends the negative peer
assertion from 6 hardcoded files (f10-f15) to all 98 ROUTES golden
files using a generic, data-driven approach.
For each golden file, the test derives expected peer pairs from
capture SrcIPs (subnet routes, direct node IPs) and DstPorts
(destination node IPs, route CIDRs), handling IP dash-range format
(100.64.0.0-100.115.91.255) and wildcards. It then asserts that no
unexpected CanAccess=true pairs exist.
Also adds addSrcIPToBuilder helper and deriveAllPeerPairsFromCaptures
for comprehensive peer derivation from golden data.
Updates #3157
Updates #3169
Add equivalence tests that compare the global filter path
(PolicyManager.FilterForNode) against the per-node path
(compileFilterRulesForNode + ReduceFilterRules) for all golden files
where the two paths should produce identical output.
TestRoutesCompatGlobalEquivalence: 98 ROUTES files, 12 nodes each.
All pass (1,176 comparisons).
TestACLCompatGlobalEquivalence: ~152 ACL files (excluding error and
autogroup:self cases), 8 nodes each. Surfaces 2 divergences (ACL-M06,
ACL-K01) where the global path loses individual non-wildcard source
IPs absorbed into the CGNAT range.
TestGrantsCompatGlobalEquivalence: ~186 GRANT files (excluding error,
autogroup:self, and via cases), 8-15 nodes each. Surfaces 3
divergences (GRANT-P09_7A, GRANT-K29, GRANT-H6) with the same root
cause.
The per-node path matches Tailscale SaaS golden data in all 5
divergent cases, confirming it preserves nonWildcardSrcs correctly.
Updates #3157
Updates #3169
Add big-router to the taggedNodes list for via compat tests. This
node has tags [tag:router, tag:exit] and is needed for future
V-series golden file tests. Existing tests are unaffected.
Enhance compareNetmap to compare PacketFilter rule content (destination
prefixes, source non-emptiness), not just rule count. Six golden files
have full SrcIPs/DstPorts data that was previously unvalidated.
Add Tier 3 route inference to inferNodeRoutes: scan each node's own
packet_filter_rules DstPorts for non-Tailscale-IP route prefixes.
This catches secondary HA routers whose routes don't appear in
AllowedIPs but do receive filter rules.
Updates #3157
Updates #3169
The matcher_test.go file was empty (just the package declaration).
Add comprehensive unit tests covering all public methods:
- TestMatchFromStrings: CIDR, wildcard, single IP, IPv6, empty inputs
- TestMatchFromFilterRule: standard rules, wildcard dst, empty DstPorts
- TestMatchesFromFilterRules: multi-rule conversion
- TestSrcsOverlapsPrefixes: exact, parent/child, no overlap, IPv6
- TestDestsOverlapsPrefixes: exact, parent/child, wildcard
- TestDestsIsTheInternet: 0.0.0.0/0, ::/0, wildcard, private ranges
- TestDebugString: output format validation
DestsIsTheInternet had zero test coverage (neither direct nor indirect)
prior to this change.
Updates #3157
Updates #3169
Add two new data-driven compatibility tests validated against all 98
Tailscale SaaS golden files:
- TestRoutesCompatAutoApproval: verifies NodeCanApproveRoute produces
the same approval decisions as captured in golden files. Exit routes
(0.0.0.0/0, ::/0) are skipped because Tailscale SaaS stores them
under autoApprovers.routes while headscale uses a separate
autoApprovers.exitNode field.
- TestRoutesCompatReduceRoutes: verifies CanAccessRoute produces route
visibility decisions consistent with golden captures. Extends the
coverage from 6 files (f10-f15) to all 98 golden files by deriving
expected access from the SrcIPs in each node's captured filter rules.
Updates #3157
Updates #3169
Add TestRoutesCompatPeerVisibility and TestRoutesCompatNoFalsePositivePeers
which use the Tailscale SaaS golden file data to validate the CanAccess /
ReduceNodes / ReduceRoutes code paths for subnet-to-subnet ACL scenarios.
These tests derive expected peer relationships from the golden file
captures: if Tailscale SaaS delivers filter rules to a node with SrcIPs
overlapping another node's subnet routes, those nodes must be peers. This
exercises the CanAccess fix where subnet routes are treated as source
identity, ensuring the fix is validated against real SaaS behavior.
Also fix buildRoutesUsersAndNodes to auto-assign unique node IDs when the
golden files don't provide them, which is required for ReduceNodes to
correctly skip self-comparisons.
Updates #3157
Updates #3169
Add 6 golden files captured from Tailscale SaaS that cover subnet-to-subnet
ACL scenarios where both source and destination are subnet CIDRs. These fill
a complete gap in the existing 92 ROUTES golden files, none of which tested
this pattern.
Scenarios covered:
f10: exact issue #3157 reproduction (disjoint subnets, host aliases)
f11: bidirectional with overlapping big-router
f12: host aliases where dst is a superset route
f13: disjoint subnets, bidirectional
f14: both src and dst overlap on one router
f15: cross-router bidirectional
All 6 pass TestRoutesCompat, confirming headscale matches Tailscale SaaS
behavior for subnet-to-subnet filter rule distribution.
Updates #3157
Updates #3169
Add TestSubnetToSubnetACL which creates two subnet routers on separate
Docker networks and verifies that ACL rules referencing only subnet
CIDRs (not node IPs) as source and destination produce correct peer
visibility, route distribution, and end-to-end connectivity.
Updates #3157
Updates #3169
CanAccess and CanAccessRoute only used a node's Tailscale IPs as
source identity when matching ACL rules. A subnet router acting on
behalf of its advertised subnets was invisible to ACLs that reference
subnet CIDRs as source (e.g. src=10.88.8.0/24 dst=10.99.9.0/24).
Extend both functions to also check whether the node's approved
subnet routes overlap the matcher's source set. This makes subnet
routers visible to each other when ACLs reference their subnet CIDRs,
enabling subnet-to-subnet traffic routing.
The new checks are guarded by len(subnetRoutes) > 0, so non-router
nodes (the vast majority) pay zero additional cost.
Fixes#3157Fixes#3169
Add unit tests covering the subnet-to-subnet ACL scenario where both
src and dst are subnet CIDRs served by different subnet routers.
Tests cover CanAccess (peer visibility), ReduceRoutes (route filtering),
and ReduceNodes (peer list reduction). Positive cases for routers that
advertise the referenced subnets, and negative cases ensuring regular
nodes and unrelated routers gain no extra visibility.
These tests currently fail and will be fixed in the next commit.
Updates #3157
Updates #3169
Rename all 594 test data files from .json to .hujson and add
descriptive header comments to each file documenting what policy
rules are under test and what outcome is expected.
Update test loaders in all 5 _test.go files to parse HuJSON via
hujson.Parse/Standardize/Pack before json.Unmarshal.
Add cross-dependency warning to via_compat_test.go documenting
that GRANT-V29/V30/V31/V36 are shared with TestGrantsCompat.
Add .gitignore exemption for testdata HuJSON files.
Remove unused error variables (ErrGrantViaNotSupported, ErrGrantEmptySources, ErrGrantEmptyDestinations, ErrGrantViaOnlyTag) and the stale TODO for via implementation. Update compat test skip reasons to reflect that user:*@passkey wildcard is a known unsupported feature, not a pending implementation.
Updates #2180
Remove TestGrantViaExitNodeSteering and TestGrantViaMixedSteering.
Exit node traffic forwarding through via grants cannot be validated
with curl/traceroute in Docker containers because Tailscale exit nodes
strip locally-connected subnets from their forwarding filter.
The correctness of via exit steering is validated by:
- Golden MapResponse comparison (TestViaGrantMapCompat with GRANT-V31
and GRANT-V36) comparing full netmap output against Tailscale SaaS
- Filter rule compatibility (TestGrantsCompat with GRANT-V14 through
GRANT-V36) comparing per-node PacketFilter rules against Tailscale SaaS
- TestGrantViaSubnetSteering (kept) validates via subnet steering with
actual curl/traceroute through Docker, which works for subnet routes
Updates #2180
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
Add golden test data for via exit route steering and fix via exit grant compilation to match Tailscale SaaS behavior. Includes MapResponse golden tests for via grant route steering verification.
Updates #2180
Add NetworkSpec struct with optional Subnet field to ScenarioSpec.Networks.
When Subnet is set, the Docker network is created with that specific CIDR
instead of Docker's auto-assigned RFC1918 range.
Fix all exit node integration tests to use curl + traceroute. Tailscale
exit nodes strip locally-connected subnets from their forwarding filter
(shrinkDefaultRoute + localInterfaceRoutes), so exit nodes cannot
forward to IPs on their Docker network via the default route alone.
This is by design: exit nodes provide internet access, not LAN access.
To also get LAN access, the subnet must be explicitly advertised as a
route — matching real-world Tailscale deployment requirements.
- TestSubnetRouterMultiNetworkExitNode: advertise usernet1 subnet
alongside exit route, upgraded from ping to curl + traceroute
- TestGrantViaExitNodeSteering: usernet1 subnet in via grants and
auto-approvers alongside autogroup:internet
- TestGrantViaMixedSteering: externet subnet in auto-approvers and
route advertisement for exit traffic
Updates #2180
Add three tests that verify control plane behavior for grant policies:
- TestGrantViaSubnetFilterRules: verifies the router's PacketFilter
contains destination rules for via-steered subnets. Without per-node
filter compilation for via grants, these rules were missing and the
router would drop forwarded traffic.
- TestGrantViaExitNodeFilterRules: same verification for exit nodes
with via grants steering autogroup:internet traffic.
- TestGrantIPv6OnlyPrefixACL: verifies that address-based aliases
(Prefix, Host) resolve to exactly the literal prefix and do not
expand to include the matching node's other IP addresses. An
IPv6-only host definition produces only IPv6 filter rules.
Updates #2180
Via grants compile filter rules that depend on the node's route state
(SubnetRoutes, ExitRoutes). Without per-node compilation, these rules
were only included in the global filter path which explicitly skips via
grants (compileFilterRules skips grants with non-empty Via fields).
Add a needsPerNodeFilter flag that is true when the policy uses either
autogroup:self or via grants. filterForNodeLocked now uses this flag
instead of usesAutogroupSelf alone, ensuring via grant rules are
compiled per-node through compileFilterRulesForNode/compileViaGrant.
The filter cache also needs to account for route-dependent compilation:
- nodesHavePolicyAffectingChanges now treats route changes as
policy-affecting when needsPerNodeFilter is true, so SetNodes
triggers updateLocked and clears caches through the normal flow.
- invalidateGlobalPolicyCache now clears compiledFilterRulesMap
(the unreduced per-node cache) alongside filterRulesMap when
needsPerNodeFilter is true and routes changed.
Updates #2180
Action.UnmarshalJSON produces the format
'action="unknown-action" is not supported: invalid ACL action',
not the reversed format the test expected.
Updates #2180
Add servertest grant policy control plane tests covering basic grants, via grants, and cap grants. Fix ReduceRoutes in State to apply route reduction to non-via routes first, then append via-included routes, preventing via grant routes from being incorrectly filtered.
Updates #2180
Test via route computation for viewer-peer pairs: self-steering returns
empty, viewer not in source returns empty, peer without advertised
destination returns empty, peer with/without via tag populates
Include/Exclude respectively, mixed prefix and autogroup:internet
destinations, and exit route steering.
7 subtests covering all code paths in ViaRoutesForPeer.
Updates #2180
Test companionCapGrantRules, sourcesHaveWildcard, sourcesHaveDangerAll,
srcIPsWithRoutes, the FilterAllowAll fix for grant-only policies,
compileViaGrant, compileGrantWithAutogroupSelf grant paths, and
destinationsToNetPortRange autogroup:internet skipping.
51 subtests across 8 test functions covering all grant-specific code
paths in filter.go that previously had no test coverage.
Updates #2180
Add NodeAttrsTaildriveShare and NodeAttrsTaildriveAccess to the node capability map, enabling Taildrive file sharing when granted via policy. Add integration test verifying the full cap/drive grant lifecycle.
Updates #2180
Add ConnectToNetwork to the TailscaleClient interface for multi-network test scenarios and implement peer relay ping support. Use these to test that cap/relay grants correctly enable peer-to-peer relay connections between tagged nodes.
Updates #2180
compileFilterRules checked only pol.ACLs == nil to decide whether
to return FilterAllowAll (permit-any). Policies that use only Grants
(no ACLs) had nil ACLs, so the function short-circuited before
compiling any CapGrant rules. This meant cap/relay, cap/drive, and
any other App-based grant capabilities were silently ignored.
Check both ACLs and Grants are empty before returning FilterAllowAll.
Updates #2180
Add integration tests validating that via grants correctly steer
routes to designated nodes per client group:
- TestGrantViaSubnetSteering: two routers advertise the same
subnet, via grants steer each client group to a specific router.
Verifies per-client route visibility, curl reachability, and
traceroute path.
- TestGrantViaExitNodeSteering: two exit nodes, via grants steer
each client group to a designated exit node. Verifies exit
routes are withdrawn from non-designated nodes and the client
rejects setting a non-designated exit node.
- TestGrantViaMixedSteering: cross-steering where subnet routes
and exit routes go to different servers per client group.
Verifies subnet traffic uses the subnet-designated server while
exit traffic uses the exit-designated server.
Also add autogroupp helper for constructing AutoGroup aliases in
grant policy configurations.
Updates #2180
Via grants steer routes to specific nodes per viewer. Until now,
all clients saw the same routes for each peer because route
assembly was viewer-independent. This implements per-viewer route
visibility so that via-designated peers serve routes only to
matching viewers, while non-designated peers have those routes
withdrawn.
Add ViaRouteResult type (Include/Exclude prefix lists) and
ViaRoutesForPeer to the PolicyManager interface. The v2
implementation iterates via grants, resolves sources against the
viewer, matches destinations against the peer's advertised routes
(both subnet and exit), and categorizes prefixes by whether the
peer has the via tag.
Add RoutesForPeer to State which composes global primary election,
via Include/Exclude filtering, exit routes, and ACL reduction.
When no via grants exist, it falls back to existing behavior.
Update the mapper to call RoutesForPeer per-peer instead of using
a single route function for all peers. The route function now
returns all routes (subnet + exit), and TailNode filters exit
routes out of the PrimaryRoutes field for HA tracking.
Updates #2180
compileViaGrant only handled *Prefix destinations, skipping
*AutoGroup entirely. This meant via grants with
dst=[autogroup:internet] produced no filter rules even when the
node was an exit node with approved exit routes.
Switch the destination loop from a type assertion to a type switch
that handles both *Prefix (subnet routes) and *AutoGroup (exit
routes via autogroup:internet). Also check ExitRoutes() in
addition to SubnetRoutes() so the function doesn't bail early
when a node only has exit routes.
Updates #2180
Add autogroup:danger-all as a valid source alias that matches ALL IP
addresses including non-Tailscale addresses. When used as a source,
it resolves to 0.0.0.0/0 + ::/0 internally but produces SrcIPs: ["*"]
in filter rules. When used as a destination, it is rejected with an
error matching Tailscale SaaS behavior.
Key changes:
- Add AutoGroupDangerAll constant and validation
- Add sourcesHaveDangerAll() helper and hasDangerAll parameter to
srcIPsWithRoutes() across all compilation paths
- Add ErrAutogroupDangerAllDst for destination rejection
- Remove 3 AUTOGROUP_DANGER_ALL skip entries (K6, K7, K8)
Updates #2180
Implement comprehensive grant validation including: accept empty sources/destinations (they produce no rules), validate grant ip/app field requirements, capability name format, autogroup constraints, via tag existence, and default route CIDR restrictions.
Updates #2180
Compile grants with "via" field into FilterRules that are placed only
on nodes matching the via tag and actually advertising the destination
subnets. Key behavior:
- Filter rules go exclusively to via-nodes with matching approved routes
- Destination subnets not advertised by the via node are silently dropped
- App-only via grants (no ip field) produce no packet filter rules
- Via grants are skipped in the global compileFilterRules since they
are node-specific
Reduces grant compat test skips from 41 to 30 (11 newly passing).
Updates #2180
Compile grant app fields into CapGrant FilterRules matching Tailscale
SaaS behavior. Key changes:
- Generate CapGrant rules in compileFilterRules and
compileGrantWithAutogroupSelf, with node-specific /32 and /128
Dsts for autogroup:self grants
- Add reversed companion rules for drive→drive-sharer and
relay→relay-target capabilities, ordered by original cap name
- Narrow broad CapGrant Dsts to node-specific prefixes in
ReduceFilterRules via new reduceCapGrantRule helper
- Skip merging CapGrant rules in mergeFilterRules to preserve
per-capability structure
- Remove ip+app mutual exclusivity validation (Tailscale accepts both)
- Add semantic JSON comparison for RawMessage types and netip.Prefix
comparators in test infrastructure
Reduces grant compat test skips from 99 to 41 (58 newly passing).
Updates #2180
When an ACL source list contains a wildcard (*) alongside explicit
sources (tags, groups, hosts, etc.), Tailscale preserves the individual
IPs from non-wildcard sources in SrcIPs alongside the merged wildcard
CGNAT ranges. Previously, headscale's IPSetBuilder would merge all
sources into a single set, absorbing the explicit IPs into the wildcard
range.
Track non-wildcard resolved addresses separately during source
resolution, then append their individual IP strings to the output
when a wildcard is also present. This fixes the remaining 5 ACL
compat test failures (K01 and M06 subtests).
Updates #2180
When an ACL has non-autogroup destinations (groups, users, tags, hosts)
alongside autogroup:self, emit non-self grants before self grants to
match Tailscale's filter rule ordering. ACLs with only autogroup
destinations (self + member) preserve the policy-defined order.
This fixes ACL-A17, ACL-SF07, and ACL-SF11 compat test failures.
Updates #2180
Add exit route check in ReduceFilterRules to prevent exit nodes from receiving packet filter rules for destinations that only overlap via exit routes. Remove resolved SUBNET_ROUTE_FILTER_RULES grant skip entries and update error message formatting for grant validation.
Updates #2180
Per Tailscale documentation, the wildcard (*) source includes "any
approved subnets" — the actually-advertised-and-approved routes from
nodes, not the autoApprover policy prefixes.
Change Asterix.resolve() to return just the base CGNAT+ULA set, and
add approved subnet routes as separate SrcIPs entries in the filter
compilation path. This preserves individual route prefixes that would
otherwise be merged by IPSet (e.g., 10.0.0.0/8 absorbing 10.33.0.0/16).
Also swap rule ordering in compileGrantWithAutogroupSelf() to emit
non-self destination rules before autogroup:self rules, matching the
Tailscale FilterRule wire format ordering.
Remove the unused AutoApproverPolicy.prefixes() method.
Updates #2180
Add routable_ips and approved_routes fields to the node topology
definitions in all golden test files. These represent the subnet
routes actually advertised by nodes on the Tailscale SaaS network
during data capture:
Routes topology (92 files, 6 router nodes):
big-router: 10.0.0.0/8
subnet-router: 10.33.0.0/16
ha-router1: 192.168.1.0/24
ha-router2: 192.168.1.0/24
multi-router: 172.16.0.0/24
exit-node: 0.0.0.0/0, ::/0
ACL topology (199 files, 1 router node):
subnet-router: 10.33.0.0/16
Grants topology (203 files, 1 router node):
subnet-router: 10.33.0.0/16
The route assignments were deduced from the golden data by analyzing
which router nodes receive FilterRules for which destination CIDRs
across all test files, and cross-referenced with the MTS setup
script (setup_grant_nodes.sh).
Updates #2180
Use ip.String() instead of netip.PrefixFrom(ip, ip.BitLen()).String()
when building DstPorts for autogroup:self destinations. This produces
bare IPs like "100.90.199.68" instead of CIDR notation like
"100.90.199.68/32", matching the Tailscale FilterRule wire format.
Updates #2180
AppendToIPSet now adds both IPv4 and IPv6 addresses for nodes, matching Tailscale's FilterRule wire format where identity-based aliases (tags, users, groups, autogroups) resolve to both address families. Update ReduceFilterRules test expectations to include IPv6 entries.
Updates #2180
Replace 8,286 lines of inline Go test expectations with 92 JSON golden files captured from Tailscale SaaS. The data-driven test driver validates route filtering, auto-approval, HA routing, and exit node behavior against real Tailscale output.
Updates #2180
Replace 9,937 lines of inline Go test expectations with 215 JSON golden files captured from Tailscale SaaS. The new data-driven test driver compares headscale's filter compilation output against real Tailscale behavior for each node in an 8-node topology.
Updates #2180