Commit Graph

4169 Commits

Author SHA1 Message Date
Kristoffer Dalby 48e22ab769 policy/v2: rewrite tag-name first-letter check via De Morgan sshtests-prerebase-1778675224 2026-05-13 12:05:47 +00:00
Kristoffer Dalby d32d91d9fb policy/v2: accept bare-IP sshTests dst, reject only explicit CIDR
SaaS accepts dst: ["100.64.0.16"] and dst: ["fd7a:115c:a1e0::10"] as
host addresses but rejects dst: ["10.0.0.0/24"]. The earlier typed-Alias
switch rejected every *Prefix and so dropped the bare-IP shape that the
Prefix parser materialises as a /BitLen prefix.

validateSSHTestDestination now distinguishes by *Prefix mask width:
Bits() == Addr().BitLen() accepts (single host, equivalent to bare IP),
anything narrower rejects with the existing ErrSSHTestDstDisallowedElement
wording. The Host branch already used the same width check for
hosts:-table aliases.

Adds two captures for the new shapes (bare IPv4 / IPv6) and parse-time
rows for the same in types_test. The IPv6 capture lands a SaaS-side
engine asymmetry (parse-accept, engine-reject "test(s) failed" while
the IPv4 mirror engine-passes) so it is parked in
knownSSHTesterDivergences for a follow-up.
2026-05-13 10:56:24 +00:00
Kristoffer Dalby 9b4f68257d policy/v2: use Alias and SSHUser types in SSHPolicyTest 2026-05-13 10:45:03 +00:00
Kristoffer Dalby 63de6b2d38 CHANGELOG: rewrite sshTests entry to match policy tests style 2026-05-13 10:27:55 +00:00
Kristoffer Dalby 2499c066a3 policy/v2: drop useless comment on checkPeriod parse 2026-05-13 10:27:18 +00:00
Kristoffer Dalby 2ec7a71a56 policy/v2: match SaaS wording for group nesting rejection
SaaS rejects any group-in-group reference (chain, cycle, or
self-reference) with `groups["X"]: "Y": group members cannot be
recursive`. headscale rejected the same input but the message
surfaced as a generic JSON parse error from the unmarshal layer.
Groups.UnmarshalJSON now scans the raw map in descending
alphabetical order and reports the first group whose member is
itself a group, mirroring the (X, Y) pair SaaS picks (deepest
non-leaf parent first). Drop the now-unused ErrNestedGroups
sentinel and update the existing group-in-group test row plus add
chain, cycle, and self-cycle rows.
2026-05-13 10:19:09 +00:00
Kristoffer Dalby 2e1d11adbf policy/v2: reject non-ASCII tag names
SaaS rejects tag names whose first character after `tag:` is not an
ASCII letter (`[a-zA-Z]`) with `tagOwners["tag:X"]: tag names must
start with a letter, after 'tag:'`. headscale was accepting any
UTF-8. Tighten Tag.Validate to enforce the first-character rule and
reshape the surfaced error in unmarshalPolicy so the tagOwners key
appears in the SaaS-style prefix. Subsequent characters remain
unconstrained — only the leading byte is checked.
2026-05-13 10:11:55 +00:00
Kristoffer Dalby 51522195ef policy/v2: reject hosts-table aliases as SSH dst
SaaS rejects any hosts-table alias on an SSH rule dst with
`invalid dst "alias"`, irrespective of whether the alias resolves
to a single IP or a CIDR. headscale was resolving the alias through
the same path as ACL dsts and accepting the policy. Validate at the
per-SSH-rule pass so the error body matches SaaS. The existing
host-alias-as-dst sshtest_test row tested the now-rejected shape
and is dropped along with its unused commonHosts fixture.
2026-05-13 10:04:39 +00:00
Kristoffer Dalby 7e4be35cd2 policy/v2: tolerate tag-owner cycles by resolving to empty
SaaS accepts tagOwners with circular references (tag:a -> tag:b ->
tag:a, tag:a -> tag:a) by dropping the cycle edge instead of failing
the policy. Each tag whose only path back is via the cycle resolves
to the empty owner set; a sibling non-tag owner survives. headscale
previously rejected the policy with ErrCircularReference at parse
time. Drop the sentinel, change flattenTags to return an empty owner
list on revisit, and update flattenTagOwners tests to capture the
new behaviour including the sibling-survives edge case.
2026-05-13 09:57:54 +00:00
Kristoffer Dalby c2d012cdf9 policy/v2: trim whitespace in SSH src and dst aliases
SaaS trims surrounding whitespace from src/dst entries before
dispatching to the alias lookup, so `"tag:server "` resolves to the
same tag and `" odin@example.com"` resolves to the same user.
headscale was treating the untrimmed strings as literals, which
either failed the tag/user lookup or dropped the affected node from
every rule referencing it. Trim inside AliasEnc.UnmarshalJSON so
ACL src/dst and SSH src/dst all benefit.
2026-05-13 09:51:57 +00:00
Kristoffer Dalby e772faf22f policy/v2: add ssh-edges captures from Tailscale SaaS
30 new captures recorded against SaaS covering multi-rule SSH
policies, group nesting, tag-owner cycles, empty references, unicode,
hosts table aliases, multi-src/dst, whitespace in src/dst, null and
missing fields, and sshTests edges. 20 captures already match
headscale behaviour. The remaining 10 land in sshSkipReasons and
sshRejectSkipReasons tracking 5 engine divergences (whitespace trim
in src/dst, tag-owner cycle tolerance, hosts-alias rejection on SSH
dst, non-ASCII tag-name rejection, group-nesting error wording);
follow-up commits close each gap and drain the corresponding skip
entries.
2026-05-13 09:46:30 +00:00
Kristoffer Dalby 9df5a1513e policy/v2: trim whitespace and reject negative checkPeriod
SaaS trims leading whitespace in action and per-user entries before
matching, so headscale does too. Reject negative checkPeriod with
"must be a positive duration" matching SaaS body. The 168h upper
bound is inclusive.
2026-05-13 08:17:11 +00:00
Kristoffer Dalby 3e83be2de1 policy/v2: align SSH rule validation with Tailscale SaaS
Drop autogroup/localpart strictness on SSH users. Drop checkPeriod
minimum. Reject empty/wildcard users and empty acceptEnv at parse.
Match SaaS wording for action and checkPeriod errors. 28 captures
under testdata/ssh_results/ssh-malformed-*.hujson lock the surface.
2026-05-13 08:17:01 +00:00
Kristoffer Dalby 9da4543c8d policy/v2: branch tailscale_ssh_data_compat_test on APIResponseCode
Rejection captures (APIResponseCode != 200) now route through
NewPolicyManager + SetPolicy mirroring sshtester_compat_test.go; the
SaaS Message must be a substring of headscale's error. The accepted
path (200) keeps the existing per-node SSHRules comparison. Adds 28
ssh-malformed-* captures and a parallel sshRejectSkipReasons map for
4xx scenarios where headscale and SaaS legitimately disagree.
2026-05-13 08:16:50 +00:00
Kristoffer Dalby 439964ae24 cmd/headscale/cli: mention sshTests in policy check help 2026-05-13 08:16:50 +00:00
Kristoffer Dalby b2d891c9d1 policy/v2: address review on sshTests engine
Empty-dst-nodes fails loudly. Compat runner uses per-capture topology
so capture IPs match. Test rows tightened; stale godoc and goto fixed.
2026-05-13 08:16:50 +00:00
Kristoffer Dalby ea52cbb288 CHANGELOG: document sshTests evaluation (beta) 2026-05-13 08:16:50 +00:00
Kristoffer Dalby eafc35106e integration: regenerate workflow for sshTests integration test 2026-05-13 08:16:40 +00:00
Kristoffer Dalby 5bed3b29b9 integration: reject failing sshTests at headscale policy set 2026-05-13 08:16:28 +00:00
Kristoffer Dalby 5b7e9bb9c8 policy/v2: add sshtester captures from Tailscale SaaS
32 captures. knownSSHTesterDivergences tracks sshtest-user-wildcard
where SaaS rejects users:["*"] at parse and headscale does not.
2026-05-13 08:16:17 +00:00
Kristoffer Dalby 90648a525d policy/v2: add sshtester compat runner
Replays captures under testdata/sshtest_results. 200 path requires
parse and SetPolicy to succeed; non-200 requires an error whose body
substring-matches the captured SaaS message.
2026-05-13 08:16:17 +00:00
Kristoffer Dalby 65e4dbc4c4 policy/v2: evaluate sshTests at write boundary
accept and check count as reachable; check requires a check-action rule
specifically. SetPolicy rejects failing tests without mutating live
state; boot path warns.
2026-05-13 08:16:17 +00:00
Kristoffer Dalby 57f838e268 policy/v2: validate sshTests at parse
Reject empty src/dst, port-suffix dst, CIDR dst, autogroup:internet dst,
unknown-tag dst. Each error wraps errSSHPolicyTestsFailed for errors.Is.
2026-05-13 08:16:17 +00:00
Florian Preinstorfer c4ab267c36 Refresh features page 2026-05-12 14:12:29 +02:00
Florian Preinstorfer 109bfc404c Refresh docs for Grants
- Mention policy as generic term that covers ACLs or Grants
- Refresh routes policy examples
- Remove Headscale specific exit node separation. Use via instead.

