Commit Graph

93 Commits

Author SHA1 Message Date
Kristoffer Dalby
9e0e77c90d policy/v2: use approved node routes in wildcard SrcIPs
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
2026-03-25 15:17:23 +00:00
Kristoffer Dalby
57bc77b98c policy/v2: add advertised routes to compat test topologies
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
2026-03-25 15:17:23 +00:00
Kristoffer Dalby
1235a17e6f policy/v2: remove resolved AUTOGROUP_SELF_CIDR_FORMAT grant skips
Remove 4 entries from grantSkipReasons that are now passing after
the autogroup:self DstPorts bare IP fix.

Updates #2180
2026-03-25 15:17:23 +00:00
Kristoffer Dalby
bc9877ce28 policy/v2: use bare IPs in autogroup:self DstPorts
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
2026-03-25 15:17:23 +00:00
Kristoffer Dalby
e3ab288351 policy/v2: remove resolved grant skip categories
Remove 91 entries from grantSkipReasons that are now passing:
- 90 MISSING_IPV6_ADDRS entries (identity aliases now include IPv6)
- 1 RAW_IPV6_ADDR_EXPANSION entry (address aliases no longer expand)

Move GRANT-P09_12B from the removed MISSING_IPV6_ADDRS category to
SUBNET_ROUTE_FILTER_RULES, which is its remaining failure mode.

Updates #2180
2026-03-25 15:17:23 +00:00
Kristoffer Dalby
ccade49742 policy: include IPv6 in identity-based alias resolution
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.

Address-based aliases (raw IPs, host names) are unchanged: they
resolve to exactly the literal prefix. The appendIfNodeHasIP helper
that incorrectly expanded address aliases to include the matching
node's other IPs is removed, fixing the RAW_IPV6_ADDR_EXPANSION
bug where a raw fd7a: IPv6 address would incorrectly include the
node's IPv4.

Updates #2180
2026-03-25 15:17:23 +00:00
Kristoffer Dalby
91aac1ceb2 hscontrol/policy/v2: replace routes golden data with Tailscale SaaS captures
Replace the headscale-adapted routes golden files with authoritative
captures from Tailscale SaaS using the 12-node topology (8 original
grant nodes + 4 new route-specific nodes: ha-router1, ha-router2,
big-router, multi-router).

The golden data was captured via debug-packet-filter-rules from all
12 nodes. The routes driver now falls back to the standard 3-user
setup when topology.users is absent (matching the SaaS capture
format) and converts @passkey/@dalby.cc emails to @example.com.

92 test cases captured, all valid JSON, all from Tailscale SaaS.

Updates #2180
2026-03-25 15:17:23 +00:00
Kristoffer Dalby
162e1dc35b hscontrol/policy/v2: replace ACL golden data with Tailscale SaaS captures
Replace the headscale-adapted ACL golden files with authoritative
captures from Tailscale SaaS using the 8-node grant topology.

The golden data was captured via debug-packet-filter-rules (FilterRule
wire format) from each of the 8 nodes after pushing each ACL policy
to the Tailscale API. This gives us the exact format Tailscale sends
to clients:

- SrcIPs use IP ranges (100.64.0.0-100.115.91.255) not CIDRs
- SrcIPs include subnet routes (10.33.0.0/16) for wildcard sources
- IPProto is omitted for default all-protocol rules
- DstPorts use bare IPs without /32 suffix
- Identity aliases include both IPv4 and IPv6 addresses

The test driver is updated to use the 8-node topology (3 users,
5 tagged nodes) matching the grant compat tests, with the same
email conversion (kratail2tid@passkey -> @example.com).

215 test cases: 199 success + 16 error (captured from API 400s).
All captured from Tailscale SaaS, no headscale-adapted values.

Updates #2180
2026-03-25 15:17:23 +00:00
Kristoffer Dalby
d83697186a hscontrol/policy/v2: convert routes compat tests to JSON-driven format
Replace 8,286 lines of inline Go struct test expectations in
tailscale_routes_compat_test.go with 92 JSON golden files in
testdata/routes_results/ROUTES-*.json and a ~300-line Go driver in
tailscale_routes_data_compat_test.go.

