Commit Graph

4159 Commits

Author SHA1 Message Date
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
Kristoffer Dalby 9621a97ebe ci, pre-commit: validate vendor hash via vendorhash check
Replace the grep/awk hash extraction in build.yml with a structured
vendorhash check step; the PR review comment now reads expected/
actual values directly from $GITHUB_OUTPUT instead of scraping Nix
stderr. Add a prek hook so divergence is caught locally before push.
2026-05-11 08:46:12 +01:00
Kristoffer Dalby e470774f6a cmd/vendorhash: track vendor SRI in flakehashes.json
Move the headscale vendorHash out of flake.nix into a content-
addressed flakehashes.json maintained by a small Go tool. The
schema and goModFingerprint algorithm mirror upstream tailscale's
tool/updateflakes so a future shared library extraction is trivial.

vendorhash check verifies flakehashes.json against the current
go.mod/go.sum. Hot path is a sha256 over those two files, so
re-runs without input change are essentially free; only an actual
fingerprint drift triggers go mod vendor + nardump.SRI.

vendorhash update recomputes both fields and rewrites the JSON.
The nix-vendor-sri devShell shim now wraps it.
2026-05-11 08:46:12 +01:00
Kristoffer Dalby 980622e9a5 flake.nix, go.mod: bump tailscale.com to v1.97.0-pre
Pulls in the cmd/nardump library split (tailscale/tailscale#19551)
so flakehashes.json tooling can import nardump.SRI directly.

Side effects: Go directive bumps to 1.26.2 and the nixpkgs lock
advances to a revision shipping go 1.26.2.
2026-05-11 08:46:12 +01:00
Kristoffer Dalby 4e0c2b8556 cmd/headscale/cli: validate users in policy check
Add --bypass-grpc-and-access-database-directly to policy check so
the new ambiguous-user validator runs against the live user list.
Without the flag, policy check stays a syntax-only check and the
success message says so.

Updates #3160
2026-05-09 11:28:12 +01:00
Kristoffer Dalby bc9fb6d403 hscontrol/policy/v2: reject ambiguous user references at load time
When a user@ token resolved to more than one DB row, ACL and SSH
rules referencing it were silently dropped at compile time, leaving
clients with SSHPolicy={rules: null} and no signal to the admin.

Validate every Username reference in groups, tagOwners,
autoApprovers, ACLs and SSH rules at NewPolicyManager and SetPolicy
and return ErrMultipleUsersFound. Missing-user tokens stay tolerant
per #2863.

Updates #3160
2026-05-09 11:28:12 +01:00
Möhsün Babayev 585d0c01bc docs(config): fix typo in config-example.yaml
Fixes a typo in the description of `metrics_listen_addr` property.
2026-05-09 05:14:08 +02:00
Möhsün Babayev 01eb5402f9 docs(setup): fix typo in requirements.md
Fix the typo in spelling of "Let's Encrypt".
2026-05-09 05:14:08 +02:00
MunMunMiao e597f4c8a0 Add Headscale UI to web UI documentation 2026-05-09 05:02:44 +02:00
SAY-5 01e548e030 state: avoid nil deref in registration handlers when old user is missing
Mirror the guard from HandleNodeFromPreAuthKey in HandleNodeFromAuthPath.
Both functions log the old user's name in the "different user" branch
when an existing NodeStore entry under the same machine key belongs to
another user. UserView.Name dereferences the backing User pointer
unconditionally, so when the cached node was loaded with a non-nil
UserID but a nil User (Preload join missed the row, or upstream code
left the snapshot in that shape), the log call panics with a nil-pointer
dereference at hscontrol/types/types_view.go:97.

The panic is caught by the http2 server's runHandler for the noise
control plane, so the process keeps running but every retry produces a
new panic — production has observed bursts of ~1.9k panics per hour
during a tailscaled reconnect loop. The gRPC/OIDC entry has no equivalent
recover and would surface the panic to the caller.

Guard both call sites with oldUser.Valid() and fall back to an empty
old-user name when the pointer is nil. The "Creating new node for
different user" log line still includes the existing node ID, hostname,
machine key, and new user, so operator visibility is preserved.

Add reproduction tests for both handlers seeding the orphan shape
directly into NodeStore via PutNodeInStoreForTest.

Co-Authored-By: Kristoffer Dalby <kristoffer@dalby.cc>
2026-05-06 07:23:02 +01:00
Kristoffer Dalby 9482cdf590 testdata: drop unused uppercase SSH-*.hujson fixtures
The 39 SSH-*.hujson files in hscontrol/policy/v2/testdata/ssh_results/
were legacy hand-written "expected SSH rules" snippets superseded by
the lowercase tscap captures (ssh-*.hujson). The active loader in
TestSSHDataCompat globs ssh-*.hujson; filepath.Glob is case-sensitive
on Linux so the uppercase set was loaded by no test.

The duplication caused permanent dirty git state on case-insensitive
filesystems (APFS, NTFS) where only one of SSH-A1.hujson and
ssh-a1.hujson can physically exist in the working tree.

Add an assertion to TestSSHDataCompat that the loader picks up every
*.hujson under ssh_results/ so future fixture migrations cannot leave
stranded files behind.

Fixes #3240
2026-05-05 11:59:01 +01:00