Fixes: #3087
2026-05-12 14:12:29 +02:00
Florian Preinstorfer 1a64d950fd Document supported autogroups once 2026-05-12 14:12:29 +02:00
Florian Preinstorfer edb7ad0f81 Rewrite ACL docs as policy
- Rename from acl.md to policy.md and setup redirect links
- Mention both ACLs and Grants
- Remove most old ACL docs and replace with links to Tailscale docs
- Add "Getting started" section
- Add section about notable differences
2026-05-12 14:12:29 +02:00
Florian Preinstorfer 892ffffc4a Remove misleading comment 2026-05-12 14:12:29 +02:00
Florian Preinstorfer e13f0458bb Remove redundant prefix 2026-05-12 14:12:29 +02:00
Florian Preinstorfer 68b0014871 Use distroless without quotes 2026-05-12 14:12:29 +02:00
Florian Preinstorfer 484462898b Remove link to sqlite
Other mentions of SQLite don't link either.
2026-05-12 14:12:29 +02:00
Florian Preinstorfer 45b698dbac Shorten container introduction 2026-05-12 14:12:29 +02:00
Florian Preinstorfer 14ce7e9106 Remove link to Arch AUR headscale-git
Its outdated and unmaintained.
2026-05-12 14:12:29 +02:00
Florian Preinstorfer 84c7f0d450 Link to development builds 2026-05-12 14:12:29 +02:00
Florian Preinstorfer c7f221dd0a Fix typo and wording 2026-05-12 14:12:29 +02:00
Florian Preinstorfer 163363a12a Use docs instead of KB 2026-05-12 14:12:29 +02:00
Kristoffer Dalby f03d41ea9a CHANGELOG: document policy tests (beta)
Fixes #1803
2026-05-12 11:54:54 +01:00
Kristoffer Dalby d5b2837231 policy/v2: match default proto set for tests with no proto
The policy `tests` block lets entries omit `proto`. Tailscale's client
maps that to the default protocol set {TCP, UDP, ICMP, ICMPv6} — the
captured packet_filter_matches show all four IANA numbers explicitly
when no proto is set — and a rule restricted to any one of them
satisfies an empty-proto reachability test.