Unlike the ACL and grants compat tests which use shared hardcoded node
topologies, the routes driver builds nodes from JSON topology data.
Each test file embeds its full topology including routable_ips and
approved_routes, making test files self-contained. This naturally
handles the IPv6 tests which use a different 4-node topology from the
standard 9-node setup.

Test count is preserved: 92 test cases across 19 original test
functions (SubnetBasics, ExitNodes, HARouters, FilterPlacement,
RouteCoverage, Overlapping, TagResolution, ProtocolPort, IPv6,
EdgeCases, AutoApprover, and additional variants).

Updates #2180
2026-03-25 15:17:23 +00:00
Kristoffer Dalby
7e71d1b58f hscontrol/policy/v2: convert ACL compat tests to JSON-driven format
Replace 9,937 lines of inline Go struct test expectations in
tailscale_acl_compat_test.go with 215 JSON golden files in
testdata/acl_results/ACL-*.json and a ~400-line Go driver in
tailscale_acl_data_compat_test.go.

This matches the pattern used by the grants compat tests
(testdata/grant_results/GRANT-*.json + tailscale_grants_compat_test.go)
and the SSH compat tests (testdata/ssh_results/SSH-*.json +
tailscale_ssh_data_compat_test.go).

The JSON golden files contain the same test expectations as the
original Go file, preserving the Tailscale SaaS reference data.
The expectations are NOT adapted to match headscale current output —
they represent the target behavior.

Test count is preserved: 215 test cases (203 success + 12 error).

Updates #2180
2026-03-25 15:17:23 +00:00
Kristoffer Dalby
0562bd85f4 hscontrol/policy/v2: fix test helpers to match production pipeline
- TestTagUserMutualExclusivity and TestUserToTagCrossIdentityGrant:
  add ReduceFilterRules after compileFilterRulesForNode to match the
  production filter pipeline in filterForNodeLocked. The compilation
  step produces global rules for all ACLs; ReduceFilterRules strips
  them down to only rules where the node is a destination.

- containsSrcIP/containsIP helpers: use util.ParseIPSet to handle
  IP range strings like "100.64.0.1-100.64.0.3" produced by
  ipSetToStrings when contiguous IPs are coalesced.

Updates #2180
2026-03-25 15:17:23 +00:00
Kristoffer Dalby
5830eabf09 hscontrol/policy: fix test assertions and expectations
Fix several test issues exposed by the ResolvedAddresses refactor:

- TestTagUserMutualExclusivity: remove incorrect ACL rule that was
  testing the wrong invariant. The test now correctly validates that
  without an explicit cross-identity grant, user-owned nodes cannot
  reach tagged nodes. Add TestUserToTagCrossIdentityGrant to verify
  that explicit user@ -> tag:X ACL rules produce valid filter rules.

- TestResolvePolicy/wildcard-alias: update expected prefixes to match
  the CGNAT range minus ChromeOS VM range (multiple prefixes instead
  of the encompassing 100.64.0.0/10).

- TestApproveRoutesWithPolicy: fix user Name fields from "testuser@"
  to "testuser" to match how resolveUser trims the @ suffix before
  comparing against stored names.

Updates #2180
2026-03-25 15:17:23 +00:00
Kristoffer Dalby
5f3bddc663 hscontrol/policy/v2: fix nil dereferences in alias resolution
Fix three nil dereference issues in the policy resolution code:

- newResolvedAddresses: preserve partial IP results when errors occur
  instead of discarding valid IPSets. Callers already handle errors
  and nil results independently, so returning both allows partial
  resolution (e.g. groups with phantom users) to work correctly.

- resolveTagOwners: guard against nil ResolvedAddresses before calling
  Prefixes(), since Resolve may return nil when resolution fails.

- Asterix.resolve: guard against nil *Policy pointer, which occurs
  when resolving wildcards without a policy context (e.g. in tests).

