Compare commits

..

10 Commits

Author SHA1 Message Date
Kristoffer Dalby 1ef18fb010 cli/policy: route check through gRPC; bypass goes direct to DB
policy check previously ran the full policy engine in-process inside
the CLI, building a sandbox PolicyManager from the file and the
database. That duplicated the engine's runtime dependencies onto the
CLI and forced --bypass-grpc-and-access-database-directly to pull in
full server config validation. nblock hit it on PR #3229: passing
--bypass with a real config produced a flood of 'Fatal config error:'
lines from validateServerConfig because the cobra init early-return
for 'policy check' skipped --config registration and OnInitialize.

Make 'policy check' 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 keeps doing what its name
says — opens the DB directly for cases where the server is not
running — but is no longer the only way to evaluate a tests block.

Drop the 'policy check' early-return in cmd/headscale/cli/root.go
(added in PR #2580 when check was syntax-only). All paths now need
either gRPC or direct DB access, both of which want the config and
flags the rest of cobra init sets up.

integration/cli_policy_test.go covers the matrix nblock asked about:
policy_mode={file,database} x fixture={acl-only, acl+passing-tests,
acl+failing-tests} x bypass={false,true} = 12 rows. acl-only and
acl-plus-passing-tests must pass; acl-plus-failing-tests must surface
'test(s) failed'; the policy_mode axis proves check does not depend
on where the server stores its current policy.

Updates #1803
2026-05-11 14:09:59 +00:00
Kristoffer Dalby 596ecec1db 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-11 14:09:41 +00:00
Kristoffer Dalby 091c65f067 Dockerfile.{tailscale-HEAD,derper}: bump build image to golang:1.26.3-alpine
tailscale/tailscale go.mod requires Go >= 1.26.3 since 24eb1574
(2026-05-07). Both Dockerfiles clone tailscale HEAD and run with
GOTOOLCHAIN=local, so go install fails at `go.mod requires go >= 1.26.3
(running go 1.26.2; GOTOOLCHAIN=local)` blocking every PR's integration
build.
2026-05-09 15:18:01 +00:00
Kristoffer Dalby 1661a9122a CHANGELOG: document policy tests (beta)
Fixes #1803
2026-05-09 14:10:51 +00:00
Kristoffer Dalby a9a84b3f0a 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-09 14:10:33 +00:00
Kristoffer Dalby 9685a742a6 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-09 14:10:33 +00:00
Kristoffer Dalby 7794d30263 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-09 14:10:32 +00:00
Kristoffer Dalby 220a2e31f3 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-09 14:10:32 +00:00
Kristoffer Dalby 3a0cc30a3a 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-09 14:10:32 +00:00
Kristoffer Dalby 47f851c43b 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` runs the engine only under
--bypass-grpc-and-access-database-directly (where it has live users
+ nodes); without the flag a policy with tests is rejected with a
pointer at the flag rather than silently skipped.

Updates #1803

Co-authored-by: Janis Jansons <janhouse@gmail.com>
2026-05-09 14:10:32 +00:00
151 changed files with 800 additions and 2079535 deletions
+15 -14
View File
@@ -38,19 +38,24 @@ jobs:
'**/flake.lock') }}
restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }}
- name: Check vendor hash
id: vendorhash
- name: Run nix build
id: build
if: steps.changed-files.outputs.files == 'true'
run: |
nix develop --command -- go run ./cmd/vendorhash check | tee check-result
{
grep '^expected_sri=' check-result || true
grep '^actual_sri=' check-result || true
} >> "$GITHUB_OUTPUT"
nix build |& tee build-result
BUILD_STATUS="${PIPESTATUS[0]}"
- name: Vendor hash diverging
OLD_HASH=$(cat build-result | grep specified: | awk -F ':' '{print $2}' | sed 's/ //g')
NEW_HASH=$(cat build-result | grep got: | awk -F ':' '{print $2}' | sed 's/ //g')
echo "OLD_HASH=$OLD_HASH" >> $GITHUB_OUTPUT
echo "NEW_HASH=$NEW_HASH" >> $GITHUB_OUTPUT
exit $BUILD_STATUS
- name: Nix gosum diverging
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
if: failure() && steps.vendorhash.outcome == 'failure'
if: failure() && steps.build.outcome == 'failure'
with:
github-token: ${{secrets.GITHUB_TOKEN}}
script: |
@@ -58,13 +63,9 @@ jobs:
pull_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: 'Vendor hash in `flakehashes.json` is stale (was `${{ steps.vendorhash.outputs.expected_sri }}`, should be `${{ steps.vendorhash.outputs.actual_sri }}`). Run `go run ./cmd/vendorhash update` and commit the result.'
body: 'Nix build failed with wrong gosum, please update "vendorSha256" (${{ steps.build.outputs.OLD_HASH }}) for the "headscale" package in flake.nix with the new SHA: ${{ steps.build.outputs.NEW_HASH }}'
})
- name: Run nix build
if: steps.changed-files.outputs.files == 'true'
run: nix build
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
if: steps.changed-files.outputs.files == 'true'
with:
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 0
fetch-depth: 2
- name: Get changed files
id: changed-files
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
-2
View File
@@ -202,8 +202,6 @@ jobs:
- TestAuthWebFlowAuthenticationPingAll
- TestAuthWebFlowLogoutAndReloginSameUser
- TestAuthWebFlowLogoutAndReloginNewUser
- TestPolicyCheckCommand
- TestSSHTestsRejectFailingPolicy
- TestUserCommand
- TestPreAuthKeyCommand
- TestPreAuthKeyCommandWithoutExpiry
-8
View File
@@ -61,11 +61,3 @@ repos:
language: system
types: [go]
pass_filenames: false
# vendor-hash keeps flakehashes.json in sync with go.mod/go.sum.
- id: vendor-hash
name: vendor-hash
entry: nix develop --command -- go run ./cmd/vendorhash check
language: system
files: ^(go\.mod|go\.sum|flakehashes\.json)$
pass_filenames: false
+6 -39
View File
@@ -31,51 +31,18 @@ A new `headscale auth` CLI command group supports the approval flow:
Headscale now evaluates the `tests` block in a policy file. Tests assert reachability between
named sources and destinations and cover the whole policy — both `acls` and `grants` rules
contribute. They run on user-initiated writes via `headscale policy set`, on SIGHUP reload
(`systemctl reload headscale` / `kill -HUP $(pidof headscale)`), and on `headscale policy check`.
A failing test rejects the write before it is applied, with the same error message Tailscale SaaS
would return for the same policy.
contribute. They run on user-initiated writes via `headscale policy set`, the file watcher, and
`headscale policy check`. A failing test rejects the write before it is applied, with the same
error message Tailscale SaaS would return for the same policy.
At boot a stored policy whose tests no longer pass — for example because a referenced user was
deleted while the server was offline — logs a warning and the server keeps running. Fix the
policy and reload.
Tests do not run at boot. An already-stored policy that no longer passes — for example because a
referenced user was deleted while the server was offline — logs a warning and the server keeps
running.
This feature is **beta** while behavioural coverage against Tailscale SaaS broadens.
[#3229](https://github.com/juanfont/headscale/pull/3229)
### SSH policy tests (beta)
Headscale now evaluates the `sshTests` block in a policy file. Tests assert which SSH login users
can connect from a named source to named destinations against the same SSH rules clients receive.
They run on `headscale policy set`, on SIGHUP reload (`systemctl reload headscale` /
`kill -HUP $(pidof headscale)`), and on `headscale policy check`. A failing test rejects the write
before it is applied, with the same error message Tailscale SaaS would return for the same policy.
An entry has the shape:
```hujson
"sshTests": [
{
"src": "alice@example.com",
"dst": ["tag:server"],
"accept": ["root"],
"deny": ["alice"],
"check": ["ubuntu"]
}
]
```
`accept` asserts the listed login users reach every dst via an accept- or check-action SSH rule,
`deny` asserts none of them reach any dst, and `check` requires reachability specifically via a
check-action rule.
At boot a stored policy whose sshTests no longer pass — for example because a referenced user was
deleted while the server was offline — logs a warning and the server keeps running. Fix the policy
and reload.
This feature is **beta** while behavioural coverage against Tailscale SaaS broadens.
### Grants
We now support [Tailscale grants](https://tailscale.com/docs/features/access-control/grants)
+1 -1
View File
@@ -2,7 +2,7 @@
# and are in no way endorsed by Headscale's maintainers as an
# official nor supported release or distribution.
FROM docker.io/golang:1.26.2-trixie AS builder
FROM docker.io/golang:1.26.1-trixie AS builder
ARG VERSION=dev
ENV GOPATH /go
WORKDIR /go/src/headscale
+4 -4
View File
@@ -48,7 +48,7 @@ func init() {
policyCmd.AddCommand(setPolicy)
checkPolicy.Flags().StringP("file", "f", "", "Path to a policy file in HuJSON format")
checkPolicy.Flags().BoolP(bypassFlag, "", false, "Open the database directly (no gRPC, no running server) to validate user@ token references and to evaluate the policy's tests and sshTests blocks. Required when those checks are needed.")
checkPolicy.Flags().BoolP(bypassFlag, "", false, "Open the database directly (no gRPC, no running server) to validate user@ token references and to evaluate the policy's tests block. Required when those checks are needed.")
mustMarkRequired(checkPolicy, "file")
policyCmd.AddCommand(checkPolicy)
}
@@ -173,8 +173,8 @@ var checkPolicy = &cobra.Command{
Short: "Check the Policy file for errors",
Long: `
Check validates the policy against the server's live users and nodes,
running any "tests" or "sshTests" block. By default the command is a
thin frontend for a gRPC call to a running headscale; pass --` + bypassFlag + ` to
running any "tests" block. By default the command is a thin frontend
for a gRPC call to a running headscale; pass --` + bypassFlag + ` to
open the database directly when headscale is not running.`,
RunE: func(cmd *cobra.Command, args []string) error {
policyPath, _ := cmd.Flags().GetString("file")
@@ -208,7 +208,7 @@ var checkPolicy = &cobra.Command{
// NewPolicyManager validates structure and user references
// but intentionally skips test evaluation (boot path).
// SetPolicy is the user-write boundary and is what runs the
// tests and sshTests blocks.
// tests block.
pm, err := policy.NewPolicyManager(policyBytes, users, nodes.ViewSlice())
if err != nil {
return fmt.Errorf("parsing policy file: %w", err)
-221
View File
@@ -1,221 +0,0 @@
// vendorhash maintains the Nix SRI hash for the Go module vendor tree
// and stores it in flakehashes.json alongside a content fingerprint of
// go.mod and go.sum.
//
// Each block records its input fingerprint (goModSum) so that re-runs
// with no input change are essentially free: the fast path is just a
// sha256 over two small files. The vendor tree is only re-walked when
// the fingerprint actually drifts.
//
// Subcommands:
//
// vendorhash check exit non-zero if flakehashes.json is stale
// vendorhash update recompute and rewrite flakehashes.json
//
// The JSON schema and goModFingerprint algorithm mirror upstream
// tailscale's tool/updateflakes so a future shared library extraction
// is straightforward.
package main
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"tailscale.com/cmd/nardump/nardump"
)
const (
hashesFile = "flakehashes.json"
goModFile = "go.mod"
goSumFile = "go.sum"
)
type FlakeHashes struct {
Vendor VendorBlock `json:"vendor"`
}
type VendorBlock struct {
GoModSum string `json:"goModSum"`
SRI string `json:"sri"`
}
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(2)
}
ctx := context.Background()
var err error
switch os.Args[1] {
case "check":
err = cmdCheck(ctx)
case "update":
err = cmdUpdate(ctx)
case "-h", "--help", "help":
usage()
return
default:
usage()
os.Exit(2)
}
if err != nil {
if errors.Is(err, errStale) {
os.Exit(1)
}
fmt.Fprintln(os.Stderr, "vendorhash:", err)
os.Exit(1)
}
}
func usage() {
fmt.Fprintln(os.Stderr, "usage: vendorhash <check|update>")
}
// errStale signals to main that the check found a mismatch; it has
// already printed a remediation message, so main should exit 1
// silently.
var errStale = errors.New("vendor hash stale")
// cmdCheck verifies that flakehashes.json matches the current
// go.mod/go.sum. The fast path (fingerprint unchanged) costs only
// a sha256 over the two files. On mismatch, it computes the actual
// SRI so the failure message gives the developer the value to paste
// (or to run `vendorhash update`).
func cmdCheck(ctx context.Context) error {
hashes, err := loadHashes()
if err != nil {
return err
}
curFP, err := goModFingerprint()
if err != nil {
return err
}
if curFP == hashes.Vendor.GoModSum {
return nil
}
curSRI, err := hashVendor(ctx)
if err != nil {
return err
}
fmt.Fprintln(os.Stderr, "vendor hash is stale.")
fmt.Fprintf(os.Stderr, " expected goModSum: %s\n", hashes.Vendor.GoModSum)
fmt.Fprintf(os.Stderr, " actual goModSum: %s\n", curFP)
fmt.Fprintf(os.Stderr, " expected sri: %s\n", hashes.Vendor.SRI)
fmt.Fprintf(os.Stderr, " actual sri: %s\n", curSRI)
fmt.Fprintln(os.Stderr, "run: go run ./cmd/vendorhash update")
// Also emit machine-parseable lines so CI can pick them up.
fmt.Printf("expected_sri=%s\n", hashes.Vendor.SRI)
fmt.Printf("actual_sri=%s\n", curSRI)
return errStale
}
func cmdUpdate(ctx context.Context) error {
fp, err := goModFingerprint()
if err != nil {
return err
}
sri, err := hashVendor(ctx)
if err != nil {
return err
}
return writeHashes(FlakeHashes{
Vendor: VendorBlock{
GoModSum: fp,
SRI: sri,
},
})
}
// goModFingerprint returns a content fingerprint of go.mod and go.sum
// that changes whenever either file changes. The byte layout matches
// upstream tailscale's tool/updateflakes.
func goModFingerprint() (string, error) {
h := sha256.New()
for _, f := range []string{goModFile, goSumFile} {
b, err := os.ReadFile(f)
if err != nil {
return "", err
}
fmt.Fprintf(h, "%s %d\n", f, len(b))
h.Write(b)
}
return "sha256-" + base64.StdEncoding.EncodeToString(h.Sum(nil)), nil
}
// hashVendor runs `go mod vendor` into a temporary directory and
// returns the Nix SRI hash of the resulting tree.
func hashVendor(ctx context.Context) (string, error) {
out, err := os.MkdirTemp("", "nar-vendor-")
if err != nil {
return "", err
}
// `go mod vendor -o` requires the destination to not already exist.
err = os.Remove(out)
if err != nil {
return "", err
}
defer os.RemoveAll(out)
cmd := exec.CommandContext(ctx, "go", "mod", "vendor", "-o", out)
cmd.Env = append(os.Environ(), "GOWORK=off")
cmd.Stderr = os.Stderr
err = cmd.Run()
if err != nil {
return "", fmt.Errorf("go mod vendor: %w", err)
}
return nardump.SRI(os.DirFS(out))
}
func loadHashes() (FlakeHashes, error) {
var h FlakeHashes
b, err := os.ReadFile(hashesFile)
if err != nil {
return h, err
}
err = json.Unmarshal(b, &h)
if err != nil {
return h, fmt.Errorf("%s: %w", hashesFile, err)
}
return h, nil
}
func writeHashes(h FlakeHashes) error {
b, err := json.MarshalIndent(h, "", " ")
if err != nil {
return err
}
b = append(b, '\n')
// flakehashes.json is committed source read by Nix during evaluation;
// world-readable matches every other tracked file in the repo.
return os.WriteFile(hashesFile, b, 0o644) //nolint:gosec
}
+7 -7
View File
@@ -281,21 +281,21 @@ log:
format: text
## Policy
# Headscale supports a wide range of Tailscale policy features such as ACLs and
# Grants. Please have a look at their docs to better understand the concepts:
# ACLs: https://tailscale.com/docs/features/access-control/acls
# Grants: https://tailscale.com/docs/features/access-control/grants
# headscale supports Tailscale's ACL policies.
# Please have a look to their KB to better
# understand the concepts: https://tailscale.com/docs/features/access-control/acls
policy:
# The mode can be "file" or "database" that defines
# where the policies are stored and read from.
# where the ACL policies are stored and read from.
mode: file
# If the mode is set to "file", the path to a HuJSON file containing policies.
# If the mode is set to "file", the path to a
# HuJSON file containing ACL policies.
path: ""
## DNS
#
# headscale supports Tailscale's DNS configuration and MagicDNS.
# Please have a look to their docs to better understand the concepts:
# Please have a look to their KB to better understand the concepts:
#
# - https://tailscale.com/docs/features/access-control/acls
# - https://tailscale.com/docs/features/magicdns
+3 -3
View File
@@ -134,7 +134,7 @@ help to the community.
Running headscale on a machine that is also in the tailnet can cause problems with subnet routers, traffic relay nodes, and MagicDNS. It might work, but it is not supported.
## Why do two nodes see each other in their status, even if a policy rule allows traffic only in one direction?
## Why do two nodes see each other in their status, even if an ACL allows traffic only in one direction?
A frequent use case is to allow traffic only from one node to another, but not the other way around. For example, the
workstation of an administrator should be able to connect to all nodes but the nodes themselves shouldn't be able to
@@ -142,7 +142,7 @@ connect back to the administrator's node. Why do all nodes see the administrator
`tailscale status`?
This is essentially how Tailscale works. If traffic is allowed to flow in one direction, then both nodes see each other
in their output of `tailscale status`. Traffic is still filtered according to the policy, with the exception of
in their output of `tailscale status`. Traffic is still filtered according to the ACL, with the exception of
`tailscale ping` which is always allowed in either direction.
See also <https://tailscale.com/docs/concepts/device-visibility>.
@@ -191,7 +191,7 @@ following steps can be used to migrate from unsupported IP prefixes back to the
SET ipv4=concat('100.64.', id/256, '.', id%256),
ipv6=concat('fd7a:115c:a1e0::', format('%x', id));
```
- Update the [policy](../ref/policy.md) to reflect the IP address changes (if any)
- Update the [policy](../ref/acls.md) to reflect the IP address changes (if any)
- Start Headscale
Nodes should reconnect within a few seconds and pickup their newly assigned IP addresses.
+6 -7
View File
@@ -13,20 +13,19 @@ provides on overview of Headscale's feature and compatibility with the Tailscale
- [x] [Global and restricted nameservers (split DNS)](https://tailscale.com/docs/reference/dns-in-tailscale#nameservers)
- [x] [search domains](https://tailscale.com/docs/reference/dns-in-tailscale#search-domains)
- [x] [Extra DNS records (Headscale only)](../ref/dns.md#setting-extra-dns-records)
- [x] [Taildrop](https://tailscale.com/docs/features/taildrop)
- [x] [Taildrop (File Sharing)](https://tailscale.com/docs/features/taildrop)
- [x] [Tags](../ref/tags.md)
- [x] [Routes](../ref/routes.md)
- [x] [Subnet routers](../ref/routes.md#subnet-router)
- [x] [Exit nodes](../ref/routes.md#exit-node)
- [x] [Route filtering with Via](https://tailscale.com/docs/features/access-control/grants/grants-via)
- [x] Dual stack (IPv4 and IPv6)
- [x] Ephemeral nodes
- [x] Embedded [DERP server](../ref/derp.md)
- [x] [Peer relays](https://tailscale.com/docs/features/peer-relay)
- [x] [Policy](../ref/policy.md) ([GitHub label "policy"](https://github.com/juanfont/headscale/labels/policy%20%F0%9F%93%9D))
- [x] ACLs
- [x] Grants
- [x] Some [Autogroups](../ref/policy.md#autogroups)
- [x] Access control lists ([GitHub label "policy"](https://github.com/juanfont/headscale/labels/policy%20%F0%9F%93%9D))
- [x] ACL management via API
- [x] Some [Autogroups](https://tailscale.com/docs/reference/targets-and-selectors#autogroups), currently:
`autogroup:internet`, `autogroup:nonroot`, `autogroup:member`, `autogroup:tagged`, `autogroup:self`,
`autogroup:danger-all`
- [x] [Auto approvers](https://tailscale.com/docs/reference/syntax/policy-file#auto-approvers) for [subnet
routers](../ref/routes.md#automatically-approve-routes-of-a-subnet-router) and [exit
nodes](../ref/routes.md#automatically-approve-an-exit-node-with-auto-approvers)
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

+1 -1
View File
@@ -8,7 +8,7 @@ hide:
Headscale is an open source, self-hosted implementation of the Tailscale control server.
This page contains the documentation for the latest version of headscale. Please also check our [FAQ](about/faq.md).
This page contains the documentation for the latest version of headscale. Please also check our [FAQ](./about/faq.md).
Join our [Discord server](https://discord.gg/c84AZQhmpx) for a chat and community support.
+295
View File
@@ -0,0 +1,295 @@
Headscale implements the same policy ACLs as Tailscale.com, adapted to the self-hosted environment.
For instance, instead of referring to users when defining groups you must
use users (which are the equivalent to user/logins in Tailscale.com).
Please check [manage permissions using ACLs](https://tailscale.com/docs/features/access-control/acls) for further
information.
When using ACL's the User borders are no longer applied. All machines
whichever the User have the ability to communicate with other hosts as
long as the ACL's permits this exchange.
## ACL Setup
To enable and configure ACLs in Headscale, you need to specify the path to your ACL policy file in the `policy.path` key in `config.yaml`.
Your ACL policy file must be formatted using [huJSON](https://github.com/tailscale/hujson).
Info on how these policies are written can be found in [Tailscale's ACL
documentation](https://tailscale.com/docs/features/access-control/acls).
Please reload or restart Headscale after updating the ACL file. Headscale may be reloaded either via its systemd service
(`sudo systemctl reload headscale`) or by sending a SIGHUP signal (`sudo kill -HUP $(pidof headscale)`) to the main
process. Headscale logs the result of ACL policy processing after each reload.
## Simple Examples
- [**Allow All**](https://tailscale.com/docs/reference/examples/acls#allow-all-default-acl): If you define an ACL file but completely omit the `"acls"` field from its content, Headscale will default to an "allow all" policy. This means all devices connected to your tailnet will be able to communicate freely with each other.
```json
{}
```
- [**Deny All**](https://tailscale.com/docs/reference/examples/acls#deny-all): To prevent all communication within your tailnet, you can include an empty array for the `"acls"` field in your policy file.
```json
{
"acls": []
}
```
## Complex Example
Let's build a more complex example use case for a small business (It may be the place where
ACL's are the most useful).
We have a small company with a boss, an admin, two developers and an intern.
The boss should have access to all servers but not to the user's hosts. Admin
should also have access to all hosts except that their permissions should be
limited to maintaining the hosts (for example purposes). The developers can do
anything they want on dev hosts but only watch on productions hosts. Intern
can only interact with the development servers.
There's an additional server that acts as a router, connecting the VPN users
to an internal network `10.20.0.0/16`. Developers must have access to those
internal resources.
Each user have at least a device connected to the network and we have some
servers.
- database.prod
- database.dev
- app-server1.prod
- app-server1.dev
- billing.internal
- router.internal
![ACL implementation example](../assets/images/headscale-acl-network.png)
When [registering the servers](../usage/getting-started.md#register-a-node) we
will need to add the flag `--advertise-tags=tag:<tag1>,tag:<tag2>`, and the user
that is registering the server should be allowed to do it. Since anyone can add
tags to a server they can register, the check of the tags is done on headscale
server and only valid tags are applied. A tag is valid if the user that is
registering it is allowed to do it.
Here are the ACL's to implement the same permissions as above:
```json title="acl.json"
{
// groups are collections of users having a common scope. A user can be in multiple groups
// groups cannot be composed of groups
"groups": {
"group:boss": ["boss@"],
"group:dev": ["dev1@", "dev2@"],
"group:admin": ["admin1@"],
"group:intern": ["intern1@"]
},
// tagOwners in tailscale is an association between a TAG and the people allowed to set this TAG on a server.
// This is documented [here](https://tailscale.com/docs/features/tags)
// and explained [here](https://tailscale.com/blog/rbac-like-it-was-meant-to-be/)
"tagOwners": {
// the administrators can add servers in production
"tag:prod-databases": ["group:admin"],
"tag:prod-app-servers": ["group:admin"],
// the boss can tag any server as internal
"tag:internal": ["group:boss"],
// dev can add servers for dev purposes as well as admins
"tag:dev-databases": ["group:admin", "group:dev"],
"tag:dev-app-servers": ["group:admin", "group:dev"]
// interns cannot add servers
},
// hosts should be defined using its IP addresses and a subnet mask.
// to define a single host, use a /32 mask. You cannot use DNS entries here,
// as they're prone to be hijacked by replacing their IP addresses.
// see https://github.com/tailscale/tailscale/issues/3800 for more information.
"hosts": {
"postgresql.internal": "10.20.0.2/32",
"webservers.internal": "10.20.10.1/29"
},
"acls": [
// boss have access to all servers
{
"action": "accept",
"src": ["group:boss"],
"dst": [
"tag:prod-databases:*",
"tag:prod-app-servers:*",
"tag:internal:*",
"tag:dev-databases:*",
"tag:dev-app-servers:*"
]
},
// admin have only access to administrative ports of the servers, in tcp/22
{
"action": "accept",
"src": ["group:admin"],
"proto": "tcp",
"dst": [
"tag:prod-databases:22",
"tag:prod-app-servers:22",
"tag:internal:22",
"tag:dev-databases:22",
"tag:dev-app-servers:22"
]
},
// we also allow admin to ping the servers
{
"action": "accept",
"src": ["group:admin"],
"proto": "icmp",
"dst": [
"tag:prod-databases:*",
"tag:prod-app-servers:*",
"tag:internal:*",
"tag:dev-databases:*",
"tag:dev-app-servers:*"
]
},
// developers have access to databases servers and application servers on all ports
// they can only view the applications servers in prod and have no access to databases servers in production
{
"action": "accept",
"src": ["group:dev"],
"dst": [
"tag:dev-databases:*",
"tag:dev-app-servers:*",
"tag:prod-app-servers:80,443"
]
},
// developers have access to the internal network through the router.
// the internal network is composed of HTTPS endpoints and Postgresql
// database servers.
{
"action": "accept",
"src": ["group:dev"],
"dst": ["10.20.0.0/16:443,5432"]
},
// servers should be able to talk to database in tcp/5432. Database should not be able to initiate connections to
// applications servers
{
"action": "accept",
"src": ["tag:dev-app-servers"],
"proto": "tcp",
"dst": ["tag:dev-databases:5432"]
},
{
"action": "accept",
"src": ["tag:prod-app-servers"],
"dst": ["tag:prod-databases:5432"]
},
// interns have access to dev-app-servers only in reading mode
{
"action": "accept",
"src": ["group:intern"],
"dst": ["tag:dev-app-servers:80,443"]
},
// Allow users to access their own devices using autogroup:self (see below for more details about performance impact)
{
"action": "accept",
"src": ["autogroup:member"],
"dst": ["autogroup:self:*"]
}
]
}
```
## Autogroups
Headscale supports several autogroups that automatically include users, destinations, or devices with specific properties. Autogroups provide a convenient way to write ACL rules without manually listing individual users or devices.
### `autogroup:internet`
Allows access to the internet through [exit nodes](routes.md#exit-node). Can only be used in ACL destinations.
```json
{
"action": "accept",
"src": ["group:users"],
"dst": ["autogroup:internet:*"]
}
```
### `autogroup:member`
Includes all [personal (untagged) devices](registration.md/#identity-model).
```json
{
"action": "accept",
"src": ["autogroup:member"],
"dst": ["tag:prod-app-servers:80,443"]
}
```
### `autogroup:tagged`
Includes all devices that [have at least one tag](registration.md/#identity-model).
```json
{
"action": "accept",
"src": ["autogroup:tagged"],
"dst": ["tag:monitoring:9090"]
}
```
### `autogroup:self`
!!! warning "The current implementation of `autogroup:self` is inefficient"
Includes devices where the same user is authenticated on both the source and destination. Does not include tagged devices. Can only be used in ACL destinations.
```json
{
"action": "accept",
"src": ["autogroup:member"],
"dst": ["autogroup:self:*"]
}
```
*Using `autogroup:self` may cause performance degradation on the Headscale coordinator server in large deployments, as filter rules must be compiled per-node rather than globally and the current implementation is not very efficient.*
If you experience performance issues, consider using more specific ACL rules or limiting the use of `autogroup:self`.
```json
{
// The following rules allow internal users to communicate with their
// own nodes in case autogroup:self is causing performance issues.
{ "action": "accept", "src": ["boss@"], "dst": ["boss@:*"] },
{ "action": "accept", "src": ["dev1@"], "dst": ["dev1@:*"] },
{ "action": "accept", "src": ["dev2@"], "dst": ["dev2@:*"] },
{ "action": "accept", "src": ["admin1@"], "dst": ["admin1@:*"] },
{ "action": "accept", "src": ["intern1@"], "dst": ["intern1@:*"] }
}
```
### `autogroup:nonroot`
Used in Tailscale SSH rules to allow access to any user except root. Can only be used in the `users` field of SSH rules.
```json
{
"action": "accept",
"src": ["autogroup:member"],
"dst": ["autogroup:self"],
"users": ["autogroup:nonroot"]
}
```
### `autogroup:danger-all`
This autogroup resolves to all IP addresses (`0.0.0.0/0` and `::/0`) which also includes all IP addresses outside the
standard Tailscale IP ranges. [This autogroup can only be used as
source](https://tailscale.com/docs/reference/targets-and-selectors#autogroupdanger-all).
+3 -3
View File
@@ -53,19 +53,19 @@ Headscale provides a metrics and debug endpoint. It allows to introspect differe
- Information about the Go runtime, memory usage and statistics
- Connected nodes and pending registrations
- Active policy, filters and SSH policy
- Active ACLs, filters and SSH policy
- Current DERPMap
- Prometheus metrics
!!! warning "Keep the metrics and debug endpoint private"
The listen address and port can be configured with the `metrics_listen_addr` variable in the [configuration
file](configuration.md). By default it listens on localhost, port 9090.
file](./configuration.md). By default it listens on localhost, port 9090.
Keep the metrics and debug endpoint private to your internal network and don't expose it to the Internet.
The metrics and debug interface can be disabled completely by setting `metrics_listen_addr: null` in the
[configuration file](configuration.md).
[configuration file](./configuration.md).
Query metrics via <http://localhost:9090/metrics> and get an overview of available debug information via
<http://localhost:9090/debug/>. Metrics may be queried from outside localhost but the debug interface is subject to
+4 -4
View File
@@ -6,8 +6,8 @@ DERP server to ensure seamless connectivity between nodes.
## Configuration
DERP related settings are configured within the `derp` section of the [configuration file](configuration.md). The
following sections only use a few of the available settings, check the [example configuration](configuration.md) for
DERP related settings are configured within the `derp` section of the [configuration file](./configuration.md). The
following sections only use a few of the available settings, check the [example configuration](./configuration.md) for
all available configuration options.
### Enable embedded DERP
@@ -163,7 +163,7 @@ Any Tailscale client may be used to introspect the DERP map and to check for con
- Check connectivity with the embedded DERP[^1]:`tailscale debug derp headscale`
Additional DERP related metrics and information is available via the [metrics and debug
endpoint](debug.md#metrics-and-debug-endpoint).
endpoint](./debug.md#metrics-and-debug-endpoint).
## Limitations
@@ -171,4 +171,4 @@ endpoint](debug.md#metrics-and-debug-endpoint).
endpoint via HTTP on port tcp/80.
- There are no speed or throughput optimisations, the main purpose is to assist in node connectivity.
[^1]: This assumes that the default region code of the [configuration file](configuration.md) is used.
[^1]: This assumes that the default region code of the [configuration file](./configuration.md) is used.
+5 -5
View File
@@ -1,19 +1,19 @@
# DNS
Headscale supports [most DNS features](../about/features.md) from Tailscale. DNS related settings can be configured
within the `dns` section of the [configuration file](configuration.md).
within the `dns` section of the [configuration file](./configuration.md).
## Setting extra DNS records
Headscale allows to set extra DNS records which are made available via
[MagicDNS](https://tailscale.com/docs/features/magicdns). Extra DNS records can be configured either via static entries
in the [configuration file](configuration.md) or from a JSON file that Headscale continuously watches for changes:
in the [configuration file](./configuration.md) or from a JSON file that Headscale continuously watches for changes:
- Use the `dns.extra_records` option in the [configuration file](configuration.md) for entries that are static and
- Use the `dns.extra_records` option in the [configuration file](./configuration.md) for entries that are static and
don't change while Headscale is running. Those entries are processed when Headscale is starting up and changes to the
configuration require a restart of Headscale.
- For dynamic DNS records that may be added, updated or removed while Headscale is running or DNS records that are
generated by scripts the option `dns.extra_records_path` in the [configuration file](configuration.md) is useful.
generated by scripts the option `dns.extra_records_path` in the [configuration file](./configuration.md) is useful.
Set it to the absolute path of the JSON file containing DNS records and Headscale processes this file as it detects
changes.
@@ -66,7 +66,7 @@ hostname and port combination "http://hostname-in-magic-dns.myvpn.example.com:30
!!! tip "Good to know"
- The `dns.extra_records_path` option in the [configuration file](configuration.md) needs to reference the
- The `dns.extra_records_path` option in the [configuration file](./configuration.md) needs to reference the
JSON file containing extra DNS records.
- Be sure to "sort keys" and produce a stable output in case you generate the JSON file with a script.
Headscale uses a checksum to detect changes to the file and a stable output avoids unnecessary processing.
+1 -1
View File
@@ -19,7 +19,7 @@ Headscale doesn't provide a built-in web interface but users may pick one from t
it offers Local (`docker exec`) and API Mode
- [headscale-console](https://github.com/rickli-cloud/headscale-console) - WebAssembly-based client supporting SSH, VNC
and RDP with optional self-service capabilities
- [headscale-piying](https://github.com/wszgrcy/headscale-piying) - headscale web ui, support visual ACL configuration
- [headscale-piying](https://github.com/wszgrcy/headscale-piying) - headscale web ui,support visual ACL configuration
- [HeadControl](https://github.com/ahmadzip/HeadControl) - Minimal Headscale admin dashboard, built with Go and HTMX
- [Headscale Manager](https://github.com/hkdone/headscalemanager) - Headscale UI for Android
- [Headscale UI](https://github.com/MunMunMiao/headscale-ui) - Headscale UI online and Self-hosting
+7 -7
View File
@@ -214,14 +214,14 @@ You may refer to users in the Headscale policy via:
{
"groups": {
"group:alice": [
"https://sso.example.com/oauth2/openid/59ac9125-c31b-46c5-814e-06242908cf57@"
"https://soo.example.com/oauth2/openid/59ac9125-c31b-46c5-814e-06242908cf57@"
]
},
"grants": [
"acls": [
{
"action": "accept",
"src": ["group:alice"],
"dst": ["*"],
"ip": ["*"]
"dst": ["*:*"]
}
]
}
@@ -246,7 +246,7 @@ endpoint.
- Support for OpenID Connect aims to be generic and vendor independent. It offers only limited support for quirks of
specific identity providers.
- OIDC groups cannot be used in policy rules.
- OIDC groups cannot be used in ACLs.
- The username provided by the identity provider needs to adhere to this pattern:
- The username must be at least two characters long.
- It must only contain letters, digits, hyphens, dots, underscores, and up to a single `@`.
@@ -283,9 +283,9 @@ Authelia is fully supported by Headscale.
### Google OAuth
!!! warning "No username due to missing preferred_username claim"
!!! warning "No username due to missing preferred_username"
Google OAuth does not send the `preferred_username` claim when the `profile` scope is requested. The username in
Google OAuth does not send the `preferred_username` claim when the scope `profile` is requested. The username in
Headscale will be blank/not set.
In order to integrate Headscale with Google, you'll need to have a [Google Cloud
-200
View File
@@ -1,200 +0,0 @@
# Policy
Headscale implements a large portion of Tailscale's [policy
features](https://tailscale.com/docs/features/tailnet-policy-file), most notably access control based on
[ACLs](https://tailscale.com/docs/features/access-control/acls) and
[Grants](https://tailscale.com/docs/features/access-control/grants) or [Tailscale
SSH](https://tailscale.com/docs/features/tailscale-ssh). See [limitations](#limitations) to learn about missing features
and notable implementation differences between Headscale and Tailscale.
Headscale uses the same [huJSON](https://github.com/tailscale/hujson) based file format as Tailscale. By default, no
policy is loaded which means that Headscale allows all traffic between nodes. To start using a policy file[^1], specify
its path in the `policy.path` key in the [configuration file](configuration.md).
Headscale needs to be reloaded to pick up changes to the policy file. Either reload Headscale via its systemd service
(`sudo systemctl reload headscale`) or by sending a SIGHUP signal (`sudo kill -HUP $(pidof headscale)`) to the main
process. Headscale logs the result of policy processing after each reload.
Please have a look at Tailscale's policy related documentation to learn more:
- [Tailscale policy file](https://tailscale.com/docs/features/tailnet-policy-file): A description of supported sections
within the policy file along with links to syntax references for each section.
- [ACLs](https://tailscale.com/docs/features/access-control/acls): How to configure access control using ACLs.
- [Grants](https://tailscale.com/docs/features/access-control/grants): Introduction to Grants with links to [syntax
reference](https://tailscale.com/docs/reference/syntax/grants),
[examples](https://tailscale.com/docs/reference/examples/grants) and a [migration guide from ACLs to
Grants](https://tailscale.com/docs/reference/migrate-acls-grants).
## Getting started
Headscale supports both [ACLs](https://tailscale.com/docs/features/access-control/acls) and
[Grants](https://tailscale.com/docs/features/access-control/grants) to write an access control policy. We recommend the
use of Grants since ACLs are considered legacy and will not receive new features by Tailscale.
### Allow All
If you define a policy file but completely omit the `"acls"` or `"grants"` section, Headscale will default to an [allow
all](https://tailscale.com/docs/reference/examples/acls#allow-all-default-acl) policy. This means all devices connected
to your tailnet will be able to communicate freely with each other.
```json title="policy.json"
{}
```
### Deny All
To [prevent all communication within your tailnet](https://tailscale.com/docs/reference/examples/acls#deny-all), you can
include an empty array for the `"grants"` section in your policy file.
```json title="policy.json"
{
"grants": []
}
```
### More examples
- See our documentation on [subnet routers](routes.md#subnet-router) and [exit nodes](routes.md#exit-node) to learn how
to restrict their use or how to automatically approve them.
- The Tailscale documentation provides a large collection of configuration examples:
- [ACL examples](https://tailscale.com/docs/reference/examples/acls)
- [Grants examples](https://tailscale.com/docs/reference/examples/grants)
- [SSH configuration](https://tailscale.com/docs/features/tailscale-ssh#configure-tailscale-ssh)
- [Define a tag](https://tailscale.com/docs/features/tags#define-a-tag)
______________________________________________________________________
## Limitations
- [Device postures](https://tailscale.com/docs/features/device-posture) and the related sections such as `postures` or
`srcPosture` aren't supported.
- [IP sets](https://tailscale.com/docs/features/tailnet-policy-file/ip-sets) aren't supported.
- A subset of [Autogroups](#autogroups) are available.
## Autogroups
Headscale supports several [Autogroups](https://tailscale.com/docs/reference/targets-and-selectors#autogroups) that
automatically include users, destinations, or devices with specific properties. Autogroups provide a convenient way to
write policy rules without manually listing individual users or devices.
### [`autogroup:internet`](https://tailscale.com/docs/reference/targets-and-selectors#autogroupinternet)
Allows access to the internet through [exit nodes](routes.md#exit-node). Can only be used in policy destinations.
```json title="policy.json"
{
"grants": [
{
"src": ["alice@"],
"dst": ["autogroup:internet"],
"ip": ["*"]
}
]
}
```
### [`autogroup:member`](https://tailscale.com/docs/reference/targets-and-selectors#autogrouprole)
Includes all [personal (untagged) devices](registration.md/#identity-model).
```json title="policy.json"
{
"grants": [
{
"src": ["autogroup:member"],
"dst": ["tag:prod-app-servers"],
"ip": ["80,443"]
}
]
}
```
### [`autogroup:tagged`](https://tailscale.com/docs/reference/targets-and-selectors#autogrouptagged)
Includes all devices that [have at least one tag](registration.md/#identity-model).
```json title="policy.json"
{
"grants": [
{
"src": ["autogroup:tagged"],
"dst": ["tag:monitoring"],
"ip": ["9090"]
}
]
}
```
### [`autogroup:self`](https://tailscale.com/docs/reference/targets-and-selectors#autogroupself)
Includes devices where the same user is authenticated on both the source and destination. Does not include tagged
devices. Can only be used in policy destinations.
```json title="policy.json"
{
"grants": [
{
"src": ["autogroup:member"],
"dst": ["autogroup:self"],
"ip": ["*"]
}
]
}
```
!!! warning "The current implementation of `autogroup:self` is inefficient"
Using `autogroup:self` may cause performance degradation on the Headscale coordinator server in large deployments,
as filter rules must be compiled per-node rather than globally and the current implementation is not very efficient.
If you experience performance issues, consider using more specific policy rules or limiting the use of
`autogroup:self`.
```json title="policy.json"
{
"grants": [
// The following rules allow internal users to communicate with their
// own nodes in case autogroup:self is causing performance issues.
{
"src": ["boss@"],
"dst": ["boss@"],
"ip": "*"
},
{
"src": ["dev1@"],
"dst": ["dev1@"],
"ip": "*"
},
{
"src": ["intern1@"],
"dst": ["intern1@"],
"ip": "*"
}
]
}
```
### [`autogroup:nonroot`](https://tailscale.com/docs/reference/targets-and-selectors#other-built-in-targets)
Used in Tailscale SSH rules to allow access to any user except root. Can only be used in the `users` field of SSH rules.
```json title="policy.json"
{
"ssh": [
{
"action": "accept",
"src": ["autogroup:member"],
"dst": ["autogroup:self"],
"users": ["autogroup:nonroot"]
}
]
}
```
### [`autogroup:danger-all`](https://tailscale.com/docs/reference/targets-and-selectors#autogroupdanger-all)
This autogroup resolves to all IP addresses (`0.0.0.0/0` and `::/0`) which also includes all IP addresses outside the
standard Tailscale IP ranges. This autogroup can only be used as source.
[^1]: Headscale also allows to store the policy in the database. This is typically only required in case a [web
interface](integration/web-ui.md) is used.
+2 -2
View File
@@ -61,8 +61,8 @@ headscale users create <USER>
=== "Tagged devices"
Your Headscale user needs to be authorized to register tagged devices. This authorization is specified in the
[`tagOwners`](https://tailscale.com/docs/reference/syntax/policy-file#tag-owners) section of the
[policy](policy.md). A simple example looks like this:
[`tagOwners`](https://tailscale.com/docs/reference/syntax/policy-file#tag-owners) section of the [ACL](acls.md). A
simple example looks like this:
```json title="The user alice can register nodes tagged with tag:server"
{
+42 -38
View File
@@ -76,29 +76,29 @@ Please refer to the official [Tailscale
documentation](https://tailscale.com/docs/features/subnet-routers#use-your-subnet-routes-from-other-devices) for how to
use a subnet router on different operating systems.
### Restrict the use of a subnet router with a policy
### Restrict the use of a subnet router with ACL
The routes announced by subnet routers are available to the nodes in a tailnet. By default, without a policy enabled,
all nodes can accept and use such routes. Configure a policy to explicitly manage who can use routes.
The routes announced by subnet routers are available to the nodes in a tailnet. By default, without an ACL enabled, all
nodes can accept and use such routes. Configure an ACL to explicitly manage who can use routes.
The policy snippet below defines three hosts, a subnet router `router`, a regular node `node` and `service.example.net`
as internal service that can be reached via a route on the subnet router `router`. It allows the node `node` to access
The ACL snippet below defines three hosts, a subnet router `router`, a regular node `node` and `service.example.net` as
internal service that can be reached via a route on the subnet router `router`. It allows the node `node` to access
`service.example.net` on port 80 and 443 which is reachable via the subnet router. Access to the subnet router itself is
denied.
```json title="Access the routes of a subnet router without the subnet router itself"
{
"hosts": {
// the router is not referenced but announces 192.168.0.0/24
// the router is not referenced but announces 192.168.0.0/24"
"router": "100.64.0.1/32",
"node": "100.64.0.2/32",
"service.example.net": "192.168.0.1/32"
},
"grants": [
"acls": [
{
"action": "accept",
"src": ["node"],
"dst": ["service.example.net"],
"ip": ["80,443"]
"dst": ["service.example.net:80,443"]
}
]
}
@@ -107,10 +107,10 @@ denied.
### Automatically approve routes of a subnet router
The initial setup of a subnet router usually requires manual approval of their announced routes on the control server
before they can be used by a node in a tailnet. Headscale supports the `autoApprovers` section in a policy to automate
the approval of routes served with a subnet router.
before they can be used by a node in a tailnet. Headscale supports the `autoApprovers` section of an ACL to automate the
approval of routes served with a subnet router.
The policy snippet below defines the tag `tag:router` owned by the user `alice`. This tag is used for `routes` in the
The ACL snippet below defines the tag `tag:router` owned by the user `alice`. This tag is used for `routes` in the
`autoApprovers` section. The IPv4 route `192.168.0.0/24` is automatically approved once announced by a subnet router
that advertises the tag `tag:router`.
@@ -124,7 +124,7 @@ that advertises the tag `tag:router`.
"192.168.0.0/24": ["tag:router"]
}
},
"grants": [
"acls": [
// more rules
]
}
@@ -204,19 +204,19 @@ $ sudo tailscale set --exit-node myexit
Please refer to the official [Tailscale documentation](https://tailscale.com/docs/features/exit-nodes#use-the-exit-node)
for how to use an exit node on different operating systems.
### Restrict the use of an exit node with a policy
### Restrict the use of an exit node with ACL
An exit node is offered to all nodes in a tailnet. By default, without a policy enabled, all nodes in a tailnet can
select and use an exit node. Configure `autogroup:internet` in a policy rule to restrict who can use _any_ of the
available exit nodes.
An exit node is offered to all nodes in a tailnet. By default, without an ACL enabled, all nodes in a tailnet can select
and use an exit node. Configure `autogroup:internet` in an ACL rule to restrict who can use _any_ of the available exit
nodes.
```json title="Example use of autogroup:internet"
{
"grants": [
"acls": [
{
"action": "accept",
"src": ["..."],
"dst": ["autogroup:internet"],
"ip": ["*"]
"dst": ["autogroup:internet:*"]
}
]
}
@@ -224,41 +224,45 @@ available exit nodes.
### Restrict access to exit nodes per user or group
A user can use _any_ of the available exit nodes with `autogroup:internet`. Alternatively, the policy snippet below
assigns each user a specific exit node while hiding all other exit nodes. The user `alice` can only use an exit node
tagged with `tag:exit1` while user `bob` can only use an exit node tagged with `tag:exit2`.
A user can use _any_ of the available exit nodes with `autogroup:internet`. Alternatively, the ACL snippet below assigns
each user a specific exit node while hiding all other exit nodes. The user `alice` can only use exit node `exit1` while
user `bob` can only use exit node `exit2`.
```json title="Assign each user a dedicated exit node"
{
"tagOwners": {
"tag:exit1": ["alice@"],
"tag:exit2": ["bob@"]
"hosts": {
"exit1": "100.64.0.1/32",
"exit2": "100.64.0.2/32"
},
"grants": [
"acls": [
{
"action": "accept",
"src": ["alice@"],
"dst": ["autogroup:internet"],
"via": ["tag:exit1"],
"ip": ["*"]
"dst": ["exit1:*"]
},
{
"action": "accept",
"src": ["bob@"],
"dst": ["autogroup:internet"],
"via": ["tag:exit2"],
"ip": ["*"]
"dst": ["exit2:*"]
}
]
}
```
!!! warning
- The above implementation is Headscale specific and will likely be removed once [support for
`via`](https://github.com/juanfont/headscale/issues/2409) is available.
- Beware that a user can also connect to any port of the exit node itself.
### Automatically approve an exit node with auto approvers
The initial setup of an exit node usually requires manual approval on the control server before it can be used by a node
in a tailnet. Headscale supports the `autoApprovers` section in a policy to automate the approval of a new exit node as
in a tailnet. Headscale supports the `autoApprovers` section of an ACL to automate the approval of a new exit node as
soon as it joins the tailnet.
The policy snippet below defines the tag `tag:exit` owned by the user `alice`. This tag is used for the `exitNode` entry
in the `autoApprovers` section. A new exit node that advertises the tag `tag:exit` is automatically approved:
The ACL snippet below defines the tag `tag:exit` owned by the user `alice`. This tag is used for `exitNode` in the
`autoApprovers` section. A new exit node that advertises the tag `tag:exit` is automatically approved:
```json title="Exit nodes tagged with tag:exit are automatically approved"
{
@@ -268,7 +272,7 @@ in the `autoApprovers` section. A new exit node that advertises the tag `tag:exi
"autoApprovers": {
"exitNode": ["tag:exit"]
},
"grants": [
"acls": [
// more rules
]
}
@@ -291,7 +295,7 @@ to clients. Please see the official [Tailscale documentation on high
availability](https://tailscale.com/docs/how-to/set-up-high-availability#subnet-router-high-availability) for details.
This feature is enabled by default when at least two nodes advertise the same prefix. See the configuration options
`node.routes.ha` in the [configuration file](configuration.md) for details.
`node.routes.ha` in the [configuration file](./configuration.md) for details.
## Troubleshooting
+6 -3
View File
@@ -1,7 +1,7 @@
# Community packages
Several Linux distributions and community members provide packages for headscale. Those packages may be used instead of
the [official releases](official.md) provided by the headscale maintainers. Such packages offer improved integration
the [official releases](./official.md) provided by the headscale maintainers. Such packages offer improved integration
for their targeted operating system and usually:
- setup a dedicated local user account to run headscale
@@ -10,8 +10,8 @@ for their targeted operating system and usually:
!!! warning "Community packages might be outdated"
The packages mentioned on this page might be outdated or unmaintained. Use the [official releases](official.md) to
get the current stable version or to [test pre-releases](main.md).
The packages mentioned on this page might be outdated or unmaintained. Use the [official releases](./official.md) to
get the current stable version or to test pre-releases.
[![Packaging status](https://repology.org/badge/vertical-allrepos/headscale.svg)](https://repology.org/project/headscale/versions)
@@ -23,6 +23,9 @@ Arch Linux offers a package for headscale, install via:
pacman -S headscale
```
The [AUR package `headscale-git`](https://aur.archlinux.org/packages/headscale-git) can be used to build the current
development version.
## Fedora, RHEL, CentOS
A third-party repository for various RPM based distributions is available at:
+5 -4
View File
@@ -7,8 +7,9 @@
**It might be outdated and it might miss necessary steps**.
A container runtime such as [Docker](https://www.docker.com) or [Podman](https://podman.io) is required. The container
image can be found on [Docker Hub](https://hub.docker.com/r/headscale/headscale) and [GitHub Container
This documentation has the goal of showing a user how-to set up and run headscale in a container. A container runtime
such as [Docker](https://www.docker.com) or [Podman](https://podman.io) is required. The container image can be found on
[Docker Hub](https://hub.docker.com/r/headscale/headscale) and [GitHub Container
Registry](https://github.com/juanfont/headscale/pkgs/container/headscale). The container image URLs are:
- [Docker Hub](https://hub.docker.com/r/headscale/headscale): `docker.io/headscale/headscale:<VERSION>`
@@ -17,7 +18,7 @@ Registry](https://github.com/juanfont/headscale/pkgs/container/headscale). The c
## Configure and run headscale
1. Create a directory on the container host to store headscale's [configuration](../../ref/configuration.md) and the SQLite database:
1. Create a directory on the container host to store headscale's [configuration](../../ref/configuration.md) and the [SQLite](https://www.sqlite.org/) database:
```shell
mkdir -p ./headscale/{config,lib}
@@ -97,7 +98,7 @@ Continue on the [getting started page](../../usage/getting-started.md) to regist
## Debugging headscale running in Docker
The Headscale container image is based on a distroless image that does not contain a shell or any other debug tools. If you need to debug headscale running in the Docker container, you can use the `-debug` variant, for example `docker.io/headscale/headscale:x.x.x-debug`.
The Headscale container image is based on a "distroless" image that does not contain a shell or any other debug tools. If you need to debug headscale running in the Docker container, you can use the `-debug` variant, for example `docker.io/headscale/headscale:x.x.x-debug`.
### Running the debug Docker container
+2 -2
View File
@@ -39,7 +39,7 @@ docker run \
serve
```
See [Running headscale in a container](container.md) for full container setup instructions.
See [Running headscale in a container](./container.md) for full container setup instructions.
## Binaries
@@ -54,5 +54,5 @@ via [nightly.link](https://nightly.link/juanfont/headscale/workflows/container-m
| macOS | arm64 | [headscale-darwin-arm64](https://nightly.link/juanfont/headscale/workflows/container-main/main/headscale-darwin-arm64.zip) |
After downloading and extracting the archive, make the binary executable and follow the
[standalone binary installation](official.md#using-standalone-binaries-advanced)
[standalone binary installation](./official.md#using-standalone-binaries-advanced)
instructions for setting up the service.
+1 -1
View File
@@ -51,7 +51,7 @@ Continue on the [getting started page](../../usage/getting-started.md) to regist
This installation method is considered advanced as one needs to take care of the local user and the systemd
service themselves. If possible, use the [DEB packages](#using-packages-for-debianubuntu-recommended) or a
[community package](community.md) instead.
[community package](./community.md) instead.
This section describes the installation of headscale according to the [Requirements and
assumptions](../requirements.md#assumptions). Headscale is run by a dedicated local user and the service itself is
+1 -1
View File
@@ -40,7 +40,7 @@ The headscale documentation and the provided examples are written with a few ass
- Headscale is running as system service via a dedicated local user `headscale`.
- The [configuration](../ref/configuration.md) is loaded from `/etc/headscale/config.yaml`.
- SQLite is used as database.
- The data directory for headscale (used for private keys, policy, SQLite database, …) is located in `/var/lib/headscale`.
- The data directory for headscale (used for private keys, ACLs, SQLite database, …) is located in `/var/lib/headscale`.
- URLs and values that need to be replaced by the user are either denoted as `<VALUE_TO_CHANGE>` or use placeholder
values such as `headscale.example.com`.
Generated
+3 -3
View File
@@ -20,11 +20,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1777270315,
"narHash": "sha256-yKB4G6cKsQsWN7M6rZGk6gkJPDNPIzT05y4qzRyCDlI=",
"lastModified": 1775701739,
"narHash": "sha256-2FWWY1rr/+pGUJK1npcVcsWNEblzmKs6VxD3VEvwJSs=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "6368eda62c9775c38ef7f714b2555a741c20c72d",
"rev": "0f7663154ff2fec150f9dbf5f81ec2785dc1e0db",
"type": "github"
},
"original": {
+10 -4
View File
@@ -27,7 +27,7 @@
let
pkgs = nixpkgs.legacyPackages.${prev.stdenv.hostPlatform.system};
buildGo = pkgs.buildGo126Module;
vendorHash = (builtins.fromJSON (builtins.readFile ./flakehashes.json)).vendor.sri;
vendorHash = "sha256-8vTEkPEMbJ6DSOjcoQrYRyKSYI8jjcllTmJ6RXmUV9w=";
in
{
headscale = buildGo {
@@ -38,8 +38,8 @@
# Only run unit tests when testing a build
checkFlags = [ "-short" ];
# vendorHash is read from flakehashes.json; refresh via:
# go run ./cmd/vendorhash update
# When updating go.mod or go.sum, a new sha will need to be calculated,
# update this if you have a mismatch after doing a change to those files.
inherit vendorHash;
subPackages = [ "cmd/headscale" ];
@@ -223,7 +223,13 @@
"nix-vendor-sri"
''
set -eu
exec go run ./cmd/vendorhash update "$@"
OUT=$(mktemp -d -t nar-hash-XXXXXX)
rm -rf "$OUT"
go mod vendor -o "$OUT"
go run tailscale.com/cmd/nardump --sri "$OUT"
rm -rf "$OUT"
'')
(pkgs.writeShellScriptBin
-6
View File
@@ -1,6 +0,0 @@
{
"vendor": {
"goModSum": "sha256-IE0n9cSqO4XNX4RN+CGBk9VC46iACiZKDFf/215iivk=",
"sri": "sha256-ijEIP9NSomhlWOgsVN7tPvSuvkTiLtnvXvhZmatIDLM="
}
}
+11 -11
View File
@@ -1,6 +1,6 @@
module github.com/juanfont/headscale
go 1.26.2
go 1.26.1
require (
github.com/arl/statsviz v0.8.0
@@ -43,9 +43,9 @@ require (
github.com/tailscale/tailsql v0.0.0-20260322172246-3ab0c1744d9c
github.com/tcnksm/go-latest v0.0.0-20170313132115-e3007ae9052e
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba
golang.org/x/crypto v0.50.0
golang.org/x/crypto v0.49.0
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90
golang.org/x/net v0.53.0
golang.org/x/net v0.52.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sync v0.20.0
google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d
@@ -55,7 +55,7 @@ require (
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
pgregory.net/rapid v1.2.0
tailscale.com v1.97.0-pre.0.20260429005429-40088602c960
tailscale.com v1.96.5
zombiezen.com/go/postgrestest v1.0.1
)
@@ -134,7 +134,7 @@ require (
github.com/dblohm7/wingoes v0.0.0-20250822163801-6d8e6105c62d // indirect
github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/cli v29.4.0+incompatible // indirect
github.com/docker/cli v29.2.1+incompatible // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
@@ -176,7 +176,7 @@ require (
github.com/jsimonetti/rtnetlink v1.4.2 // indirect
github.com/kamstrup/intmap v0.5.2 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/compress v1.18.3 // indirect
github.com/lib/pq v1.11.1 // indirect
github.com/lithammer/fuzzysearch v1.1.8 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
@@ -186,8 +186,8 @@ require (
github.com/mdlayher/socket v0.5.1 // indirect
github.com/mitchellh/go-ps v1.0.0 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/moby/api v1.54.1 // indirect
github.com/moby/moby/client v0.4.0 // indirect
github.com/moby/moby/api v1.53.0 // indirect
github.com/moby/moby/client v0.2.2 // indirect
github.com/moby/sys/atomicwriter v0.1.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/term v0.5.2 // indirect
@@ -215,12 +215,12 @@ require (
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d // indirect
github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect
github.com/tailscale/setec v0.0.0-20260115174028-19d190c5556d // indirect
github.com/tailscale/web-client-prebuilt v0.0.0-20251127225136-f19339b67368 // indirect
github.com/tailscale/wireguard-go v0.0.0-20260304043104-4184faf59e56 // indirect
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da // indirect
github.com/toqueteos/webbrowser v1.2.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
@@ -242,7 +242,7 @@ require (
golang.org/x/term v0.42.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.44.0 // indirect
golang.org/x/tools v0.43.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
+20 -22
View File
@@ -155,8 +155,8 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
github.com/docker/cli v29.4.0+incompatible h1:+IjXULMetlvWJiuSI0Nbor36lcJ5BTcVpUmB21KBoVM=
github.com/docker/cli v29.4.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/cli v29.2.1+incompatible h1:n3Jt0QVCN65eiVBoUTZQM9mcQICCJt3akW4pKAbKdJg=
github.com/docker/cli v29.2.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
@@ -303,8 +303,8 @@ github.com/kamstrup/intmap v0.5.2 h1:qnwBm1mh4XAnW9W9Ue9tZtTff8pS6+s6iKF6JRIV2Dk
github.com/kamstrup/intmap v0.5.2/go.mod h1:gWUVWHKzWj8xpJVFf5GC0O26bWmv3GqdnIX/LMT6Aq4=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
@@ -351,10 +351,10 @@ github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc
github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4=
github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw=
github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g=
github.com/moby/moby/api v1.53.0 h1:PihqG1ncw4W+8mZs69jlwGXdaYBeb5brF6BL7mPIS/w=
github.com/moby/moby/api v1.53.0/go.mod h1:8mb+ReTlisw4pS6BRzCMts5M49W5M7bKt1cJy/YbAqc=
github.com/moby/moby/client v0.2.2 h1:Pt4hRMCAIlyjL3cr8M5TrXCwKzguebPAc2do2ur7dEM=
github.com/moby/moby/client v0.2.2/go.mod h1:2EkIPVNCqR05CMIzL1mfA07t0HvVUUOl85pasRz/GmQ=
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
@@ -473,10 +473,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d h1:JcGKBZAL7ePLwOhUdN8qGQZlP5GueEiIZwY7R62pejE=
github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4=
github.com/tailscale/gliderssh v0.3.4-0.20260330083525-c1389c70ff89 h1:glgVc1ZYMjwN1Q/ITWeuSQyl029uayagaR2sjsifehc=
github.com/tailscale/gliderssh v0.3.4-0.20260330083525-c1389c70ff89/go.mod h1:wn16Km1EZOX4UEAyaZa3dBwfFGOJ7neck40NcwosJUw=
github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e h1:PtWT87weP5LWHEY//SWsYkSO3RWRZo4OSWagh3YD2vQ=
github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4=
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4=
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg=
github.com/tailscale/golang-x-crypto v0.0.0-20250404221719-a5573b049869 h1:SRL6irQkKGQKKLzvQP/ke/2ZuB7Py5+XuqtOgSj+iMM=
@@ -497,8 +495,8 @@ github.com/tailscale/web-client-prebuilt v0.0.0-20251127225136-f19339b67368 h1:0
github.com/tailscale/web-client-prebuilt v0.0.0-20251127225136-f19339b67368/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ=
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M=
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y=
github.com/tailscale/wireguard-go v0.0.0-20260304043104-4184faf59e56 h1:/R1vu+eNhg1eKstmVPEKvsJgkh4TUyb+J+Eadwv+d/I=
github.com/tailscale/wireguard-go v0.0.0-20260304043104-4184faf59e56/go.mod h1:zvaAPQrjUBWufXgqpSQ1/BYu9ZFOKnsNWLFQe+E78cM=
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da h1:jVRUZPRs9sqyKlYHHzHjAqKN+6e/Vog6NpHYeNPJqOw=
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek=
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg=
github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA=
@@ -561,8 +559,8 @@ go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/W
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f h1:phY1HzDcf18Aq9A8KkmRtY9WvOFIxN8wgfvy6Zm1DV8=
@@ -578,8 +576,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -630,8 +628,8 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
@@ -708,7 +706,7 @@ sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k=
software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
tailscale.com v1.97.0-pre.0.20260429005429-40088602c960 h1:I56vAGia4DV24Dbv8N07F/Awtnguvmm7PAgWCxCIdqw=
tailscale.com v1.97.0-pre.0.20260429005429-40088602c960/go.mod h1:8nwFkmNdNRtTIM2dkmr/DhbzSKeLmzusWOTacX1zVKk=
tailscale.com v1.96.5 h1:gNkfA/KSZAl6jCH9cj8urq00HRWItDDTtGsyATI89jA=
tailscale.com v1.96.5/go.mod h1:/3lnZBYb2UEwnN0MNu2SDXUtT06AGd5k0s+OWx3WmcY=
zombiezen.com/go/postgrestest v1.0.1 h1:aXoADQAJmZDU3+xilYVut0pHhgc0sF8ZspPW9gFNwP4=
zombiezen.com/go/postgrestest v1.0.1/go.mod h1:marlZezr+k2oSJrvXHnZUs1olHqpE9czlz8ZYkVxliQ=
-3
View File
@@ -239,10 +239,7 @@ func (m *mapper) policyChangeResponse(
// Send remaining peers in PeersChanged - their AllowedIPs may have
// changed due to the policy update (e.g., different routes allowed).
// Cross-user peers must also carry their user profile, otherwise the
// client's netmap shows the peer without a UserProfiles[user] entry.
if currentPeers.Len() > 0 {
builder.WithUserProfiles(currentPeers)
builder.WithPeerChanges(currentPeers)
}
+4 -31
View File
@@ -44,40 +44,13 @@ func MatchesFromFilterRules(rules []tailcfg.FilterRule) []Match {
return matches
}
// MatchFromFilterRule derives a Match from a tailcfg.FilterRule. The
// destination IP set is the union of DstPorts[].IP and CapGrant[].Dsts:
// cap-grant-only rules (e.g. tailscale.com/cap/relay) carry their
// destinations in CapGrant.Dsts and would otherwise contribute nothing
// to peer-visibility derivation in BuildPeerMap / ReduceNodes, hiding
// the cap target from the source unless a companion IP-level rule
// also exists.
func MatchFromFilterRule(rule tailcfg.FilterRule) Match {
srcs := new(netipx.IPSetBuilder)
dests := new(netipx.IPSetBuilder)
for _, srcIP := range rule.SrcIPs {
set, _ := util.ParseIPSet(srcIP, nil)
srcs.AddSet(set)
dests := make([]string, 0, len(rule.DstPorts))
for _, dest := range rule.DstPorts {
dests = append(dests, dest.IP)
}
for _, dp := range rule.DstPorts {
set, _ := util.ParseIPSet(dp.IP, nil)
dests.AddSet(set)
}
for _, cg := range rule.CapGrant {
for _, pref := range cg.Dsts {
dests.AddPrefix(pref)
}
}
srcsSet, _ := srcs.IPSet()
destsSet, _ := dests.IPSet()
return Match{
srcs: srcsSet,
dests: destsSet,
}
return MatchFromStrings(rule.SrcIPs, dests)
}
// MatchFromStrings builds a Match from raw source and destination
-72
View File
@@ -180,78 +180,6 @@ func TestMatchFromFilterRule(t *testing.T) {
srcMatch: true,
dstMatch: false,
},
{
// Regression: cap-grant-only rules (e.g. cap/relay)
// carry their destinations in CapGrant.Dsts. The
// matcher must surface those for peer-visibility
// derivation. https://github.com/juanfont/headscale/issues/3256
name: "CapGrant Dsts populate destination set",
rule: tailcfg.FilterRule{
SrcIPs: []string{"100.64.0.1/32", "100.64.0.2/32"},
CapGrant: []tailcfg.CapGrant{
{
Dsts: []netip.Prefix{
netip.MustParsePrefix("100.64.0.3/32"),
},
CapMap: tailcfg.PeerCapMap{
tailcfg.PeerCapabilityRelay: nil,
},
},
},
},
checkSrc: netip.MustParseAddr("100.64.0.1"),
checkDst: netip.MustParseAddr("100.64.0.3"),
srcMatch: true,
dstMatch: true,
},
{
// Companion cap-grant shape produced by
// companionCapGrantRules: SrcIPs are the original
// destinations, CapGrant.Dsts are the original sources.
name: "companion CapGrant Dsts populate destination set",
rule: tailcfg.FilterRule{
SrcIPs: []string{"100.64.0.3/32"},
CapGrant: []tailcfg.CapGrant{
{
Dsts: []netip.Prefix{
netip.MustParsePrefix("100.64.0.1/32"),
netip.MustParsePrefix("100.64.0.2/32"),
},
CapMap: tailcfg.PeerCapMap{
tailcfg.PeerCapabilityRelayTarget: nil,
},
},
},
},
checkSrc: netip.MustParseAddr("100.64.0.3"),
checkDst: netip.MustParseAddr("100.64.0.2"),
srcMatch: true,
dstMatch: true,
},
{
// Mixed rule: DstPorts and CapGrant both contribute to dests.
name: "DstPorts and CapGrant Dsts both contribute",
rule: tailcfg.FilterRule{
SrcIPs: []string{"100.64.0.1/32"},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.0.0.0/8"},
},
CapGrant: []tailcfg.CapGrant{
{
Dsts: []netip.Prefix{
netip.MustParsePrefix("100.64.0.3/32"),
},
CapMap: tailcfg.PeerCapMap{
tailcfg.PeerCapabilityRelay: nil,
},
},
},
},
checkSrc: netip.MustParseAddr("100.64.0.1"),
checkDst: netip.MustParseAddr("100.64.0.3"),
srcMatch: true,
dstMatch: true,
},
}
for _, tt := range tests {
+5 -25
View File
@@ -1317,7 +1317,7 @@ func TestSSHPolicyRules(t *testing.T) {
]
}`,
expectErr: true,
errorMessage: `"invalid" is not a valid action`,
errorMessage: `invalid SSH action: "invalid", must be one of: accept, check`,
},
{
name: "invalid-check-period",
@@ -1341,15 +1341,10 @@ func TestSSHPolicyRules(t *testing.T) {
]
}`,
expectErr: true,
errorMessage: `time: invalid duration "invalid"`,
errorMessage: "not a valid duration string",
},
// `autogroup:invalid` as an SSH user is no longer rejected:
// SaaS treats every `autogroup:*` user-string as a literal
// label and compiles it into the SSHUsers map. The compat
// suite covers this via ssh-malformed-user-autogroup-* — no
// dedicated case is needed here.
{
name: "ssh-user-unknown-autogroup-as-literal",
name: "unsupported-autogroup",
targetNode: taggedClient,
peers: types.Nodes{&nodeUser2},
policy: `{
@@ -1368,23 +1363,8 @@ func TestSSHPolicyRules(t *testing.T) {
}
]
}`,
wantSSH: &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{
{
Principals: []*tailcfg.SSHPrincipal{
{NodeIP: "100.64.0.2"},
},
SSHUsers: map[string]string{
"autogroup:invalid": "autogroup:invalid",
"root": "",
},
Action: &tailcfg.SSHAction{
Accept: true,
AllowAgentForwarding: true,
AllowLocalPortForwarding: true,
AllowRemotePortForwarding: true,
},
},
}},
expectErr: true,
errorMessage: "autogroup not supported for SSH user",
},
{
name: "autogroup-nonroot-should-use-wildcard-with-root-excluded",
+22 -34
View File
@@ -187,20 +187,6 @@ func NewPolicyManager(b []byte, users []types.User, nodes views.Slice[types.Node
return nil, err
}
// Boot path: log a warning if the stored policy's tests would
// fail against the current users and nodes, but keep the server
// running. A stale stored policy (e.g. referencing a user that
// was deleted while the server was offline) should not block
// boot; the operator finds out via logs and re-runs the write
// boundary when they are ready.
if testErr := pm.RunTests(); testErr != nil { //nolint:noinlineerr // boot path: warn-and-continue, not return
log.Warn().Err(testErr).Msg("policy tests failed at boot; server starting anyway, fix the policy and reload")
}
if testErr := pm.RunSSHTests(); testErr != nil { //nolint:noinlineerr // boot path: warn-and-continue, not return
log.Warn().Err(testErr).Msg("policy sshTests failed at boot; server starting anyway, fix the policy and reload")
}
return &pm, nil
}
@@ -465,16 +451,9 @@ func (pm *PolicyManager) SetPolicy(polB []byte) (bool, error) {
// sandbox compiled from the new policy + current users/nodes; if
// they fail, return without mutating the live PolicyManager so the
// failed write does not knock the running config offline.
//
// Aggregate ACL and SSH test failures via multierr so operators
// see both classes in a single response instead of having to
// fix-and-retry to discover the second one.
testErr := multierr.New(
evaluateTests(pol, pm.users, pm.nodes),
evaluateSSHTests(pol, pm.users, pm.nodes),
)
if testErr != nil {
return false, testErr
err = evaluateTests(pol, pm.users, pm.nodes)
if err != nil {
return false, err
}
// Log policy metadata for debugging
@@ -1456,18 +1435,27 @@ func (pm *PolicyManager) invalidateGlobalPolicyCache(newNodes views.Slice[types.
}
}
// flattenTags flattens the TagOwners by resolving nested tags. Cycles
// in the ownership graph (tag:a -> tag:b -> tag:a, or tag:a -> tag:a)
// are tolerated to match SaaS: the cycle-causing edge is dropped, the
// remaining owners propagate, and the cycle itself contributes no
// addresses. Non-cycle owners on the cycled tags still resolve.
// Undefined-tag references remain a hard error.
// flattenTags flattens the TagOwners by resolving nested tags and detecting cycles.
// It will return a Owners list where all the Tag types have been resolved to their underlying Owners.
func flattenTags(tagOwners TagOwners, tag Tag, visiting map[Tag]bool, chain []Tag) (Owners, error) {
if visiting[tag] {
// Cycle: this tag is already on the resolution stack. SaaS
// drops the edge instead of failing, so we return an empty
// owner set and let the caller continue with any siblings.
return nil, nil
cycleStart := 0
for i, t := range chain {
if t == tag {
cycleStart = i
break
}
}
cycleTags := make([]string, len(chain[cycleStart:]))
for i, t := range chain[cycleStart:] {
cycleTags[i] = string(t)
}
slices.Sort(cycleTags)
return nil, fmt.Errorf("%w: %s", ErrCircularReference, strings.Join(cycleTags, " -> "))
}
visiting[tag] = true
-178
View File
@@ -2022,181 +2022,3 @@ func TestValidateUserReferences_AllSites(t *testing.T) {
})
}
}
// TestPeerRelayGrantMakesRelayVisible is a regression test for
// https://github.com/juanfont/headscale/issues/3256.
//
// A grant that uses only `app: { "tailscale.com/cap/relay": [] }` must
// make the relay node visible to the source nodes (and vice-versa).
// Before the fix, MatchFromFilterRule only considered DstPorts as
// destinations and ignored CapGrant.Dsts, so cap-grant-only rules
// produced matchers with an empty destination set and BuildPeerMap
// could not detect the cap-relay relationship.
//
// Sub-tests cover every alias shape documented for peer-relay grants
// at https://tailscale.com/docs/features/peer-relay: tag→tag,
// hostname→hostname (`hosts` block lookup), autogroup:member→hostname,
// and a direct Tailscale-IP destination. Each must establish mutual
// visibility between sources and the relay node without any companion
// IP-level grant.
func TestPeerRelayGrantMakesRelayVisible(t *testing.T) {
users := types.Users{
{Model: gorm.Model{ID: 1}, Name: "alice", Email: "alice@headscale.net"},
{Model: gorm.Model{ID: 2}, Name: "tagowner", Email: "tagowner@headscale.net"},
}
// Helper for tagged nodes belonging to the tag-owner user.
taggedNode := func(id types.NodeID, hostname, v4, v6 string, tags ...string) *types.Node {
return &types.Node{
ID: id,
Hostname: hostname,
IPv4: ap(v4),
IPv6: ap(v6),
User: new(users[1]),
UserID: new(users[1].ID),
Tags: tags,
}
}
userNode := func(id types.NodeID, hostname, v4, v6 string) *types.Node {
return &types.Node{
ID: id,
Hostname: hostname,
IPv4: ap(v4),
IPv6: ap(v6),
User: new(users[0]),
UserID: new(users[0].ID),
}
}
tests := []struct {
name string
nodes types.Nodes
policy string
srcIDs []types.NodeID // expected to see the relay
relayID types.NodeID
}{
{
// Issue #3256 example: hosts block + autogroup:member src,
// hostname dst.
name: "hosts+autogroup_member src, hostname dst",
nodes: types.Nodes{
userNode(1, "n1", "100.64.0.1", "fd7a:115c:a1e0::1"),
userNode(2, "n2", "100.64.0.2", "fd7a:115c:a1e0::2"),
userNode(3, "peer-relay", "100.64.0.3", "fd7a:115c:a1e0::3"),
},
policy: `{
"hosts": {
"n1": "100.64.0.1/32",
"n2": "100.64.0.2/32",
"peer-relay": "100.64.0.3/32"
},
"grants": [
{"src": ["n1"], "dst": ["n2"], "ip": ["*"]},
{
"src": ["autogroup:member"],
"dst": ["peer-relay"],
"app": {"tailscale.com/cap/relay": []}
}
]
}`,
srcIDs: []types.NodeID{1, 2},
relayID: 3,
},
{
// Tailscale docs example 1: tag → tag.
name: "tag src, tag dst",
nodes: types.Nodes{
taggedNode(1, "vpc-a", "100.64.0.1", "fd7a:115c:a1e0::1", "tag:us-east-vpc"),
taggedNode(2, "vpc-b", "100.64.0.2", "fd7a:115c:a1e0::2", "tag:us-east-vpc"),
taggedNode(3, "relay-1", "100.64.0.3", "fd7a:115c:a1e0::3", "tag:us-east-relays"),
},
policy: `{
"tagOwners": {
"tag:us-east-vpc": ["tagowner@headscale.net"],
"tag:us-east-relays": ["tagowner@headscale.net"]
},
"grants": [
{
"src": ["tag:us-east-vpc"],
"dst": ["tag:us-east-relays"],
"app": {"tailscale.com/cap/relay": []}
}
]
}`,
srcIDs: []types.NodeID{1, 2},
relayID: 3,
},
{
// Direct Tailscale-IP destination (no hosts alias).
name: "tag src, raw Tailscale IP dst",
nodes: types.Nodes{
taggedNode(1, "client-a", "100.64.0.1", "fd7a:115c:a1e0::1", "tag:client"),
taggedNode(2, "client-b", "100.64.0.2", "fd7a:115c:a1e0::2", "tag:client"),
userNode(3, "peer-relay", "100.64.0.3", "fd7a:115c:a1e0::3"),
},
policy: `{
"tagOwners": {
"tag:client": ["tagowner@headscale.net"]
},
"grants": [
{
"src": ["tag:client"],
"dst": ["100.64.0.3/32"],
"app": {"tailscale.com/cap/relay": []}
}
]
}`,
srcIDs: []types.NodeID{1, 2},
relayID: 3,
},
{
// User → hostname relay using `hosts` aliasing.
name: "user src, hostname dst via hosts block",
nodes: types.Nodes{
userNode(1, "n1", "100.64.0.1", "fd7a:115c:a1e0::1"),
userNode(3, "peer-relay", "100.64.0.3", "fd7a:115c:a1e0::3"),
},
policy: `{
"hosts": {
"peer-relay": "100.64.0.3/32"
},
"grants": [
{
"src": ["alice@headscale.net"],
"dst": ["peer-relay"],
"app": {"tailscale.com/cap/relay": []}
}
]
}`,
srcIDs: []types.NodeID{1},
relayID: 3,
},
}
containsID := func(peers []types.NodeView, id types.NodeID) bool {
return slices.ContainsFunc(peers, func(nv types.NodeView) bool {
return nv.ID() == id
})
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pm, err := NewPolicyManager(
[]byte(tt.policy), users, tt.nodes.ViewSlice(),
)
require.NoError(t, err)
peerMap := pm.BuildPeerMap(tt.nodes.ViewSlice())
for _, srcID := range tt.srcIDs {
require.True(t, containsID(peerMap[srcID], tt.relayID),
"node %d must see relay %d via cap/relay alone",
srcID, tt.relayID)
require.True(t, containsID(peerMap[tt.relayID], srcID),
"relay %d must see node %d via cap/relay alone",
tt.relayID, srcID)
}
})
}
}
-748
View File
@@ -1,748 +0,0 @@
package v2
import (
"fmt"
"net/netip"
"slices"
"strings"
"github.com/juanfont/headscale/hscontrol/types"
"go4.org/netipx"
"tailscale.com/tailcfg"
"tailscale.com/types/views"
)
// The sshTests block is Tailscale's parallel of the ACL `tests` block for
// SSH-shaped rules: each entry asserts that a source identity reaching out
// to one or more destination hosts can SSH in as the named login users —
// or, conversely, must be refused. The block runs at the same user-write
// boundary as `tests` (SetPolicy, `headscale policy check`, file-mode
// reload after a change). Boot-time reload skips evaluation so a stored
// policy referencing a now-deleted entity does not block startup.
//
// Three assertion kinds:
//
// - accept[user]: from src, every dst must be reachable as user via an
// action:accept OR action:check rule. Check counts as reachable for
// the accept assertion because both actions resolve to "the user is
// allowed to start a session" at the wire layer.
// - deny[user]: from src, no dst is reachable as user. A test passes
// when no rule allows the user at all (or every matching rule's
// SSHUsers map blocks the user).
// - check[user]: from src, every dst must be reachable as user via a
// rule whose action is specifically check (HoldAndDelegate is the
// wire signal — see filter.go sshCheck). An accept-only match
// fails the check assertion: SaaS keeps the distinction so policy
// authors can pin sensitive logins to check rules.
// SSHPolicyTestResult is the outcome of a single SSHPolicyTest.
//
// Each map is keyed by login user and records the per-dst breakdown so
// the rendered error tells the operator which (src, user, dst) triple
// went the wrong way.
type SSHPolicyTestResult struct {
Src string `json:"src"`
Passed bool `json:"passed"`
Errors []string `json:"errors,omitempty"`
AcceptOK map[string][]string `json:"accept_ok,omitempty"`
AcceptFail map[string][]string `json:"accept_fail,omitempty"`
DenyOK map[string][]string `json:"deny_ok,omitempty"`
DenyFail map[string][]string `json:"deny_fail,omitempty"`
CheckOK map[string][]string `json:"check_ok,omitempty"`
CheckFail map[string][]string `json:"check_fail,omitempty"`
}
// SSHPolicyTestResults aggregates one evaluation run.
type SSHPolicyTestResults struct {
AllPassed bool `json:"all_passed"`
Results []SSHPolicyTestResult `json:"results"`
}
// Errors renders the per-test failure breakdown joined by newlines.
//
// Tailscale SaaS returns only the literal "test(s) failed" body for
// either assertion class. We keep the per-test detail because operators
// invoking SetPolicy from the CLI or file-mode reload have no separate
// audit endpoint, so the rendered body is the only signal they get.
func (r SSHPolicyTestResults) Errors() string {
if r.AllPassed {
return ""
}
var lines []string
for _, res := range r.Results {
if res.Passed {
continue
}
for _, e := range res.Errors {
lines = append(lines, fmt.Sprintf("%s: %s", res.Src, e))
}
for _, user := range sortedUsers(res.AcceptFail) {
for _, dst := range res.AcceptFail[user] {
lines = append(lines, fmt.Sprintf(
"%s/%s -> %s: expected ALLOWED, got DENIED",
res.Src, displayUser(user), dst,
))
}
}
for _, user := range sortedUsers(res.DenyFail) {
for _, dst := range res.DenyFail[user] {
lines = append(lines, fmt.Sprintf(
"%s/%s -> %s: expected DENIED, got ALLOWED",
res.Src, displayUser(user), dst,
))
}
}
for _, user := range sortedUsers(res.CheckFail) {
for _, dst := range res.CheckFail[user] {
lines = append(lines, fmt.Sprintf(
"%s/%s -> %s: expected ALLOWED via check, got %s",
res.Src, displayUser(user), dst,
checkFailReason(res, user, dst),
))
}
}
}
return strings.Join(lines, "\n")
}
// sortedUsers returns the keys of m sorted by user name so error
// rendering is deterministic across runs.
func sortedUsers(m map[string][]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
slices.Sort(keys)
return keys
}
// displayUser formats a login user for the rendered error. An empty
// string is shown as `""` so the operator can see that the assertion
// referenced an empty username (which is itself a failure case).
func displayUser(u string) string {
if u == "" {
return `""`
}
return u
}
// checkFailReason annotates a check-fail line with whether the user
// reached the dst via an accept rule (so the operator knows to flip the
// rule to action:check) or did not reach the dst at all.
func checkFailReason(res SSHPolicyTestResult, user, dst string) string {
if slices.Contains(res.AcceptOK[user], dst) {
return "ALLOWED via accept"
}
return "DENIED"
}
// RunSSHTests evaluates the policy's sshTests block against the live
// users and nodes and returns a wrapped error when any assertion fails.
// Callers that need the per-test breakdown can call runSSHPolicyTests
// directly with their own compile cache.
func (pm *PolicyManager) RunSSHTests() error {
if pm == nil || pm.pol == nil || len(pm.pol.SSHTests) == 0 {
return nil
}
pm.mu.Lock()
defer pm.mu.Unlock()
cache := make(map[types.NodeID]*tailcfg.SSHPolicy)
results := runSSHPolicyTests(pm.pol, pm.users, pm.nodes, cache)
if results.AllPassed {
return nil
}
return fmt.Errorf("%w:\n%s", errSSHPolicyTestsFailed, results.Errors())
}
// evaluateSSHTests is the user-write sandbox: run sshTests against pol
// + current users/nodes without mutating any live state. It mirrors
// evaluateTests for the ACL block.
func evaluateSSHTests(
pol *Policy,
users []types.User,
nodes views.Slice[types.NodeView],
) error {
if pol == nil || len(pol.SSHTests) == 0 {
return nil
}
cache := make(map[types.NodeID]*tailcfg.SSHPolicy)
results := runSSHPolicyTests(pol, users, nodes, cache)
if results.AllPassed {
return nil
}
return fmt.Errorf("%w:\n%s", errSSHPolicyTestsFailed, results.Errors())
}
// runSSHPolicyTests evaluates every sshTests entry against pol. The
// cache is keyed by destination node ID and reused across entries so a
// 10-entry block hitting 4 dst nodes pays 4 compiles, not 40.
func runSSHPolicyTests(
pol *Policy,
users []types.User,
nodes views.Slice[types.NodeView],
cache map[types.NodeID]*tailcfg.SSHPolicy,
) SSHPolicyTestResults {
results := SSHPolicyTestResults{
AllPassed: true,
Results: make([]SSHPolicyTestResult, 0, len(pol.SSHTests)),
}
for _, test := range pol.SSHTests {
res := runSSHPolicyTest(test, pol, users, nodes, cache)
if !res.Passed {
results.AllPassed = false
}
results.Results = append(results.Results, res)
}
return results
}
// runSSHPolicyTest evaluates one SSHPolicyTest entry against pol.
//
// Order of operations: resolve src → resolve dst nodes → reject empty
// assertion blocks → walk accept/deny/check arrays, asking the per-dst
// compiled SSH policy whether the user can reach the dst.
func runSSHPolicyTest(
test SSHPolicyTest,
pol *Policy,
users []types.User,
nodes views.Slice[types.NodeView],
cache map[types.NodeID]*tailcfg.SSHPolicy,
) SSHPolicyTestResult {
srcLabel := ""
if test.Src != nil {
srcLabel = test.Src.String()
}
res := SSHPolicyTestResult{
Src: srcLabel,
Passed: true,
}
srcAddrs, srcUserID, err := resolveSSHTestSource(test.Src, pol, users, nodes)
if err != nil {
res.Passed = false
res.Errors = append(res.Errors,
fmt.Sprintf("failed to resolve source %q: %v", srcLabel, err))
return res
}
if len(srcAddrs) == 0 {
res.Passed = false
res.Errors = append(res.Errors,
fmt.Sprintf("source %q resolved to no IP addresses", srcLabel))
return res
}
// Tailscale SaaS treats an entry with no accept/deny/check arrays
// as "nothing to assert", which is reported as a failure. Catching
// it here keeps the engine output mirroring SaaS for parse-accepted
// inputs.
if len(test.Accept) == 0 && len(test.Deny) == 0 && len(test.Check) == 0 {
res.Passed = false
res.Errors = append(res.Errors,
"no accept, deny, or check assertions specified")
return res
}
dstNodes, emptyDsts, err := resolveSSHTestDestNodes(test.Dst, pol, users, nodes, srcUserID)
if err != nil {
res.Passed = false
res.Errors = append(res.Errors,
fmt.Sprintf("failed to resolve destinations: %v", err))
return res
}
// SaaS treats a dst alias that resolves to no nodes as a failure
// when the entry has anything to assert. Without this branch, the
// per-assertion loops below run zero iterations and the test
// passes silently — wrong shape, missed regression.
for _, dst := range emptyDsts {
res.Passed = false
res.Errors = append(res.Errors,
fmt.Sprintf("dst alias %q resolved to no nodes", dst))
}
if len(dstNodes) == 0 {
return res
}
for _, user := range test.Accept {
evaluateAssertion(
pol, users, nodes, cache,
srcAddrs, dstNodes, user.String(),
assertAccept, &res,
)
}
for _, user := range test.Deny {
evaluateAssertion(
pol, users, nodes, cache,
srcAddrs, dstNodes, user.String(),
assertDeny, &res,
)
}
for _, user := range test.Check {
evaluateAssertion(
pol, users, nodes, cache,
srcAddrs, dstNodes, user.String(),
assertCheck, &res,
)
}
return res
}
// sshAssertion is the kind of assertion being evaluated for a single
// (src, dst, user) triple.
type sshAssertion int
const (
assertAccept sshAssertion = iota
assertDeny
assertCheck
)
// evaluateAssertion walks every (srcAddr, dstNode) pair for one user and
// records the outcome in res. The semantics:
//
// - accept passes iff every (srcAddr, dstNode) reaches the dst via at
// least one rule whose action is accept or check.
// - deny passes iff no (srcAddr, dstNode) is reachable as user.
// - check passes iff every (srcAddr, dstNode) reaches the dst via at
// least one check rule (HoldAndDelegate set). An accept-only match
// fails the check assertion — SaaS keeps the two categories
// distinct.
//
// Empty username is parse-accepted but reports as a failure here: SSH
// login users cannot be empty, so the assertion can never be satisfied.
func evaluateAssertion(
pol *Policy,
users []types.User,
nodes views.Slice[types.NodeView],
cache map[types.NodeID]*tailcfg.SSHPolicy,
srcAddrs []netip.Addr,
dstNodes []types.NodeView,
user string,
kind sshAssertion,
res *SSHPolicyTestResult,
) {
dstLoop:
for _, dst := range dstNodes {
dstPol, err := compiledSSHPolicy(pol, users, nodes, cache, dst)
if err != nil {
res.Passed = false
res.Errors = append(res.Errors,
fmt.Sprintf("compiling SSH policy for %s: %v",
dst.Hostname(), err))
continue
}
dstLabel := dst.Hostname()
// acceptHit covers "any matching accept-or-check rule";
// checkHit restricts to check-action matches only.
acceptHit := false
checkHit := false
for _, srcAddr := range srcAddrs {
a, c := reachability(dstPol, srcAddr, user)
if a {
acceptHit = true
}
if c {
checkHit = true
}
// accept and deny require ALL src IPs to reach (or all
// to be blocked). A single counter-example fails the
// assertion.
switch kind {
case assertAccept:
if !a {
res.Passed = false
res.AcceptFail = appendUserDst(res.AcceptFail, user, dstLabel)
continue dstLoop
}
case assertDeny:
if a {
res.Passed = false
res.DenyFail = appendUserDst(res.DenyFail, user, dstLabel)
continue dstLoop
}
case assertCheck:
if !c {
res.Passed = false
res.CheckFail = appendUserDst(res.CheckFail, user, dstLabel)
// Record whether the accept side passed so
// the rendered error can say "ALLOWED via
// accept" instead of "DENIED".
if a {
res.AcceptOK = appendUserDst(res.AcceptOK, user, dstLabel)
}
continue dstLoop
}
}
}
switch kind {
case assertAccept:
if acceptHit {
res.AcceptOK = appendUserDst(res.AcceptOK, user, dstLabel)
}
case assertDeny:
res.DenyOK = appendUserDst(res.DenyOK, user, dstLabel)
case assertCheck:
if checkHit {
res.CheckOK = appendUserDst(res.CheckOK, user, dstLabel)
}
}
}
}
// appendUserDst appends dst to m[user], lazily allocating m.
func appendUserDst(m map[string][]string, user, dst string) map[string][]string {
if m == nil {
m = make(map[string][]string)
}
m[user] = append(m[user], dst)
return m
}
// resolveSSHTestSource resolves the typed src alias into a list of
// netip.Addr (one per principal address the SSH compiler would emit
// for the same source). For user-shaped sources, srcUserID returns the
// resolved user's ID so autogroup:self destinations can scope to the
// same user. Returns ID 0 when the source is a tag, host, or IP.
func resolveSSHTestSource(
src Alias,
pol *Policy,
users []types.User,
nodes views.Slice[types.NodeView],
) ([]netip.Addr, uint, error) {
if src == nil {
return nil, 0, nil
}
addrs, err := src.Resolve(pol, users, nodes)
if err != nil {
return nil, 0, fmt.Errorf("resolving: %w", err)
}
if addrs == nil || addrs.Empty() {
return nil, 0, nil
}
out := make([]netip.Addr, 0)
for a := range addrs.Iter() {
out = append(out, a)
}
var userID uint
u, ok := src.(*Username)
if ok {
resolved, rErr := u.resolveUser(users)
if rErr == nil {
userID = resolved.ID
}
}
return out, userID, nil
}
// resolveSSHTestDestNodes resolves every dst alias in the test entry
// to its destination NodeViews. autogroup:self requires special
// handling because it cannot resolve in the general (non-per-node)
// context — see AutoGroup.resolve in types.go.
//
// For non-self aliases, the resolved IPSet is matched against each
// node's IPs via InIPSet (the same primitive the SSH compiler uses to
// decide whether a node is a destination of a given rule).
func resolveSSHTestDestNodes(
dsts SSHTestDestinations,
pol *Policy,
users []types.User,
nodes views.Slice[types.NodeView],
srcUserID uint,
) ([]types.NodeView, []string, error) {
seen := make(map[types.NodeID]struct{})
var (
out []types.NodeView
emptyDsts []string
)
for _, alias := range dsts {
dstLabel := alias.String()
matched := false
if ag, ok := alias.(*AutoGroup); ok && ag.Is(AutoGroupSelf) {
// autogroup:self → destinations are the non-tagged
// nodes owned by the same user as src. A tagged or
// IP-only src has no user identity, so the dst set
// is empty and the caller surfaces it as a failure
// (matches SaaS, which treats a no-node dst as a
// failing assertion).
if srcUserID == 0 {
emptyDsts = append(emptyDsts, dstLabel)
continue
}
for _, n := range nodes.All() {
if n.IsTagged() {
continue
}
if !n.User().Valid() {
continue
}
if n.User().ID() != srcUserID {
continue
}
matched = true
if _, dup := seen[n.ID()]; dup {
continue
}
seen[n.ID()] = struct{}{}
out = append(out, n)
}
if !matched {
emptyDsts = append(emptyDsts, dstLabel)
}
continue
}
ips, err := alias.Resolve(pol, users, nodes)
if err != nil {
return nil, nil, fmt.Errorf("resolving destination %q: %w", dstLabel, err)
}
if ips == nil || ips.Empty() {
emptyDsts = append(emptyDsts, dstLabel)
continue
}
// Compile to an IPSet for the InIPSet primitive. ResolvedAddresses
// already wraps one; expose it via the IPSet builder by walking
// the resolved prefixes.
set, err := prefixesToIPSet(ips.Prefixes())
if err != nil {
return nil, nil, fmt.Errorf("building IPSet for %q: %w", dstLabel, err)
}
for _, n := range nodes.All() {
if !n.InIPSet(set) {
continue
}
matched = true
if _, dup := seen[n.ID()]; dup {
continue
}
seen[n.ID()] = struct{}{}
out = append(out, n)
}
if !matched {
emptyDsts = append(emptyDsts, dstLabel)
}
}
return out, emptyDsts, nil
}
// prefixesToIPSet builds a netipx.IPSet from a slice of prefixes. The
// SSH compiler does the same dance via netipx.IPSetBuilder; we mirror
// the shape so InIPSet (the node-side primitive) behaves identically
// for test evaluation and live compilation.
func prefixesToIPSet(prefixes []netip.Prefix) (*netipx.IPSet, error) {
var b netipx.IPSetBuilder
for _, p := range prefixes {
b.AddPrefix(p)
}
return b.IPSet()
}
// compiledSSHPolicy returns the per-node compiled SSH policy, populating
// cache on miss. baseURL is empty because the engine only needs the
// "is this rule a check rule" signal (HoldAndDelegate non-empty), not
// the actual URL contents.
func compiledSSHPolicy(
pol *Policy,
users []types.User,
nodes views.Slice[types.NodeView],
cache map[types.NodeID]*tailcfg.SSHPolicy,
node types.NodeView,
) (*tailcfg.SSHPolicy, error) {
if sshPol, ok := cache[node.ID()]; ok {
return sshPol, nil
}
sshPol, err := pol.compileSSHPolicy("", users, node, nodes)
if err != nil {
return nil, err
}
cache[node.ID()] = sshPol
return sshPol, nil
}
// reachability walks dstPolicy.Rules and reports whether srcAddr is
// allowed to log in as user via:
//
// - any rule (first return) — satisfies accept assertions
// - a check rule specifically (second return) — satisfies check assertions
//
// A nil policy is treated as "no rule matches", which is the right
// answer for both accept (DENIED) and check (DENIED) and for deny
// (PASS, because the deny assertion inverts).
func reachability(
dstPolicy *tailcfg.SSHPolicy,
srcAddr netip.Addr,
user string,
) (bool, bool) {
if dstPolicy == nil {
return false, false
}
var acceptHit, checkHit bool
for _, rule := range dstPolicy.Rules {
if !principalContainsAddr(rule.Principals, srcAddr) {
continue
}
if !sshUserMapAllows(rule.SSHUsers, user) {
continue
}
if rule.Action == nil {
continue
}
acceptHit = true
if rule.Action.HoldAndDelegate != "" {
checkHit = true
}
// Early-out only when both bits are set; a rule that
// satisfies one assertion may not satisfy the other.
if acceptHit && checkHit {
return acceptHit, checkHit
}
}
return acceptHit, checkHit
}
// principalContainsAddr reports whether any principal has a NodeIP
// matching srcAddr. The SSH compiler emits one principal per source
// IP, so an exact-match comparison is correct.
func principalContainsAddr(
principals []*tailcfg.SSHPrincipal,
srcAddr netip.Addr,
) bool {
for _, p := range principals {
if p == nil {
continue
}
if p.NodeIP == "" {
continue
}
addr, err := netip.ParseAddr(p.NodeIP)
if err != nil {
continue
}
if addr == srcAddr {
return true
}
}
return false
}
// sshUserMapAllows reports whether SSHUsers permits user. The wire
// shape (see filter.go compileSSHPolicy):
//
// - SSHUsers["root"] == "root" when the rule's users list contains
// "root", and == "" otherwise (the empty mapping means "root NOT
// allowed", per Tailscale's SSH evaluator).
// - SSHUsers["*"] == "=" when the rule's users list contains
// autogroup:nonroot — wildcard fallback for any non-root user.
// - SSHUsers[<literal>] == <literal> for every named SSH user in
// the rule.
//
// An empty user input (which the parse layer accepts but treats as
// a failure case) cannot match any map entry.
func sshUserMapAllows(m map[string]string, user string) bool {
if user == "" {
return false
}
if v, ok := m[user]; ok {
return v != ""
}
if user == "root" {
return false
}
// Wildcard fallback for non-root users.
if v, ok := m["*"]; ok {
return v != ""
}
return false
}
File diff suppressed because it is too large Load Diff
@@ -1,115 +0,0 @@
// Compatibility tests for the policy `sshTests` block, replaying captures
// recorded against a real Tailscale SaaS tailnet. The runner mirrors the
// pattern in policytester_compat_test.go: a single Glob over a testdata
// directory, one t.Run per file. Each capture is one of:
//
// - APIResponseCode != 200 — the policy was rejected by SaaS, the
// captured Message is the body the user saw, and headscale must
// reject the same input with an error string that contains the same
// body (substring match, allowing wrapping like "test(s) failed:\n…").
// - APIResponseCode == 200 — SaaS accepted the policy (its sshTests
// block passed); headscale's evaluateSSHTests must also pass.
//
// Captures live in testdata/sshtest_results/*.hujson. Scenarios in
// knownSSHTesterDivergences are skipped with their tracking note —
// these are real Tailscale ↔ headscale divergences that need engine-level
// fixes in follow-up PRs.
//
// Source format: github.com/juanfont/headscale/hscontrol/types/testcapture
package v2
import (
"path/filepath"
"strings"
"testing"
"github.com/juanfont/headscale/hscontrol/types/testcapture"
"github.com/stretchr/testify/require"
)
// knownSSHTesterDivergences tracks scenarios where headscale and SaaS
// disagree on whether a policy is accepted. Each entry should describe
// the engine area a follow-up PR needs to touch.
var knownSSHTesterDivergences = map[string]string{
// SaaS parse-accepts a bare IPv6 sshTests dst but engine-rejects
// the same input with "test(s) failed" while the matching IPv4
// scenario engine-passes. The two captures share the same topology
// and the same policy shape, so the asymmetry is in the SaaS
// sshTests evaluator's IPv6 handling, not in any rule the user
// wrote. Headscale's evaluator resolves both literals to the
// tagged node that carries them and the assertion passes — a
// follow-up needs to either reproduce the SaaS-side IPv6 quirk or
// confirm this is a SaaS bug we will not match.
"sshtest-malformed-dst-bare-ipv6": "engine: SaaS rejects bare IPv6 sshTests dst; headscale accepts (IPv4 mirror passes both sides)",
}
func TestSSHTesterCompat(t *testing.T) {
t.Parallel()
files, err := filepath.Glob(filepath.Join("testdata", "sshtest_results", "*.hujson"))
require.NoError(t, err, "failed to glob test files")
if len(files) == 0 {
t.Skip("no sshtest captures yet")
}
users := setupSSHDataCompatUsers()
for _, file := range files {
c, err := testcapture.Read(file)
require.NoError(t, err, "reading %s", file)
t.Run(c.TestID, func(t *testing.T) {
t.Parallel()
if reason, skip := knownSSHTesterDivergences[c.TestID]; skip {
t.Skip(reason)
}
// Per-capture nodes mean the topology IPs (which a
// policy `hosts` mapping references by literal IP)
// resolve to real nodes in the test fixture. Without
// this the static fixture's IPs do not overlap with
// the captures and host-alias dsts resolve to no
// nodes — that path is now a load-bearing failure.
nodes := buildGrantsNodesFromCapture(users, c)
policyJSON := []byte(c.Input.FullPolicy)
pm, parseErr := NewPolicyManager(policyJSON, users, nodes.ViewSlice())
if c.Input.APIResponseCode == 200 {
require.NoError(t, parseErr,
"tailscale accepted this policy; headscale must parse it")
_, setErr := pm.SetPolicy(policyJSON)
require.NoError(t, setErr,
"tailscale accepted this policy; headscale sshTests must pass")
return
}
var got error
switch {
case parseErr != nil:
got = parseErr
default:
_, setErr := pm.SetPolicy(policyJSON)
got = setErr
}
require.Error(t, got, "tailscale rejected; headscale must reject too")
if c.Input.APIResponseBody == nil || c.Input.APIResponseBody.Message == "" {
return
}
want := c.Input.APIResponseBody.Message
if !strings.Contains(got.Error(), want) {
t.Errorf("error body mismatch\n tailscale wants: %q\n headscale got: %q", want, got.Error())
}
})
}
}
@@ -3,17 +3,13 @@
// from Tailscale SaaS by tscap, and compares headscale's SSH policy compilation
// against the captured SSH rules.
//
// Each capture is one of:
// - APIResponseCode == 200 — SaaS accepted the policy; the captured
// per-node SSH rules in tf.Captures[name].SSHRules are the source of
// truth, and headscale's compileSSHPolicy must produce the same shape.
// - APIResponseCode != 200 — SaaS rejected the policy at the API; the
// captured Message is the body the user saw. headscale must reject
// the same input with an error whose text contains that body as a
// substring (mirroring sshtester_compat_test.go).
// Each file is a testcapture.Capture containing:
// - The full policy that was POSTed to Tailscale SaaS (we use tf.Input.FullPolicy
// directly instead of reconstructing it from a sub-section)
// - The expected SSH rules for each of the 8 test nodes (in tf.Captures[name].SSHRules)
//
// Tests known to diverge are listed in sshSkipReasons (200 path) or
// sshRejectSkipReasons (!= 200 path) with a TODO explaining the gap.
// Tests known to fail due to unimplemented features or known differences are
// skipped with a TODO comment explaining the root cause.
//
// Test data source: testdata/ssh_results/ssh-*.hujson
// Source format: github.com/juanfont/headscale/hscontrol/types/testcapture
@@ -65,6 +61,61 @@ func setupSSHDataCompatUsers() types.Users {
}
}
// setupSSHDataCompatNodes returns the test nodes for SSH data-driven
// compatibility tests. Node GivenNames match the anonymized pokémon names:
// - bulbasaur (owned by odin)
// - ivysaur (owned by thor)
// - venusaur (owned by freya)
// - beedrill (tag:server)
// - kakuna (tag:prod)
func setupSSHDataCompatNodes(users types.Users) types.Nodes {
return types.Nodes{
&types.Node{
ID: 1,
GivenName: "bulbasaur",
User: &users[0],
UserID: &users[0].ID,
IPv4: ptrAddr("100.90.199.68"),
IPv6: ptrAddr("fd7a:115c:a1e0::2d01:c747"),
Hostinfo: &tailcfg.Hostinfo{},
},
&types.Node{
ID: 2,
GivenName: "ivysaur",
User: &users[1],
UserID: &users[1].ID,
IPv4: ptrAddr("100.110.121.96"),
IPv6: ptrAddr("fd7a:115c:a1e0::1737:7960"),
Hostinfo: &tailcfg.Hostinfo{},
},
&types.Node{
ID: 3,
GivenName: "venusaur",
User: &users[2],
UserID: &users[2].ID,
IPv4: ptrAddr("100.103.90.82"),
IPv6: ptrAddr("fd7a:115c:a1e0::9e37:5a52"),
Hostinfo: &tailcfg.Hostinfo{},
},
&types.Node{
ID: 4,
GivenName: "beedrill",
IPv4: ptrAddr("100.108.74.26"),
IPv6: ptrAddr("fd7a:115c:a1e0::b901:4a87"),
Tags: []string{"tag:server"},
Hostinfo: &tailcfg.Hostinfo{},
},
&types.Node{
ID: 5,
GivenName: "kakuna",
IPv4: ptrAddr("100.103.8.15"),
IPv6: ptrAddr("fd7a:115c:a1e0::5b37:80f"),
Tags: []string{"tag:prod"},
Hostinfo: &tailcfg.Hostinfo{},
},
}
}
// loadSSHTestFile loads and parses a single SSH capture HuJSON file.
func loadSSHTestFile(t *testing.T, path string) *testcapture.Capture {
t.Helper()
@@ -75,11 +126,8 @@ func loadSSHTestFile(t *testing.T, path string) *testcapture.Capture {
return c
}
// sshSkipReasons documents APIResponseCode == 200 captures where SaaS
// accepted the policy but headscale either does not yet support the
// shape or rejects it stricter than SaaS does. Each entry should
// describe the gap a follow-up PR needs to close (or justify why
// headscale is intentionally stricter).
// sshSkipReasons documents why each skipped test fails and what needs to be
// fixed. Tests are grouped by root cause to identify high-impact changes.
var sshSkipReasons = map[string]string{
// USER_PASSKEY_WILDCARD (2 tests)
//
@@ -87,25 +135,18 @@ var sshSkipReasons = map[string]string{
// equivalent for the user:*@passkey wildcard pattern.
"ssh-b5": "user:*@passkey wildcard not supported in headscale",
"ssh-d10": "user:*@passkey wildcard not supported in headscale",
}
// sshRejectSkipReasons documents APIResponseCode != 200 captures where
// headscale and SaaS legitimately disagree on whether the policy should
// be rejected (or where headscale rejects with different wording).
var sshRejectSkipReasons = map[string]string{
// DOMAIN_NOT_ASSOCIATED (5 tests)
// DOMAIN_NOT_ASSOCIATED (4 tests)
//
// SaaS validates that email domains in user:*@domain and
// localpart:*@domain expressions are configured tailnet
// domains. headscale has no concept of "associated tailnet
// domains" — it only has users with email addresses. These
// policies are legitimately rejected by SaaS but not by
// headscale.
// localpart:*@domain expressions are configured tailnet domains.
// headscale has no concept of "associated tailnet domains" — it
// only has users with email addresses. These policies are
// legitimately rejected by SaaS but not by headscale.
"ssh-b4": "domain validation: headscale has no 'associated tailnet domains' concept",
"ssh-d1": "domain validation: headscale has no 'associated tailnet domains' concept",
"ssh-e1": "domain validation: headscale has no 'associated tailnet domains' concept",
"ssh-e2": "domain validation: headscale has no 'associated tailnet domains' concept",
"ssh-malformed-user-localpart-multi-glob": "domain validation: headscale has no 'associated tailnet domains' concept (same gap as ssh-b4/d1/e1/e2)",
}
// TestSSHDataCompat is a data-driven test that loads all ssh-*.hujson test
@@ -151,63 +192,7 @@ func TestSSHDataCompat(t *testing.T) {
t.Run(tf.TestID, func(t *testing.T) {
t.Parallel()
// Build nodes per-scenario from this file's topology.
// tscap uses clean-slate mode, so each scenario has
// different node IPs.
nodes := buildGrantsNodesFromCapture(users, tf)
// Use the captured full policy as is. Anonymization in
// tscap already rewrites SaaS emails to @example.com.
policyJSON := []byte(tf.Input.FullPolicy)
// Branch on the SaaS response code. Captures with
// APIResponseCode != 200 are policies SaaS rejected at
// the API; headscale must reject the same input. The
// 200 path falls through to the existing per-node SSH
// rule comparison.
if tf.Input.APIResponseCode != 200 {
if reason, ok := sshRejectSkipReasons[tf.TestID]; ok {
t.Skipf(
"TODO: %s — see sshRejectSkipReasons for details",
reason,
)
return
}
pm, parseErr := NewPolicyManager(policyJSON, users, nodes.ViewSlice())
var got error
switch {
case parseErr != nil:
got = parseErr
default:
_, setErr := pm.SetPolicy(policyJSON)
got = setErr
}
require.Error(t, got, "tailscale rejected; headscale must reject too")
if tf.Input.APIResponseBody == nil ||
tf.Input.APIResponseBody.Message == "" {
return
}
want := tf.Input.APIResponseBody.Message
if !strings.Contains(got.Error(), want) {
t.Errorf(
"error body mismatch\n tailscale wants: %q\n headscale got: %q",
want,
got.Error(),
)
}
return
}
// APIResponseCode == 200: SaaS accepted; headscale must
// match the captured per-node SSH rules.
// Check if this test is in the skip list
if reason, ok := sshSkipReasons[tf.TestID]; ok {
t.Skipf(
"TODO: %s — see sshSkipReasons comments for details",
@@ -217,13 +202,29 @@ func TestSSHDataCompat(t *testing.T) {
return
}
pol, err := unmarshalPolicy(policyJSON)
// SaaS rejected this policy — verify headscale also rejects it.
if tf.Error {
testSSHError(t, tf)
return
}
// Build nodes per-scenario from this file's topology.
// tscap uses clean-slate mode, so each scenario has
// different node IPs.
nodes := buildGrantsNodesFromCapture(users, tf)
// Use the captured full policy as is. Anonymization in
// tscap already rewrites SaaS emails to @example.com.
policyJSON := tf.Input.FullPolicy
pol, err := unmarshalPolicy([]byte(policyJSON))
require.NoError(
t,
err,
"%s: policy should parse successfully\nPolicy:\n%s",
tf.TestID,
tf.Input.FullPolicy,
policyJSON,
)
for nodeName, capture := range tf.Captures {
@@ -308,3 +309,97 @@ func TestSSHDataCompat(t *testing.T) {
})
}
}
// sshErrorMessageMap maps Tailscale SaaS error substrings to headscale
// equivalents where the wording differs but the meaning is the same.
var sshErrorMessageMap = map[string]string{}
// testSSHError verifies that an invalid policy produces the expected error.
func testSSHError(t *testing.T, tf *testcapture.Capture) {
t.Helper()
policyJSON := []byte(tf.Input.FullPolicy)
pol, err := unmarshalPolicy(policyJSON)
if err != nil {
// Parse-time error.
if tf.Input.APIResponseBody != nil {
wantMsg := tf.Input.APIResponseBody.Message
if wantMsg != "" {
assertSSHErrorContains(t, err, wantMsg, tf.TestID)
}
}
return
}
err = pol.validate()
if err != nil {
if tf.Input.APIResponseBody != nil {
wantMsg := tf.Input.APIResponseBody.Message
if wantMsg != "" {
assertSSHErrorContains(t, err, wantMsg, tf.TestID)
}
}
return
}
t.Errorf(
"%s: expected error but policy parsed and validated successfully",
tf.TestID,
)
}
// assertSSHErrorContains checks that an error message matches the
// expected Tailscale SaaS message, using progressive fallbacks:
// 1. Direct substring match
// 2. Mapped equivalent from sshErrorMessageMap
// 3. Key-part extraction (tags, autogroups)
// 4. t.Errorf on no match (strict)
func assertSSHErrorContains(
t *testing.T,
err error,
wantMsg string,
testID string,
) {
t.Helper()
errStr := err.Error()
// 1. Direct substring match.
if strings.Contains(errStr, wantMsg) {
return
}
// 2. Mapped equivalent.
for tsKey, hsKey := range sshErrorMessageMap {
if strings.Contains(wantMsg, tsKey) &&
strings.Contains(errStr, hsKey) {
return
}
}
// 3. Key-part extraction.
for _, part := range []string{
"autogroup:",
"tag:",
"undefined",
"not valid",
} {
if strings.Contains(wantMsg, part) &&
strings.Contains(errStr, part) {
return
}
}
// 4. No match — strict failure.
t.Errorf(
"%s: error message mismatch\n"+
" want (tailscale): %q\n"+
" got (headscale): %q",
testID,
wantMsg,
errStr,
)
}
+2 -105
View File
@@ -7,7 +7,6 @@ import (
"slices"
"strings"
"github.com/go-json-experiment/json"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/util"
"tailscale.com/tailcfg"
@@ -30,15 +29,9 @@ import (
// errors. The Error() prefix is "test(s) failed", the same string Tailscale
// SaaS returns in the api_response_body.message — see
// hscontrol/policy/v2/testdata/policytest_results/.
//
// errSSHPolicyTestsFailed wraps sshTests failures. Tailscale SaaS returns the
// same literal "test(s) failed" body for both ACL tests and SSH tests, but
// the two sentinels are kept as distinct values so callers can use errors.Is
// to tell them apart while still matching the SaaS body byte-for-byte.
var (
errPolicyTestsFailed = errors.New("test(s) failed")
errSSHPolicyTestsFailed = errors.New("test(s) failed")
errTestDestinationNoIP = errors.New("destination resolved to no IP addresses")
errPolicyTestsFailed = errors.New("test(s) failed")
errTestDestinationNoIP = errors.New("destination resolved to no IP addresses")
)
// PolicyTest is one entry in the policy's `tests` block.
@@ -60,102 +53,6 @@ type PolicyTest struct {
Deny []string `json:"deny,omitempty"`
}
// SSHPolicyTest is one entry in the policy's `sshTests` block. Unlike the
// ACL `tests` block, sshTests describe SSH login attempts: a source alias
// connects to each destination host and tries each named login user. The
// accept / deny / check arrays carry usernames, not destinations — every
// listed user is asserted against every entry in dst.
type SSHPolicyTest struct {
// Src is a single source alias (user, group, tag, host, or IP). Same
// shape as PolicyTest.Src — Tailscale only supports one src per entry.
Src Alias `json:"src"`
// Dst lists destination host aliases the test exercises. Tags, hosts,
// and the SSH-compatible autogroups are valid; ports, CIDR ranges, and
// autogroup:internet are rejected at parse time.
Dst SSHTestDestinations `json:"dst"`
// Accept lists SSH login users that must be allowed by an action:accept
// or action:check rule when Src connects to each entry in Dst.
Accept []SSHUser `json:"accept,omitempty"`
// Deny lists SSH login users that must NOT be allowed by any rule when
// Src connects to each entry in Dst.
Deny []SSHUser `json:"deny,omitempty"`
// Check lists SSH login users that must reach every dst via an
// action:check rule specifically (the HoldAndDelegate signal on the
// compiled SSH policy). An action:accept rule alone does not satisfy
// a check assertion — SaaS keeps the two categories distinct so
// policy authors can pin sensitive logins to check rules.
Check []SSHUser `json:"check,omitempty"`
}
// SSHTestDestinations is the list of destination aliases an sshTests entry
// targets. Unmarshalling reuses the same alias parser the rest of the
// policy engine drives so each element lands as a typed Alias; the parse-
// time shape rules in validateSSHTestDestination continue to enforce the
// SSH-specific restrictions (no :port, no CIDR, no autogroup:internet,
// known tag).
type SSHTestDestinations []Alias
// UnmarshalJSON walks the JSON array, dispatching each element through
// AliasEnc so trimming and prefix detection match the rest of the parser.
func (d *SSHTestDestinations) UnmarshalJSON(b []byte) error {
var aliases []AliasEnc
err := json.Unmarshal(b, &aliases, policyJSONOpts...)
if err != nil {
return err
}
*d = make([]Alias, len(aliases))
for i, a := range aliases {
(*d)[i] = a.Alias
}
return nil
}
// UnmarshalJSON drives the typed shape of SSHPolicyTest. The wire format
// is unchanged: src is a JSON string parsed through parseAlias; dst is an
// array of strings handled by SSHTestDestinations; accept/deny/check are
// arrays of strings handled per element by SSHUser.UnmarshalJSON. An
// empty src string lands as a nil Alias so the empty-src case stays a
// validation-time error with the SaaS-aligned ErrSSHTestEmptySrc body
// rather than a raw parser failure.
func (t *SSHPolicyTest) UnmarshalJSON(b []byte) error {
var raw struct {
Src string `json:"src"`
Dst SSHTestDestinations `json:"dst"`
Accept []SSHUser `json:"accept,omitempty"`
Deny []SSHUser `json:"deny,omitempty"`
Check []SSHUser `json:"check,omitempty"`
}
err := json.Unmarshal(b, &raw, policyJSONOpts...)
if err != nil {
return err
}
trimmedSrc := strings.TrimSpace(raw.Src)
if trimmedSrc != "" {
alias, parseErr := parseAlias(trimmedSrc)
if parseErr != nil {
return parseErr
}
t.Src = alias
}
t.Dst = raw.Dst
t.Accept = raw.Accept
t.Deny = raw.Deny
t.Check = raw.Check
return nil
}
// PolicyTestResult is the outcome of a single PolicyTest.
type PolicyTestResult struct {
Src string `json:"src"`
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More