srcReachesDst was passing the empty Protocol through unchanged, which
landed an empty []int in ruleMatchesProto. The matcher then short-
circuited to "no match" for every rule with a non-empty IPProto
restriction, including TCP-only grants compiled from `ip: ["tcp:80"]`.
The bug surfaced in the captured allpass-acls-and-grants-mixed
scenario: the grant `tag:client → webserver:80` was reachable in the
compiled filter but the empty-proto test could not see it.

Expand the empty Protocol to the default set at the call site so
ruleMatchesProto's intersection check sees the right requested
protocols. Drop the now-dead empty-requestedProtos branch from the
matcher. The last divergence drops out of knownPolicyTesterDivergences
as a result.

Updates #1803
2026-05-12 11:54:54 +01:00
Kristoffer Dalby e4e209f919 policy/v2: canonicalize Protocol form during unmarshal
Tailscale accepts both named ("tcp") and numeric IANA ("6") protocol
forms wherever a Protocol value is allowed. Headscale stored whichever
form the user wrote, leaving downstream code with two equivalents to
handle separately. validateProtocolPortCompatibility only recognised
the named constants and rejected the numeric form, so a policy with
`proto: "6", dst: ["host:443"]` was rejected at parse time even though
SaaS accepts it.

Resolve the disagreement by normalising to the named form during
Protocol.UnmarshalJSON. Every downstream consumer now sees one form
regardless of what the user wrote, so layered guards like
`|| protocol == "6"` in the validator are unnecessary.

Updates #1803
2026-05-12 11:54:54 +01:00
Kristoffer Dalby f172dba0e3 policy/v2: validate tests block at parse boundary
A `tests` entry describes one connection attempt to one specific
host on one specific port over a connection-oriented protocol, and
asserts whether it is allowed or denied. Five shape rules follow —
single-port dst, proto in {tcp, udp, sctp, ""}, no
autogroup:internet dst, no CIDR-typed dst (raw `/N` or hosts:-alias
to a multi-host prefix), at least one of accept/deny — and every
one was previously silently accepted by headscale even though
Tailscale SaaS rejects them as "test(s) failed".

Enforce them in one pass over `pol.Tests` from `Policy.validate()`,
reusing the existing parse-time multierr aggregation. The same
shapes remain valid inside ACL or Grant destinations where the rule
does not apply; the validator only walks the tests array.

The compat runner now treats parse-time errors equivalently to
SetPolicy errors so the captured Tailscale body still matches via
substring regardless of which step surfaces the rejection. Nine
divergences resolved by this validation pass drop out of
knownPolicyTesterDivergences.

Updates #1803
2026-05-12 11:54:54 +01:00
Kristoffer Dalby c0774a739b policy/v2: add policytester captures recorded from Tailscale SaaS
57 captures covering the alias × outcome matrix for the tests block,
recorded against a real Tailscale SaaS tailnet. Replayed by
TestPolicyTesterCompat.