Updates #2180
2026-03-25 15:17:23 +00:00
Kristoffer Dalby
0c6ac28b04 hscontrol/policy/v2: recategorize grants skip list from SRCIPS_FORMAT into granular root causes
Replace the monolithic SRCIPS_FORMAT skip category (125 tests) with 7
specific subcategories based on analysis of actual test failures:

  MISSING_IPV6_ADDRS          - 90 tests: identity aliases resolve to IPv4 only
  SUBNET_ROUTE_FILTER_RULES   - 10 tests: no rules for subnet-routed CIDRs
  AUTOGROUP_SELF_CIDR_FORMAT  -  4 tests: /32 and /128 suffix on DstPorts IPs
  USER_PASSKEY_WILDCARD       -  2 tests: user:*@passkey unresolvable
  RAW_IPV6_ADDR_EXPANSION     -  2 tests: raw IPv6 expanded to include IPv4
  SRCIPS_WILDCARD_NODE_DEDUP  -  1 test:  wildcard+specific node IP dedup

Also reclassify tests that moved between categories after the CGNAT
split range fix (4 tests now passing, others recategorized into
CAPGRANT_COMPILATION, ERROR_VALIDATION_GAP, VIA_COMPILATION, etc).

Total: 207 skipped, 30 passing (was 193 skipped, 19 passing).
2026-03-25 15:17:23 +00:00
Kristoffer Dalby
6f32dcf6f9 maybe only return ipv4? not always?
Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2026-03-25 15:17:23 +00:00
Kristoffer Dalby
f01052c85f speculative new datastruct, fix ip range return
Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2026-03-25 15:17:23 +00:00
Kristoffer Dalby
646a6e8266 hscontrol/policy/v2: add skip entries for 25 v2 gap-filling grant tests
Update the grants compatibility test skip list with 23 new entries for
the V-series tests (V07 and V24 pass without skipping).

New skip categories introduced:
- VIA_COMPILATION (3): via routes with specific src identities where
  there is no SrcIPs format issue (V11, V12, V13)
- Additional VIA_COMPILATION_AND_SRCIPS_FORMAT (3): via with wildcard
  src (V17, V21, V23)
- Additional CAPGRANT_COMPILATION (6): app grants on specific tags,
  drive cap, autogroup:self app (V02, V03, V06, V19, V20, V25)
- Additional CAPGRANT_COMPILATION_AND_SRCIPS_FORMAT (2): mixed ip+app
  on specific tags rejected by headscale (V09, V10)
- Additional ERROR_VALIDATION_GAP (9): autogroup:internet + app,
  raw 0.0.0.0/0 and ::/0 as grant dst (V01, V04, V05, V08, V14-V16,
  V18, V22)

Test totals: 237 total, 21 pass, 216 skip, 0 fail.

Updates #2180
2026-03-25 15:17:23 +00:00
Kristoffer Dalby
aa68fbafc0 hscontrol/policy/v2: add 25 v2 gap-filling grant testdata files
Add GRANT-V01 through GRANT-V25 JSON files captured from Tailscale SaaS
to fill coverage gaps in the grants compatibility test suite.

These tests cover:
- App grants on specific tags (not just wildcards)
- Mixed ip+app grants on specific tags
- Via routes with specific src identities (tags, groups, members)
- Via with multiple dst subnets and multiple via tags
- Drive cap with reverse drive-sharer generation
- autogroup:self with app grants
- autogroup:internet rejection with app grants
- Raw default route CIDR (0.0.0.0/0, ::/0) rejection as grant dst

Updates #2180
2026-03-25 15:17:23 +00:00
Kristoffer Dalby
2446158191 hscontrol/policy/v2: add data-driven grants compatibility test
Add TestGrantsCompat, a data-driven test that validates headscale's
grants implementation against 212 test cases captured from Tailscale
SaaS. Each test case loads a GRANT-*.json file from testdata/, applies
the policy through headscale's engine, and compares the resulting
packet filter rules against Tailscale's actual output.

Currently 19 tests pass and 193 are skipped with documented reasons:
- SRCIPS_FORMAT (125): IP range formatting differences
- CAPGRANT_COMPILATION (41): app capability grants not yet compiled
- ERROR_VALIDATION_GAP (14): validation strictness differences
- CAPGRANT_AND_SRCIPS_FORMAT (9): combined ip+app grant issues
- VIA_AND_SRCIPS_FORMAT (4): via route compilation not implemented
- AUTOGROUP_DANGER_ALL (3): autogroup:danger-all not supported
- VALIDATION_STRICTNESS (2): empty src/dst array handling

Updates #2180
2026-03-25 15:17:22 +00:00
Kristoffer Dalby
f1756f4d12 hscontrol/policy/v2: add grants compatibility testdata (212 JSON files)
Add 212 GRANT-*.json test files captured from Tailscale SaaS to
testdata/grant_results/. Each file contains a policy with grants,
the expected packet_filter_rules for 8 test nodes, and the topology
used during capture.

These files serve as the ground truth for the data-driven grants
compatibility test.

Updates #2180
2026-03-25 15:17:22 +00:00
Kristoffer Dalby
ca2081a44f hscontrol/policy/v2: rename tailscale_compat_test.go to tailscale_acl_compat_test.go
Rename the ACL compatibility test file to include 'acl' in the name,
making room for the upcoming grants compatibility test file.

Also fix a godoclint issue by adding a blank line between the file
header comment and the package declaration.

Updates #2180
2026-03-25 15:17:22 +00:00
Kristoffer Dalby
90c9555876 hscontrol/policy/v2: add ProtocolPort.MarshalJSON for Grant serialization
Implement ProtocolPort.MarshalJSON to produce string format matching
UnmarshalJSON expectations (e.g. "tcp:443", "udp:10000-20000", "*").

Add comprehensive TestGrantMarshalJSON with 10 test cases:
- IP-based grants with TCP, UDP, ICMP, and wildcard protocols
- Single ports, port ranges, and wildcard ports
- Capability-based grants using app field
- Grants with both ip and app fields
- Grants with via field for route filtering
- Testing omitempty behavior for ip, app, and via fields
- JSON round-trip validation (marshal → unmarshal → compare)

Add omitempty tag to Grant.InternetProtocols to avoid marshaling
null when field is empty.

Updates #2180
2026-03-25 15:17:22 +00:00
Kristoffer Dalby
1c31f04fab hscontrol/policy/v2: add TestACLToGrants
Add test for aclToGrants() function that converts ACL rules to Grant
format. Tests conversion of:
- Single-port TCP rules
- Multiple ACL entries to multiple Grants
- Port ranges and multiple ports in a single rule
- Wildcard protocols
- UDP, ICMP, and other protocol types

Ensures backward compatibility by verifying that ACL rules are correctly
transformed to the new Grant format.

Updates #2180
2026-03-25 15:17:22 +00:00
Kristoffer Dalby
31c0ecbd68 hscontrol/policy/v2: add TestUnmarshalGrants
Add comprehensive tests for Grant unmarshaling covering:
- Valid grants with ip field (network access)
- Valid grants with app field (capabilities)
- Wildcard port handling
- Port range parsing
- Error cases (missing fields, conflicting fields)

Updates #2180
2026-03-25 15:17:22 +00:00
Kristoffer Dalby
3ffdb4280a hscontrol/policy/v2: add Grant policy format support
Add support for the Grant policy format as an alternative to ACL format,
following Tailscale's policy v2 specification. Grants provide a more
structured way to define network access rules with explicit separation
of IP-based and capability-based permissions.

Key changes:

- Add Grant struct with Sources, Destinations, InternetProtocols (ip),
  and App (capabilities) fields
- Add ProtocolPort type for unmarshaling protocol:port strings
- Add Grant validation in Policy.validate() to enforce:
  - Mutual exclusivity of ip and app fields
  - Required ip or app field presence
  - Non-empty sources and destinations
- Refactor compileFilterRules to support both ACLs and Grants
- Convert ACLs to Grants internally via aclToGrants() for unified
  processing