Bump the check-added-large-files pre-commit threshold to 1024 KB —
captures include verbose per-node netmaps and one is 620 KB.

Updates #1803
2026-05-12 11:54:54 +01:00
Kristoffer Dalby 7bc701179b policy/v2: add policytester compat test runner
Pin headscale's accept/reject decision and error body against
Tailscale SaaS by replaying captures recorded from a real tailnet.
Mirrors the tailscale_grants_compat_test.go pattern: glob over
testdata/policytest_results/, one t.Run per file, parse-or-SetPolicy
error must contain the captured api_response_body.message.

errPolicyTestsFailed is "test(s) failed" — Tailscale's literal body —
so substring match works against captured response bodies. Per-test
detail (src, dst, expected vs got) is preserved below the prefix for
the CLI / config-reload paths that don't have an audit endpoint.

knownPolicyTesterDivergences gates the 12 mismatches the captures
will surface so the suite stays green; engine fixes in follow-up
commits drop the entries as each is resolved.

Updates #1803
2026-05-12 11:54:54 +01:00
Kristoffer Dalby b29ae25356 policy/v2: evaluate the tests block on user-initiated writes
v2 silently dropped policy.tests, so a policy that contradicted its
own assertions still applied. Resolve src/dst via the existing Alias
machinery, walk the compiled global filter rules (acls and grants
both contribute), and run on every user-write boundary: SetPolicy,
the file watcher, and `headscale policy check`. A failing test
rejects the write before it mutates live state.

Boot-time reload skips evaluation; an already-stored policy that
references a deleted user shouldn't lock the server out.

`headscale policy check` is a thin frontend for the new CheckPolicy
gRPC method. The server-side handler builds a fresh PolicyManager
from the request bytes and the state's live users/nodes, runs
SetPolicy on the sandbox so the tests block executes, and returns
the result through gRPC status. No persistence, no policy_mode
coupling. --bypass-grpc-and-access-database-directly opens the DB
directly when the server is not running.

cmd/headscale/cli/root.go no longer special-cases `policy check` in
init() (the early return from PR #2580 broke --config registration
and viper priming for --bypass).

integration/cli_policy_test.go covers policy_mode={file,database} x
fixture={acl-only, acl+passing-tests, acl+failing-tests} x
bypass={false,true} = 12 rows.

Updates #1803

Co-authored-by: Janis Jansons <janhouse@gmail.com>
2026-05-12 11:54:54 +01:00
Kristoffer Dalby 56146de377 proto: add CheckPolicy RPC
CheckPolicy validates a candidate policy against a running server's
live users and nodes (running its tests block) without persisting
anything. Used by 'headscale policy check' to replace the in-process
validation path the CLI runs today, which would otherwise need its
own database connection.

Updates #1803
2026-05-12 11:54:54 +01:00
Kristoffer Dalby c3df84e354 policy/matcher: include CapGrant.Dsts in match destinations
MatchFromFilterRule only read DstPorts[].IP into the destination
IPSet. Cap-grant-only filter rules (e.g. tailscale.com/cap/relay)
carry their destinations in CapGrant[].Dsts, so the derived matchers
had empty dest sets and BuildPeerMap / ReduceNodes never exposed the
cap target to its source nodes. Without a companion IP-level grant
the relay node stayed invisible, so clients never tried to use it
and connections sat on DERP.

Union CapGrant[].Dsts into the destination IPSet alongside DstPorts.
Restores peer-visibility for any cap-grant-only relationship; the
peer-relay flow is the most visible instance.

Fixes #3256
2026-05-11 14:55:06 +01:00
Kristoffer Dalby 795a1efe9b ci: fetch full history in golangci-lint job
revgrep needs pull_request.base.sha in the local clone to compute
the diff against new code. With fetch-depth: 2, only HEAD and one
parent are fetched, so a stale base SHA (when main moves between
PR syncs) is not reachable and revgrep falls through, surfacing
pre-existing issues outside the PR scope.
2026-05-11 10:34:58 +01:00
Kristoffer Dalby dc733767c4 Dockerfile.tailscale-HEAD,Dockerfile.derper: bump golang to 1.26.3
tailscale upstream go.mod now requires 1.26.3.
2026-05-11 10:34:58 +01:00
Lealem Amedie 542091e82b Add unit test 2026-05-11 09:25:26 +01:00
Lealem Amedie 6cd919d411 mapper: include UserProfiles in policy-change MapResponses 2026-05-11 09:25:26 +01:00
Kristoffer Dalby 2f907edf87 hscontrol/types: regenerate types_clone.go for viewer bump
cmd/viewer in tailscale.com/cmd v1.97.0-pre emits new(*x) instead
of ptr.To(*x). No behaviour change.
2026-05-11 08:46:12 +01:00