- Extract destinationsToNetPortRange() helper for cleaner code
- Rename parseProtocol() to toIANAProtocolNumbers() for clarity
- Add ProtocolNumberToName mapping for reverse lookups

The Grant format allows policies to be written using either the legacy
ACL format or the new Grant format. ACLs are converted to Grants
internally, ensuring backward compatibility while enabling the new
format's benefits.

Updates #2180
2026-03-25 15:17:22 +00:00
Kristoffer Dalby
3e0a96ec3a all: fix test flakiness and improve test infrastructure
Buffer the AuthRequest verdict channel to prevent a race where the
sender blocks indefinitely if the receiver has already timed out, and
increase the auth followup test timeout from 100ms to 5s to prevent
spurious failures under load.

Skip postgres-backed tests when the postgres server is unavailable
instead of calling t.Fatal, which was preventing the rest of the test
suite from running.

Add TestMain to db, types, and policy/v2 packages to chdir to the
source directory before running tests. This ensures relative testdata/
paths resolve correctly when the test binary is executed from an
arbitrary working directory (e.g., via "go tool stress").
2026-03-14 02:52:28 -07:00
Kristoffer Dalby
6c59d3e601 policy/v2: add SSH compatibility testdata from Tailscale SaaS
Add 39 test fixtures captured from Tailscale SaaS API responses
to validate SSH policy compilation parity. Each JSON file contains
the SSH policy section and expected compiled SSHRule arrays for 5
test nodes (3 user-owned, 2 tagged).

Test series: SSH-A (basic), SSH-B (specific sources), SSH-C
(destination combos), SSH-D (localpart), SSH-E (edge cases),
SSH-F (multi-rule), SSH-G (acceptEnv).

The data-driven TestSSHDataCompat harness uses cmp.Diff with
principal order tolerance but strict rule ordering (first-match-wins
semantics require exact order).

Updates #3049
2026-02-28 05:14:11 -08:00
Kristoffer Dalby
0acf09bdd2 policy/v2: add localpart:*@domain SSH user compilation
Add support for localpart:*@<domain> entries in SSH policy users.
When a user SSHes into a target, their email local-part becomes the
OS username (e.g. alice@example.com → OS user alice).

Type system (types.go):
- SSHUser.IsLocalpart() and ParseLocalpart() for validation
- SSHUsers.LocalpartEntries(), NormalUsers(), ContainsLocalpart()
- Enforces format: localpart:*@<domain> (wildcard-only)
- UserWildcard.Resolve for user:*@domain SSH source aliases
- acceptEnv passthrough for SSH rules

Compilation (filter.go):
- resolveLocalparts: pure function mapping users to local-parts
  by email domain. No node walking, easy to test.
- groupSourcesByUser: single walk producing per-user principals
  with sorted user IDs, and tagged principals separately.
- ipSetToPrincipals: shared helper replacing 6 inline copies.
- selfPrincipalsForNode: self-access using pre-computed byUser.

The approach separates data gathering from rule assembly. Localpart
rules are interleaved per source user to match Tailscale SaaS
first-match-wins ordering.

Updates #3049
2026-02-28 05:14:11 -08:00
Kristoffer Dalby
7bab8da366 state, policy, noise: implement SSH check period auto-approval
Add SSH check period tracking so that recently authenticated users
are auto-approved without requiring manual intervention each time.

Introduce SSHCheckPeriod type with validation (min 1m, max 168h,
"always" for every request) and encode the compiled check period
as URL query parameters in the HoldAndDelegate URL.

The SSHActionHandler checks recorded auth times before creating a
new HoldAndDelegate flow. Auth timestamps are stored in-memory:
- Default period (no explicit checkPeriod): auth covers any
  destination, keyed by source node with Dst=0 sentinel
- Explicit period: auth covers only that specific destination,
  keyed by (source, destination) pair

Auth times are cleared on policy changes.

Updates #1850
2026-02-25 21:28:05 +01:00
Kristoffer Dalby
107c2f2f70 policy, noise: implement SSH check action
Implement the SSH "check" action which requires additional
verification before allowing SSH access. The policy compiler generates
a HoldAndDelegate URL that the Tailscale client calls back to
headscale. The SSHActionHandler creates an auth session and waits for
approval via the generalised auth flow.

Sort check (HoldAndDelegate) rules before accept rules to match
Tailscale's first-match-wins evaluation order.

Updates #1850
2026-02-25 21:28:05 +01:00
Kristoffer Dalby
b668c7a596 policy/v2: add policy unmarshal tests for bracketed IPv6
Add end-to-end test cases to TestUnmarshalPolicy that verify bracketed
IPv6 addresses are correctly parsed through the full policy pipeline
(JSON unmarshal -> splitDestinationAndPort -> parseAlias -> parsePortRange)
and survive JSON round-trips.

Cover single port, multiple ports, wildcard port, CIDR prefix, port
range, bracketed IPv4, and hostname rejection.

Updates #2754
2026-02-20 21:49:21 +01:00
Kristoffer Dalby
49744cd467 policy/v2: accept RFC 3986 bracketed IPv6 in ACL destinations
Headscale rejects IPv6 addresses with square brackets in ACL policy
destinations (e.g. "[fd7a:115c:a1e0::87e1]:80,443"), while Tailscale
SaaS accepts them. The root cause is that splitDestinationAndPort uses
strings.LastIndex(":") which leaves brackets on the destination string,
and netip.ParseAddr does not accept brackets.

Add a bracket-handling branch at the top of splitDestinationAndPort that
uses net.SplitHostPort for RFC 3986 parsing when input starts with "[".
The extracted host is validated with netip.ParseAddr/ParsePrefix to
ensure brackets are only accepted around IP addresses and CIDR prefixes,
not hostnames or other alias types like tags and groups.

Fixes #2754
2026-02-20 21:49:21 +01:00
Kristoffer Dalby
eccf64eb58 all: fix staticcheck SA4006 in types_test.go
Use new(users["name"]) instead of extracting to intermediate
variables that staticcheck does not recognise as used with
Go 1.26 new(value) syntax.

Updates #3058
2026-02-19 08:21:23 +01:00
Kristoffer Dalby
0f6d312ada all: upgrade to Go 1.26rc2 and modernize codebase
This commit upgrades the codebase from Go 1.25.5 to Go 1.26rc2 and
adopts new language features.

Toolchain updates:
- go.mod: go 1.25.5 → go 1.26rc2
- flake.nix: buildGo125Module → buildGo126Module, go_1_25 → go_1_26
- flake.nix: build golangci-lint from source with Go 1.26
- Dockerfile.integration: golang:1.25-trixie → golang:1.26rc2-trixie
- Dockerfile.tailscale-HEAD: golang:1.25-alpine → golang:1.26rc2-alpine
- Dockerfile.derper: golang:alpine → golang:1.26rc2-alpine
- .goreleaser.yml: go mod tidy -compat=1.25 → -compat=1.26
- cmd/hi/run.go: fallback Go version 1.25 → 1.26rc2
- .pre-commit-config.yaml: simplify golangci-lint hook entry

Code modernization using Go 1.26 features:
- Replace tsaddr.SortPrefixes with slices.SortFunc + netip.Prefix.Compare
- Replace ptr.To(x) with new(x) syntax
- Replace errors.As with errors.AsType[T]

Lint rule updates:
- Add forbidigo rules to prevent regression to old patterns
2026-02-08 12:35:23 +01:00
Kristoffer Dalby
ce580f8245 all: fix golangci-lint issues (#3064) 2026-02-06 21:45:32 +01:00
Kristoffer Dalby
3acce2da87 errors: rewrite errors to follow go best practices
Errors should not start capitalised and they should not contain the word error
or state that they "failed" as we already know it is an error

Signed-off-by: Kristoffer Dalby <kristoffer@dalby.cc>
2026-02-06 07:40:29 +01:00
Kristoffer Dalby
4a9a329339 all: use lowercase log messages
Go style recommends that log messages and error strings should not be
capitalized (unless beginning with proper nouns or acronyms) and should
not end with punctuation.

This change normalizes all zerolog .Msg() and .Msgf() calls to start
with lowercase letters, following Go conventions and making logs more
consistent across the codebase.
2026-02-06 07:40:29 +01:00
Kristoffer Dalby
835b7eb960 policy: autogroup:internet does not generate packet filters
According to Tailscale SaaS behavior, autogroup:internet is handled
by exit node routing via AllowedIPs, not by packet filtering. ACL
rules with autogroup:internet as destination should produce no
filter rules for any node.

Previously, Headscale expanded autogroup:internet to public CIDR
ranges and distributed filters to exit nodes (because 0.0.0.0/0
"covers" internet destinations). This was incorrect.

Add detection for AutoGroupInternet in filter compilation to skip
filter generation for this autogroup. Update test expectations
accordingly.
2026-02-05 19:29:16 +01:00
Kristoffer Dalby
95b1fd636e policy: fix wildcard DstPorts format and proto:icmp handling
Fix two compatibility issues discovered in Tailscale SaaS testing:

1. Wildcard DstPorts format: Headscale was expanding wildcard
   destinations to CGNAT ranges (100.64.0.0/10, fd7a:115c:a1e0::/48)
   while Tailscale uses {IP: "*"} directly. Add detection for
   wildcard (Asterix) alias type in filter compilation to use the
   correct format.

2. proto:icmp handling: The "icmp" protocol name was returning both
   ICMPv4 (1) and ICMPv6 (58), but Tailscale only returns ICMPv4.
   Users should use "ipv6-icmp" or protocol number 58 explicitly
   for IPv6 ICMP.

Update all test expectations accordingly. This significantly reduces
test file line count by replacing duplicated CGNAT range patterns
with single wildcard entries.
2026-02-05 19:29:16 +01:00
Kristoffer Dalby
834ac27779 policy/v2: add subnet routes and exit node compatibility tests
Add comprehensive test file for validating Headscale's ACL engine
behavior for subnet routes and exit nodes against documented
Tailscale SaaS behavior.

Tests cover:
- Category A: Subnet route basics (wildcard includes routes, tag-based
  ACL excludes routes)
- Category B: Exit node behavior (exit routes not in SrcIPs)
- Category F: Filter placement rules (filters on destination nodes)
- Category G: Protocol and port restrictions
- Category R: Route coverage rules
- Category O: Overlapping routes
- Category H: Edge cases (wildcard formats, CGNAT handling)
- Category T: Tag resolution (tags resolve to node IPs only)
- Category I: IPv6 specific behavior

The tests document expected Tailscale SaaS behavior with TODOs marking
areas where Headscale currently differs. This provides a baseline for
compatibility improvements.
2026-02-05 19:29:16 +01:00
Kristoffer Dalby
29aa08df0e policy: update test expectations for merged filter rules
Update test expectations across policy tests to expect merged
FilterRule entries instead of separate ones. Tests now expect:
- Single FilterRule with combined DstPorts for same source
- Reduced matcher counts for exit node tests

Updates #3036
2026-02-05 19:29:16 +01:00
Kristoffer Dalby
0b1727c337 policy: merge filter rules with identical SrcIPs and IPProto
Tailscale merges multiple ACL rules into fewer FilterRule entries
when they have identical SrcIPs and IPProto, combining their DstPorts
arrays. This change implements the same behavior in Headscale.

Add mergeFilterRules() which uses O(n) hash map lookup to merge rules
with identical keys. DstPorts are NOT deduplicated to match Tailscale
behavior.

Also fix DestsIsTheInternet() to handle merged filter rules where
TheInternet is combined with other destinations - now uses superset
check instead of equality check.

Updates #3036
2026-02-05 19:29:16 +01:00
Kristoffer Dalby
08fe2e4d6c policy: use CIDR format for autogroup:self destinations
Updates #3036
2026-02-05 19:29:16 +01:00
Kristoffer Dalby
cb29cade46 docs: add compatibility test documentation
Updates #3036
2026-02-05 19:29:16 +01:00
Kristoffer Dalby
8baa14ef4a policy: use CGNAT/ULA ranges for wildcard resolution
Change Asterix.Resolve() to use Tailscale's CGNAT range (100.64.0.0/10)
and ULA range (fd7a:115c:a1e0::/48) instead of all IPs (0.0.0.0/0 and
::/0).
This better matches Tailscale's security model where wildcard (*) means
"any node in the tailnet" rather than literally "any IP address on the
internet".
Updates #3036

Updates #3036
2026-02-05 19:29:16 +01:00
Kristoffer Dalby
ebdbe03639 policy: validate autogroup:self sources in ACL rules
Tailscale validates that autogroup:self destinations in ACL rules can
only be used when ALL sources are users, groups, autogroup:member, or
wildcard (*). Previously, Headscale only performed this validation for
SSH rules.
Add validateACLSrcDstCombination() to enforce that tags, autogroup:tagged,
hosts, and raw IPs cannot be used as sources with autogroup:self
destinations. Invalid policies like `tag:client → autogroup:self:*` are
now rejected at validation time, matching Tailscale behavior.
Wildcard (*) is allowed because autogroup:self evaluation narrows it
per-node to only the node's own IPs.

Updates #3036
2026-02-05 19:29:16 +01:00
Kristoffer Dalby
f735502eae policy: add ICMP protocols to default and export constants
When ACL rules don't specify a protocol, Headscale now defaults to
[TCP, UDP, ICMP, ICMPv6] instead of just [TCP, UDP], matching
Tailscale's behavior.
Also export protocol number constants (ProtocolTCP, ProtocolUDP, etc.)
for use in external test packages, renaming the string protocol
constants to ProtoNameTCP, ProtoNameUDP, etc. to avoid conflicts.
This resolves 78 ICMP-related TODOs in the Tailscale compatibility
tests, reducing the total from 165 to 87.

Updates #3036
2026-02-05 19:29:16 +01:00
Kristoffer Dalby
53d17aa321 policy: add comprehensive Tailscale ACL compatibility tests
Add extensive test coverage verifying Headscale's ACL policy behavior
matches Tailscale's coordination server. Tests cover:
- Source/destination resolution for users, groups, tags, hosts, IPs
- autogroup:member, autogroup:tagged, autogroup:self behavior
- Filter rule deduplication and merging semantics
- Multi-rule interaction patterns
- Error case validation
Key behavioral differences documented:
- Headscale creates separate filter entries per ACL rule; Tailscale
  merges rules with identical sources
- Headscale deduplicates Dsts within a rule; Tailscale does not
- Headscale does not validate autogroup:self source restrictions for
  ACL rules (only SSH rules); Tailscale rejects invalid sources
Tests are based on real Tailscale coordination server responses
captured from a test environment with 5 nodes (1 user-owned, 4 tagged).

Updates #3036
2026-02-05 19:29:16 +01:00
Kristoffer Dalby
a09b0d1d69 policy/v2: add Caller() to log statements in compileACLWithAutogroupSelf
Both compileFilterRules and compileSSHPolicy include .Caller() on
their resolution error log statements, but compileACLWithAutogroupSelf
does not. Add .Caller() to the three log sites (source resolution
error, destination resolution error, nil destination) for consistent
debuggability across all compilation paths.

Updates #2990
2026-02-03 16:53:15 +01:00
Kristoffer Dalby
362696a5ef policy/v2: keep partial IPSet on SSH destination resolution errors
In compileSSHPolicy, when resolving other (non-autogroup:self)
destinations, the code discards the entire result on error via
`continue`. If a destination alias (e.g., a tag owned by a group
with a non-existent user) returns a partial IPSet alongside an
error, valid IPs are lost.

Both ACL compilation paths (compileFilterRules and
compileACLWithAutogroupSelf) already handle this correctly by
logging the error and using the IPSet if non-nil.

Remove the `continue` so the SSH path is consistent with the
ACL paths.

Fixes #2990
2026-02-03 16:53:15 +01:00