mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-20 15:00:43 +09:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ef18fb010 | |||
| 596ecec1db | |||
| 091c65f067 | |||
| 1661a9122a | |||
| a9a84b3f0a | |||
| 9685a742a6 | |||
| 7794d30263 | |||
| 220a2e31f3 | |||
| 3a0cc30a3a | |||
| 47f851c43b |
+15
-14
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -202,8 +202,6 @@ jobs:
|
||||
- TestAuthWebFlowAuthenticationPingAll
|
||||
- TestAuthWebFlowLogoutAndReloginSameUser
|
||||
- TestAuthWebFlowLogoutAndReloginNewUser
|
||||
- TestPolicyCheckCommand
|
||||
- TestSSHTestsRejectFailingPolicy
|
||||
- TestUserCommand
|
||||
- TestPreAuthKeyCommand
|
||||
- TestPreAuthKeyCommandWithoutExpiry
|
||||
|
||||
@@ -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
-129
@@ -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)
|
||||
@@ -90,74 +57,6 @@ addresses, including those outside the tailnet. This replaces the old behaviour
|
||||
all IPs (see BREAKING below). The name is intentionally scary: accepting traffic from the entire
|
||||
internet is a security-sensitive choice. `autogroup:danger-all` can only be used as a source.
|
||||
|
||||
### Node attributes (`nodeAttrs`)
|
||||
|
||||
ACL policies now accept a `nodeAttrs` block. Each entry hands a list of
|
||||
Tailscale node capabilities to every node matching `target`. The accepted
|
||||
target forms are the same as `acls.src` and `grants.src`: users, groups,
|
||||
tags, hosts, prefixes, `autogroup:member`, `autogroup:tagged`, and `*`.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"randomizeClientPort": true,
|
||||
"nodeAttrs": [
|
||||
{ "target": ["autogroup:tagged"], "attr": ["disable-captive-portal-detection"] },
|
||||
{ "target": ["alice@example.com"], "attr": ["nextdns:abc123"] },
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
Frequently requested capabilities this unlocks include `magicdns-aaaa`,
|
||||
`disable-relay-server`, `disable-captive-portal-detection`,
|
||||
`nextdns:<profile>` / `nextdns:no-device-info`, `randomize-client-port`,
|
||||
and the Taildrive `drive:share` / `drive:access` pair. The set is not
|
||||
limited to these — any string-only cap an operator places in policy
|
||||
reaches clients unchanged.
|
||||
|
||||
`randomizeClientPort` also lands as a top-level policy field that toggles
|
||||
the default for every node, replacing the old server-config knob.
|
||||
|
||||
A new `auto_update.enabled` config option controls the tailnet-wide
|
||||
default for client auto-update. When true, every node's CapMap carries
|
||||
`default-auto-update: [true]` so fresh clients pick up the default
|
||||
unless they make a local opt-in / opt-out choice.
|
||||
|
||||
Policies that use the `funnel` cap, `ipPool` blocks, or
|
||||
`autogroup:admin` / `autogroup:owner` targets are rejected at load —
|
||||
those features depend on machinery headscale does not yet ship.
|
||||
|
||||
[#3251](https://github.com/juanfont/headscale/pull/3251)
|
||||
|
||||
### Taildrive
|
||||
|
||||
Taildrive ([file-sync between
|
||||
nodes](https://tailscale.com/docs/features/taildrive)) is now
|
||||
configurable through policy. Grant `drive:share` to the node that
|
||||
hosts files and `drive:access` to nodes that read or write them; pair
|
||||
with a `tailscale.com/cap/drive` grant to set the per-share access
|
||||
mode:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"nodeAttrs": [
|
||||
{ "target": ["tag:fileserver"], "attr": ["drive:share"] },
|
||||
{ "target": ["autogroup:member"], "attr": ["drive:access"] },
|
||||
],
|
||||
"grants": [
|
||||
{
|
||||
"src": ["autogroup:member"],
|
||||
"dst": ["tag:fileserver"],
|
||||
"app": {
|
||||
"tailscale.com/cap/drive": [{ "shares": ["*"], "access": "rw" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
A wildcard `nodeAttrs` (`"target": ["*"]`) hands the caps to every
|
||||
node when fine-grained control is not needed.
|
||||
|
||||
### Hostname handling (cleanroom rewrite)
|
||||
|
||||
The hostname ingest pipeline has been rewritten to match Tailscale SaaS byte-for-byte.
|
||||
@@ -218,28 +117,6 @@ Examples that previously regressed and now work:
|
||||
- Downgrading to a previous minor version is blocked
|
||||
- Patch version changes within the same minor are always allowed
|
||||
|
||||
#### Configuration
|
||||
|
||||
- The `randomize_client_port` server-config key was removed; the
|
||||
toggle now lives in the policy file as a top-level
|
||||
`randomizeClientPort` field, matching the Tailscale-hosted schema.
|
||||
Headscale refuses to start when the old key is set. Move it to the
|
||||
policy file referenced by `policy.path` (defaults to
|
||||
`/etc/headscale/policy.hujson`):
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"randomizeClientPort": true,
|
||||
}
|
||||
```
|
||||
|
||||
If you do not have a policy file yet, create one with that minimal
|
||||
content and point `policy.path` at it. The default carries over —
|
||||
empty / absent policy means `randomizeClientPort: false`, matching
|
||||
the previous behaviour for operators who never set the key. Per-node
|
||||
opt-in via `nodeAttrs` is also supported and stacks on top of the
|
||||
global default.
|
||||
|
||||
#### CLI
|
||||
|
||||
- `headscale nodes register` is deprecated in favour of `headscale auth register --auth-id <id> --user <user>` [#1850](https://github.com/juanfont/headscale/pull/1850)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -73,4 +73,5 @@ func TestConfigLoading(t *testing.T) {
|
||||
assert.Equal(t, "HTTP-01", viper.GetString("tls_letsencrypt_challenge_type"))
|
||||
assert.Equal(t, fs.FileMode(0o770), util.GetFileMode("unix_socket_permission"))
|
||||
assert.False(t, viper.GetBool("logtail.enabled"))
|
||||
assert.False(t, viper.GetBool("randomize_client_port"))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+16
-21
@@ -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
|
||||
@@ -452,24 +452,19 @@ logtail:
|
||||
# disabled by default. Enabling this will make your clients send logs to Tailscale Inc.
|
||||
enabled: false
|
||||
|
||||
# Enabling this option makes devices prefer a random port for WireGuard traffic over the
|
||||
# default static port 41641. This option is intended as a workaround for some buggy
|
||||
# firewall devices. See https://tailscale.com/docs/integrations/firewalls for more information.
|
||||
randomize_client_port: false
|
||||
|
||||
# Taildrop configuration
|
||||
# Taildrop is the file sharing feature of Tailscale, allowing nodes to
|
||||
# send files to each other.
|
||||
# Taildrop is the file sharing feature of Tailscale, allowing nodes to send files to each other.
|
||||
# https://tailscale.com/docs/features/taildrop
|
||||
taildrop:
|
||||
# Enable or disable Taildrop tailnet-wide. When disabled, headscale
|
||||
# withholds `https://tailscale.com/cap/file-sharing` from every
|
||||
# node's CapMap.
|
||||
# Enable or disable Taildrop for all nodes.
|
||||
# When enabled, nodes can send files to other nodes owned by the same user.
|
||||
# Tagged devices and cross-user transfers are not permitted by Tailscale clients.
|
||||
enabled: true
|
||||
|
||||
# Default node auto-update behaviour. When enabled, every node's
|
||||
# CapMap carries `default-auto-update: [true]` so clients that have
|
||||
# not made a local opt-in / opt-out choice run auto-updates by
|
||||
# default. Setting it back to false flips the default for future
|
||||
# clients; clients that already stored the value locally keep their
|
||||
# choice.
|
||||
auto_update:
|
||||
enabled: false
|
||||
# Advanced performance tuning parameters.
|
||||
# The defaults are carefully chosen and should rarely need adjustment.
|
||||
# Only modify these if you have identified a specific performance issue.
|
||||
|
||||
+3
-3
@@ -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.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||

|
||||
|
||||
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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
[](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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"vendor": {
|
||||
"goModSum": "sha256-IE0n9cSqO4XNX4RN+CGBk9VC46iACiZKDFf/215iivk=",
|
||||
"sri": "sha256-ijEIP9NSomhlWOgsVN7tPvSuvkTiLtnvXvhZmatIDLM="
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/policy"
|
||||
policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/views"
|
||||
@@ -85,9 +84,7 @@ func (b *MapResponseBuilder) WithSelfNode() *MapResponseBuilder {
|
||||
|
||||
return slices.Concat(primaries, nv.ExitRoutes())
|
||||
},
|
||||
b.mapper.cfg,
|
||||
b.mapper.state.NodeCapMap(nv.ID()),
|
||||
)
|
||||
b.mapper.cfg)
|
||||
if err != nil {
|
||||
b.addError(err)
|
||||
return b
|
||||
@@ -161,7 +158,7 @@ func (b *MapResponseBuilder) WithDNSConfig() *MapResponseBuilder {
|
||||
return b
|
||||
}
|
||||
|
||||
b.resp.DNSConfig = generateDNSConfig(b.mapper.cfg, node, b.mapper.state.NodeCapMap(node.ID()))
|
||||
b.resp.DNSConfig = generateDNSConfig(b.mapper.cfg, node)
|
||||
|
||||
return b
|
||||
}
|
||||
@@ -258,37 +255,17 @@ func (b *MapResponseBuilder) buildTailPeers(peers views.Slice[types.NodeView]) (
|
||||
changedViews = peers
|
||||
}
|
||||
|
||||
// Snapshot the per-node policy CapMap once per peer-list build
|
||||
// instead of locking the policy manager per peer. The per-call
|
||||
// path used to take pm.mu N times for an N-peer response.
|
||||
allCapMaps := b.mapper.state.NodeCapMaps()
|
||||
|
||||
// Build tail nodes with per-peer via-aware route function.
|
||||
tailPeers := make([]*tailcfg.Node, 0, changedViews.Len())
|
||||
|
||||
for _, peer := range changedViews.All() {
|
||||
// Pass the peer's policy CapMap as selfPolicyCaps so per-peer
|
||||
// address-shape rules (today: disable-ipv4) apply consistently
|
||||
// in the viewer's netmap. The CapMap merge into tn.CapMap is
|
||||
// overwritten by the PeerCapMap call below; only the address
|
||||
// filtering side-effect inside TailNode survives.
|
||||
tn, err := peer.TailNode(b.capVer, func(_ types.NodeID) []netip.Prefix {
|
||||
return b.mapper.state.RoutesForPeer(node, peer, matchers)
|
||||
}, b.mapper.cfg, allCapMaps[peer.ID()])
|
||||
}, b.mapper.cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// [tailcfg.Node.CapMap] on a peer carries the small set of
|
||||
// caps the Tailscale client reads from the peer view rather
|
||||
// than the self view (suggest-exit-node, dns-subdomain-resolve
|
||||
// — see ipn/ipnlocal/local.go:7534 and node_backend.go:745).
|
||||
// The Tailscale-hosted control plane stamps these only when
|
||||
// the peer satisfies the cap's emission condition; every other
|
||||
// cap stays off the peer view, leaving CapMap empty for most
|
||||
// peers. [policyv2.PeerCapMap] encodes those conditions.
|
||||
tn.CapMap = policyv2.PeerCapMap(peer, allCapMaps[peer.ID()])
|
||||
|
||||
tailPeers = append(tailPeers, tn)
|
||||
}
|
||||
|
||||
|
||||
+18
-135
@@ -7,7 +7,6 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -115,25 +114,9 @@ func generateUserProfiles(
|
||||
return profiles
|
||||
}
|
||||
|
||||
// nextDNSAttrPrefix is the form Tailscale uses for per-node NextDNS profile
|
||||
// selection: an "attr" entry of "nextdns:<profile-id>" overrides the resolver
|
||||
// path, and "nextdns:no-device-info" suppresses the metadata-appending step.
|
||||
// See https://tailscale.com/docs/integrations/nextdns.
|
||||
const (
|
||||
nextDNSAttrPrefix = "nextdns:"
|
||||
nextDNSAttrNoInfo tailcfg.NodeCapability = "nextdns:no-device-info"
|
||||
)
|
||||
|
||||
// nextDNSProfileRE bounds the characters accepted in a `nextdns:<profile>`
|
||||
// suffix. NextDNS profile IDs are short alphanumeric strings; restricting
|
||||
// to that charset prevents a policy author from injecting `?`, `/`, `@`,
|
||||
// or `..` into the resolver URL via a crafted cap name.
|
||||
var nextDNSProfileRE = regexp.MustCompile(`^[A-Za-z0-9._-]{1,64}$`)
|
||||
|
||||
func generateDNSConfig(
|
||||
cfg *types.Config,
|
||||
node types.NodeView,
|
||||
capMap tailcfg.NodeCapMap,
|
||||
) *tailcfg.DNSConfig {
|
||||
if cfg.TailcfgDNSConfig == nil {
|
||||
return nil
|
||||
@@ -141,129 +124,32 @@ func generateDNSConfig(
|
||||
|
||||
dnsConfig := cfg.TailcfgDNSConfig.Clone()
|
||||
|
||||
profile := nextDNSProfileFromCapMap(capMap)
|
||||
if profile != "" {
|
||||
applyNextDNSProfile(dnsConfig.Resolvers, profile)
|
||||
applyNextDNSProfile(dnsConfig.FallbackResolvers, profile)
|
||||
|
||||
for suffix, rs := range dnsConfig.Routes {
|
||||
applyNextDNSProfile(rs, profile)
|
||||
dnsConfig.Routes[suffix] = rs
|
||||
}
|
||||
}
|
||||
|
||||
if _, suppressMetadata := capMap[nextDNSAttrNoInfo]; !suppressMetadata {
|
||||
addNextDNSMetadata(dnsConfig.Resolvers, node)
|
||||
addNextDNSMetadata(dnsConfig.FallbackResolvers, node)
|
||||
|
||||
for suffix, rs := range dnsConfig.Routes {
|
||||
addNextDNSMetadata(rs, node)
|
||||
dnsConfig.Routes[suffix] = rs
|
||||
}
|
||||
}
|
||||
addNextDNSMetadata(dnsConfig.Resolvers, node)
|
||||
|
||||
return dnsConfig
|
||||
}
|
||||
|
||||
// nextDNSProfileFromCapMap returns the policy-selected
|
||||
// `nextdns:<profile>` value on the node, or the empty string when none
|
||||
// is set or the cap is malformed. The reserved
|
||||
// `nextdns:no-device-info` string is not a profile — it controls
|
||||
// metadata appending and is handled separately.
|
||||
// If any nextdns DoH resolvers are present in the list of resolvers it will
|
||||
// take metadata from the node metadata and instruct tailscale to add it
|
||||
// to the requests. This makes it possible to identify from which device the
|
||||
// requests come in the NextDNS dashboard.
|
||||
//
|
||||
// The profile pick is deterministic across reloads: cap keys are
|
||||
// gathered, sorted, and the first valid profile wins. Map iteration
|
||||
// order in Go is randomised, so taking the literal first match would
|
||||
// cause the chosen profile to flip between reloads when a node has
|
||||
// multiple `nextdns:` caps. The profile string is also validated
|
||||
// against [nextDNSProfileRE] so a crafted cap cannot inject path or
|
||||
// query characters into the resolver URL.
|
||||
func nextDNSProfileFromCapMap(capMap tailcfg.NodeCapMap) string {
|
||||
if len(capMap) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
candidates := make([]string, 0, len(capMap))
|
||||
|
||||
for cap := range capMap {
|
||||
if cap == nextDNSAttrNoInfo {
|
||||
continue
|
||||
}
|
||||
|
||||
profile, ok := strings.CutPrefix(string(cap), nextDNSAttrPrefix)
|
||||
if !ok || profile == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if !nextDNSProfileRE.MatchString(profile) {
|
||||
log.Warn().
|
||||
Str("cap", string(cap)).
|
||||
Msg("nextdns profile rejected: must match [A-Za-z0-9._-]{1,64}")
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
candidates = append(candidates, profile)
|
||||
}
|
||||
|
||||
if len(candidates) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
slices.Sort(candidates)
|
||||
|
||||
return candidates[0]
|
||||
}
|
||||
|
||||
// nextDNSDoHHost matches a NextDNS DoH resolver address. The check is
|
||||
// anchored on the host segment so a typo-squatted operator-configured
|
||||
// resolver such as `https://dns.nextdns.io.attacker.example/x` does
|
||||
// not slip through.
|
||||
func nextDNSDoHHost(addr string) bool {
|
||||
return addr == nextDNSDoHPrefix ||
|
||||
strings.HasPrefix(addr, nextDNSDoHPrefix+"/") ||
|
||||
strings.HasPrefix(addr, nextDNSDoHPrefix+"?")
|
||||
}
|
||||
|
||||
// applyNextDNSProfile rewrites every NextDNS DoH resolver to point at
|
||||
// the given profile, dropping any existing profile path or query. Per
|
||||
// the Tailscale spec the per-node profile overrides the global value,
|
||||
// so the rewrite is unconditional rather than additive.
|
||||
func applyNextDNSProfile(resolvers []*dnstype.Resolver, profile string) {
|
||||
for _, resolver := range resolvers {
|
||||
if !nextDNSDoHHost(resolver.Addr) {
|
||||
continue
|
||||
}
|
||||
|
||||
resolver.Addr = nextDNSDoHPrefix + "/" + profile
|
||||
}
|
||||
}
|
||||
|
||||
// addNextDNSMetadata appends device metadata as a query string to
|
||||
// every NextDNS DoH resolver. Existing query parameters on the
|
||||
// resolver address are preserved by parsing the URL and merging into
|
||||
// its [url.URL.RawQuery] rather than concatenating with `?`.
|
||||
// This will produce a resolver like:
|
||||
// `https://dns.nextdns.io/<nextdns-id>?device_name=node-name&device_model=linux&device_ip=100.64.0.1`
|
||||
func addNextDNSMetadata(resolvers []*dnstype.Resolver, node types.NodeView) {
|
||||
for _, resolver := range resolvers {
|
||||
if !nextDNSDoHHost(resolver.Addr) {
|
||||
continue
|
||||
if strings.HasPrefix(resolver.Addr, nextDNSDoHPrefix) {
|
||||
attrs := url.Values{
|
||||
"device_name": []string{node.Hostname()},
|
||||
"device_model": []string{node.Hostinfo().OS()},
|
||||
}
|
||||
|
||||
if len(node.IPs()) > 0 {
|
||||
attrs.Add("device_ip", node.IPs()[0].String())
|
||||
}
|
||||
|
||||
resolver.Addr = fmt.Sprintf("%s?%s", resolver.Addr, attrs.Encode())
|
||||
}
|
||||
|
||||
u, err := url.Parse(resolver.Addr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
q := u.Query()
|
||||
q.Set("device_name", node.Hostname())
|
||||
q.Set("device_model", node.Hostinfo().OS())
|
||||
|
||||
if ips := node.IPs(); len(ips) > 0 {
|
||||
q.Set("device_ip", ips[0].String())
|
||||
}
|
||||
|
||||
u.RawQuery = q.Encode()
|
||||
resolver.Addr = u.String()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,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)
|
||||
}
|
||||
|
||||
|
||||
@@ -68,7 +68,6 @@ func TestDNSConfigMapResponse(t *testing.T) {
|
||||
TailcfgDNSConfig: &dnsConfigOrig,
|
||||
},
|
||||
nodeInShared1.View(),
|
||||
nil,
|
||||
)
|
||||
|
||||
if diff := cmp.Diff(tt.want, got, cmpopts.EquateEmpty()); diff != "" {
|
||||
@@ -77,119 +76,3 @@ func TestDNSConfigMapResponse(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextDNSCapMapRendering(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mkConfig := func(addrs ...string) *types.Config {
|
||||
resolvers := make([]*dnstype.Resolver, len(addrs))
|
||||
for i, a := range addrs {
|
||||
resolvers[i] = &dnstype.Resolver{Addr: a}
|
||||
}
|
||||
|
||||
return &types.Config{
|
||||
TailcfgDNSConfig: &tailcfg.DNSConfig{
|
||||
Resolvers: resolvers,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
mkNode := func() types.NodeView {
|
||||
return (&types.Node{
|
||||
ID: 1,
|
||||
Hostname: "node1",
|
||||
IPv4: iap("100.64.0.1"),
|
||||
Hostinfo: &tailcfg.Hostinfo{OS: "linux"},
|
||||
}).View()
|
||||
}
|
||||
|
||||
// resolverAddr extracts the first resolver's address with a
|
||||
// bounds check. Without it, a regression that drops the
|
||||
// resolver list would nil-panic instead of failing cleanly.
|
||||
resolverAddr := func(t *testing.T, got *tailcfg.DNSConfig) string {
|
||||
t.Helper()
|
||||
|
||||
if got == nil {
|
||||
t.Fatalf("generateDNSConfig returned nil")
|
||||
}
|
||||
|
||||
if len(got.Resolvers) == 0 {
|
||||
t.Fatalf("generateDNSConfig returned no Resolvers")
|
||||
}
|
||||
|
||||
return got.Resolvers[0].Addr
|
||||
}
|
||||
|
||||
t.Run("no_capmap_metadata_appended", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := generateDNSConfig(
|
||||
mkConfig("https://dns.nextdns.io/abc"),
|
||||
mkNode(),
|
||||
nil,
|
||||
)
|
||||
|
||||
want := "https://dns.nextdns.io/abc?device_ip=100.64.0.1&device_model=linux&device_name=node1"
|
||||
if addr := resolverAddr(t, got); addr != want {
|
||||
t.Errorf("addr = %q, want %q", addr, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("profile_overrides_global", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
capMap := tailcfg.NodeCapMap{
|
||||
"nextdns:override": []tailcfg.RawMessage{},
|
||||
}
|
||||
|
||||
got := generateDNSConfig(
|
||||
mkConfig("https://dns.nextdns.io/global"),
|
||||
mkNode(),
|
||||
capMap,
|
||||
)
|
||||
|
||||
want := "https://dns.nextdns.io/override?device_ip=100.64.0.1&device_model=linux&device_name=node1"
|
||||
if addr := resolverAddr(t, got); addr != want {
|
||||
t.Errorf("addr = %q, want %q", addr, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no_device_info_skips_metadata", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
capMap := tailcfg.NodeCapMap{
|
||||
"nextdns:abc": []tailcfg.RawMessage{},
|
||||
"nextdns:no-device-info": []tailcfg.RawMessage{},
|
||||
}
|
||||
|
||||
got := generateDNSConfig(
|
||||
mkConfig("https://dns.nextdns.io/global"),
|
||||
mkNode(),
|
||||
capMap,
|
||||
)
|
||||
|
||||
want := "https://dns.nextdns.io/abc"
|
||||
if addr := resolverAddr(t, got); addr != want {
|
||||
t.Errorf("addr = %q, want %q", addr, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non_nextdns_resolver_untouched", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
capMap := tailcfg.NodeCapMap{
|
||||
"nextdns:abc": []tailcfg.RawMessage{},
|
||||
}
|
||||
|
||||
got := generateDNSConfig(
|
||||
mkConfig("https://dns.example.org/dns-query"),
|
||||
mkNode(),
|
||||
capMap,
|
||||
)
|
||||
|
||||
want := "https://dns.example.org/dns-query"
|
||||
if addr := resolverAddr(t, got); addr != want {
|
||||
t.Errorf("non-nextdns resolver was rewritten: %q", addr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+20
-223
@@ -16,8 +16,6 @@ import (
|
||||
)
|
||||
|
||||
func TestTailNode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mustNK := func(str string) key.NodePublic {
|
||||
var k key.NodePublic
|
||||
|
||||
@@ -53,6 +51,7 @@ func TestTailNode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
node *types.Node
|
||||
pol []byte
|
||||
dnsConfig *tailcfg.DNSConfig
|
||||
baseDomain string
|
||||
want *tailcfg.Node
|
||||
@@ -75,10 +74,11 @@ func TestTailNode(t *testing.T) {
|
||||
MachineAuthorized: true,
|
||||
|
||||
CapMap: tailcfg.NodeCapMap{
|
||||
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
|
||||
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
|
||||
tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{},
|
||||
tailcfg.NodeAttrDefaultAutoUpdate: []tailcfg.RawMessage{tailcfg.RawMessage("false")},
|
||||
tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{},
|
||||
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
|
||||
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
|
||||
tailcfg.NodeAttrsTaildriveShare: []tailcfg.RawMessage{},
|
||||
tailcfg.NodeAttrsTaildriveAccess: []tailcfg.RawMessage{},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
@@ -165,10 +165,11 @@ func TestTailNode(t *testing.T) {
|
||||
MachineAuthorized: true,
|
||||
|
||||
CapMap: tailcfg.NodeCapMap{
|
||||
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
|
||||
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
|
||||
tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{},
|
||||
tailcfg.NodeAttrDefaultAutoUpdate: []tailcfg.RawMessage{tailcfg.RawMessage("false")},
|
||||
tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{},
|
||||
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
|
||||
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
|
||||
tailcfg.NodeAttrsTaildriveShare: []tailcfg.RawMessage{},
|
||||
tailcfg.NodeAttrsTaildriveAccess: []tailcfg.RawMessage{},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
@@ -191,10 +192,11 @@ func TestTailNode(t *testing.T) {
|
||||
MachineAuthorized: true,
|
||||
|
||||
CapMap: tailcfg.NodeCapMap{
|
||||
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
|
||||
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
|
||||
tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{},
|
||||
tailcfg.NodeAttrDefaultAutoUpdate: []tailcfg.RawMessage{tailcfg.RawMessage("false")},
|
||||
tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{},
|
||||
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
|
||||
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
|
||||
tailcfg.NodeAttrsTaildriveShare: []tailcfg.RawMessage{},
|
||||
tailcfg.NodeAttrsTaildriveAccess: []tailcfg.RawMessage{},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
@@ -206,12 +208,11 @@ func TestTailNode(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := &types.Config{
|
||||
BaseDomain: tt.baseDomain,
|
||||
TailcfgDNSConfig: tt.dnsConfig,
|
||||
Taildrop: types.TaildropConfig{Enabled: true},
|
||||
BaseDomain: tt.baseDomain,
|
||||
TailcfgDNSConfig: tt.dnsConfig,
|
||||
RandomizeClientPort: false,
|
||||
Taildrop: types.TaildropConfig{Enabled: true},
|
||||
}
|
||||
|
||||
// Stub primary-route lookup: tt.node owns its SubnetRoutes,
|
||||
@@ -233,7 +234,6 @@ func TestTailNode(t *testing.T) {
|
||||
return slices.Concat(primaries[id], nv.ExitRoutes())
|
||||
},
|
||||
cfg,
|
||||
nil,
|
||||
)
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
@@ -249,208 +249,6 @@ func TestTailNode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestTailNodeBaselineGates focuses on the cfg-driven baseline cap
|
||||
// emission: cfg.Taildrop.Enabled gates [tailcfg.CapabilityFileSharing]
|
||||
// and cfg.AutoUpdate.Enabled controls the value of
|
||||
// [tailcfg.NodeAttrDefaultAutoUpdate]. Admin and SSH are unconditional
|
||||
// baseline.
|
||||
func TestTailNodeBaselineGates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
autoUpdate := func(b bool) []tailcfg.RawMessage {
|
||||
if b {
|
||||
return []tailcfg.RawMessage{tailcfg.RawMessage("true")}
|
||||
}
|
||||
|
||||
return []tailcfg.RawMessage{tailcfg.RawMessage("false")}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg *types.Config
|
||||
want tailcfg.NodeCapMap
|
||||
}{
|
||||
{
|
||||
name: "taildrop_on_autoupdate_off",
|
||||
cfg: &types.Config{
|
||||
Taildrop: types.TaildropConfig{Enabled: true},
|
||||
AutoUpdate: types.AutoUpdateConfig{Enabled: false},
|
||||
},
|
||||
want: tailcfg.NodeCapMap{
|
||||
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
|
||||
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
|
||||
tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{},
|
||||
tailcfg.NodeAttrDefaultAutoUpdate: autoUpdate(false),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "taildrop_off_autoupdate_off",
|
||||
cfg: &types.Config{
|
||||
Taildrop: types.TaildropConfig{Enabled: false},
|
||||
AutoUpdate: types.AutoUpdateConfig{Enabled: false},
|
||||
},
|
||||
want: tailcfg.NodeCapMap{
|
||||
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
|
||||
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
|
||||
tailcfg.NodeAttrDefaultAutoUpdate: autoUpdate(false),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "taildrop_on_autoupdate_on",
|
||||
cfg: &types.Config{
|
||||
Taildrop: types.TaildropConfig{Enabled: true},
|
||||
AutoUpdate: types.AutoUpdateConfig{Enabled: true},
|
||||
},
|
||||
want: tailcfg.NodeCapMap{
|
||||
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
|
||||
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
|
||||
tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{},
|
||||
tailcfg.NodeAttrDefaultAutoUpdate: autoUpdate(true),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "taildrop_off_autoupdate_on",
|
||||
cfg: &types.Config{
|
||||
Taildrop: types.TaildropConfig{Enabled: false},
|
||||
AutoUpdate: types.AutoUpdateConfig{Enabled: true},
|
||||
},
|
||||
want: tailcfg.NodeCapMap{
|
||||
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
|
||||
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
|
||||
tailcfg.NodeAttrDefaultAutoUpdate: autoUpdate(true),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
node := &types.Node{GivenName: "baseline-node", Hostinfo: &tailcfg.Hostinfo{}}
|
||||
|
||||
got, err := node.View().TailNode(
|
||||
0,
|
||||
func(types.NodeID) []netip.Prefix { return nil },
|
||||
tt.cfg,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("TailNode: %v", err)
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(tt.want, got.CapMap, cmpopts.EquateEmpty()); diff != "" {
|
||||
t.Errorf("CapMap mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTailNodeDisableIPv4 asserts that a node with the disable-ipv4
|
||||
// nodeAttr has its own IPv4 (the CGNAT /32) stripped from Addresses
|
||||
// and AllowedIPs, while subnet routes the node advertises -- even
|
||||
// IPv4 ones -- remain in AllowedIPs and PrimaryRoutes. Matches the
|
||||
// SaaS behaviour captured in
|
||||
// hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-attr-c1{5,6}-disable-ipv4*.hujson.
|
||||
func TestTailNodeDisableIPv4(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const NodeAttrDisableIPv4 tailcfg.NodeCapability = "disable-ipv4"
|
||||
|
||||
v4 := iap("100.64.0.1")
|
||||
v6Addr := netip.MustParseAddr("fd7a:115c:a1e0::1")
|
||||
v6 := &v6Addr
|
||||
subnet := netip.MustParsePrefix("10.33.0.0/16")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
hasCap bool
|
||||
approved []netip.Prefix
|
||||
wantAllowed []netip.Prefix
|
||||
wantPrimary []netip.Prefix
|
||||
wantAddrs []netip.Prefix
|
||||
}{
|
||||
{
|
||||
name: "no-cap_emits_both_families",
|
||||
hasCap: false,
|
||||
wantAllowed: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32"), netip.MustParsePrefix("fd7a:115c:a1e0::1/128")},
|
||||
wantAddrs: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32"), netip.MustParsePrefix("fd7a:115c:a1e0::1/128")},
|
||||
},
|
||||
{
|
||||
name: "cap_strips_own_ipv4",
|
||||
hasCap: true,
|
||||
wantAllowed: []netip.Prefix{netip.MustParsePrefix("fd7a:115c:a1e0::1/128")},
|
||||
wantAddrs: []netip.Prefix{netip.MustParsePrefix("fd7a:115c:a1e0::1/128")},
|
||||
},
|
||||
{
|
||||
name: "cap_keeps_advertised_subnet_route",
|
||||
hasCap: true,
|
||||
approved: []netip.Prefix{subnet},
|
||||
// AllowedIPs is sorted by netip.Prefix.Compare so IPv4
|
||||
// sorts before IPv6.
|
||||
wantAllowed: []netip.Prefix{
|
||||
subnet,
|
||||
netip.MustParsePrefix("fd7a:115c:a1e0::1/128"),
|
||||
},
|
||||
wantPrimary: []netip.Prefix{subnet},
|
||||
wantAddrs: []netip.Prefix{netip.MustParsePrefix("fd7a:115c:a1e0::1/128")},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
node := &types.Node{
|
||||
GivenName: "ipv4-disabled-node",
|
||||
IPv4: v4,
|
||||
IPv6: v6,
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
RoutableIPs: tt.approved,
|
||||
},
|
||||
ApprovedRoutes: tt.approved,
|
||||
}
|
||||
|
||||
var selfCaps tailcfg.NodeCapMap
|
||||
if tt.hasCap {
|
||||
selfCaps = tailcfg.NodeCapMap{NodeAttrDisableIPv4: nil}
|
||||
}
|
||||
|
||||
got, err := node.View().TailNode(
|
||||
0,
|
||||
func(types.NodeID) []netip.Prefix {
|
||||
return tt.approved
|
||||
},
|
||||
&types.Config{Taildrop: types.TaildropConfig{Enabled: true}},
|
||||
selfCaps,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("TailNode: %v", err)
|
||||
}
|
||||
|
||||
prefStrings := func(ps []netip.Prefix) []string {
|
||||
out := make([]string, len(ps))
|
||||
for i, p := range ps {
|
||||
out[i] = p.String()
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(prefStrings(tt.wantAddrs), prefStrings(got.Addresses), cmpopts.EquateEmpty()); diff != "" {
|
||||
t.Errorf("Addresses (-want +got):\n%s", diff)
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(prefStrings(tt.wantAllowed), prefStrings(got.AllowedIPs), cmpopts.EquateEmpty()); diff != "" {
|
||||
t.Errorf("AllowedIPs (-want +got):\n%s", diff)
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(prefStrings(tt.wantPrimary), prefStrings(got.PrimaryRoutes), cmpopts.EquateEmpty()); diff != "" {
|
||||
t.Errorf("PrimaryRoutes (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeExpiry(t *testing.T) {
|
||||
tp := func(t time.Time) *time.Time {
|
||||
return &t
|
||||
@@ -492,7 +290,6 @@ func TestNodeExpiry(t *testing.T) {
|
||||
return []netip.Prefix{}
|
||||
},
|
||||
&types.Config{Taildrop: types.TaildropConfig{Enabled: true}},
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("nodeExpiry() error = %v", err)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -42,28 +42,6 @@ type PolicyManager interface {
|
||||
// both fields are empty and the caller falls back to existing behavior.
|
||||
ViaRoutesForPeer(viewer, peer types.NodeView) types.ViaRouteResult
|
||||
|
||||
// NodeCapMap returns the policy-derived CapMap for the given node,
|
||||
// or nil when no nodeAttrs entry targets it. The returned map is
|
||||
// owned by the manager; treat it as read-only and copy before
|
||||
// merging into a [tailcfg.Node]. It describes the node's own
|
||||
// capabilities, not a per-viewer view.
|
||||
NodeCapMap(id types.NodeID) tailcfg.NodeCapMap
|
||||
|
||||
// NodeCapMaps returns a snapshot of the per-node policy CapMap so
|
||||
// callers can amortise lock acquisitions over a peer loop. The
|
||||
// outer map is a fresh container; the inner [tailcfg.NodeCapMap]
|
||||
// values are shared with the manager and read-only.
|
||||
NodeCapMaps() map[types.NodeID]tailcfg.NodeCapMap
|
||||
|
||||
// NodesWithChangedCapMap returns the IDs of nodes whose nodeAttrs
|
||||
// CapMap shifted during recent updateLocked calls. The buffer
|
||||
// drains on read; callers consume it once per update cycle to
|
||||
// decide which nodes need a self-targeted MapResponse.
|
||||
// refreshNodeAttrsLocked appends to the buffer rather than
|
||||
// overwriting, so a SetUsers/SetNodes between SetPolicy and the
|
||||
// drain cannot lose the policy-reload diff.
|
||||
NodesWithChangedCapMap() []types.NodeID
|
||||
|
||||
Version() int
|
||||
DebugString() string
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"slices"
|
||||
|
||||
@@ -95,92 +94,6 @@ func buildUserNodeIndex(
|
||||
return idx
|
||||
}
|
||||
|
||||
// compileNodeAttrs returns the per-node CapMap derived from policy
|
||||
// nodeAttrs plus the tailnet-wide RandomizeClientPort flag.
|
||||
//
|
||||
// Returns an error when a target alias fails to resolve so the caller
|
||||
// surfaces a corrupt policy instead of silently granting a partial set
|
||||
// of attrs.
|
||||
func (pol *Policy) compileNodeAttrs(
|
||||
users types.Users,
|
||||
nodes views.Slice[types.NodeView],
|
||||
) (map[types.NodeID]tailcfg.NodeCapMap, error) {
|
||||
empty := map[types.NodeID]tailcfg.NodeCapMap{}
|
||||
|
||||
if pol == nil {
|
||||
return empty, nil
|
||||
}
|
||||
|
||||
if len(pol.NodeAttrs) == 0 && !pol.RandomizeClientPort {
|
||||
return empty, nil
|
||||
}
|
||||
|
||||
result := make(map[types.NodeID]tailcfg.NodeCapMap)
|
||||
stamp := func(id types.NodeID, attr tailcfg.NodeCapability) {
|
||||
capMap, ok := result[id]
|
||||
if !ok {
|
||||
capMap = tailcfg.NodeCapMap{}
|
||||
result[id] = capMap
|
||||
}
|
||||
|
||||
// nil RawMessage matches the wire format from a Tailscale-hosted
|
||||
// control plane: capabilities without companion data marshal as
|
||||
// `null` rather than `[]`. Storing nil keeps the merge stable
|
||||
// and lets the compat test diff cleanly against captured
|
||||
// netmaps.
|
||||
if _, exists := capMap[attr]; !exists {
|
||||
capMap[attr] = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Cache each node's IPs once per call. Without the cache, the
|
||||
// node-attr inner loop would call NodeView.IPs() once per attr
|
||||
// per node — O(grants × nodes) allocations of a 2-element slice
|
||||
// for what is invariant per node within a single policy compile.
|
||||
type nodeIPs struct {
|
||||
id types.NodeID
|
||||
ips []netip.Addr
|
||||
}
|
||||
|
||||
nodeList := make([]nodeIPs, 0, nodes.Len())
|
||||
for _, n := range nodes.All() {
|
||||
nodeList = append(nodeList, nodeIPs{id: n.ID(), ips: n.IPs()})
|
||||
}
|
||||
|
||||
if pol.RandomizeClientPort {
|
||||
for _, ni := range nodeList {
|
||||
stamp(ni.id, tailcfg.NodeAttrRandomizeClientPort)
|
||||
}
|
||||
}
|
||||
|
||||
for _, na := range pol.NodeAttrs {
|
||||
if len(na.Attrs) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
resolved, err := na.Targets.Resolve(pol, users, nodes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("nodeAttrs target %s: %w", na.Targets, err)
|
||||
}
|
||||
|
||||
if resolved == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, ni := range nodeList {
|
||||
if !slices.ContainsFunc(ni.ips, resolved.Contains) {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, attr := range na.Attrs {
|
||||
stamp(ni.id, attr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// compileGrants resolves all policy grants into compiledGrant structs.
|
||||
// Source resolution and non-self destination resolution happens once
|
||||
// here. This is the single resolution path that replaces the
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Tests pinned against captures for juanfont/headscale#3212.
|
||||
// Tests pinned against tscap captures for juanfont/headscale#3212.
|
||||
//
|
||||
// The captures were taken on 2026-04-28 against a live Tailscale SaaS
|
||||
// tailnet. They reproduce the literal #3212 setup: an ACL granting
|
||||
|
||||
@@ -1,357 +0,0 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
// nodeAttrsTestUsers returns a minimal user set: two passkey-style users on
|
||||
// different domains, mirroring the production multi-domain shape so user-target
|
||||
// resolution is exercised across both.
|
||||
func nodeAttrsTestUsers() types.Users {
|
||||
return types.Users{
|
||||
{Model: gorm.Model{ID: 1}, Name: "alice", Email: "alice@example.com"},
|
||||
{Model: gorm.Model{ID: 2}, Name: "bob", Email: "bob@example.org"},
|
||||
}
|
||||
}
|
||||
|
||||
// nodeAttrsTestNodes returns a fixed mix of user-owned and tagged nodes used
|
||||
// by every nodeAttrs unit test. Two user-owned nodes (one per user) and three
|
||||
// tagged nodes (server, client, prod) so target resolution can be exercised
|
||||
// across user, group, tag, autogroup, and wildcard alias forms.
|
||||
func nodeAttrsTestNodes(users types.Users) types.Nodes {
|
||||
return types.Nodes{
|
||||
{
|
||||
ID: 1,
|
||||
GivenName: "alice-laptop",
|
||||
User: &users[0],
|
||||
UserID: &users[0].ID,
|
||||
IPv4: ptrAddr("100.64.0.1"),
|
||||
IPv6: ptrAddr("fd7a:115c:a1e0::1"),
|
||||
Hostinfo: &tailcfg.Hostinfo{},
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
GivenName: "bob-laptop",
|
||||
User: &users[1],
|
||||
UserID: &users[1].ID,
|
||||
IPv4: ptrAddr("100.64.0.2"),
|
||||
IPv6: ptrAddr("fd7a:115c:a1e0::2"),
|
||||
Hostinfo: &tailcfg.Hostinfo{},
|
||||
},
|
||||
{
|
||||
ID: 3,
|
||||
GivenName: "server",
|
||||
Tags: []string{"tag:server"},
|
||||
IPv4: ptrAddr("100.64.0.3"),
|
||||
IPv6: ptrAddr("fd7a:115c:a1e0::3"),
|
||||
Hostinfo: &tailcfg.Hostinfo{},
|
||||
},
|
||||
{
|
||||
ID: 4,
|
||||
GivenName: "client",
|
||||
Tags: []string{"tag:client"},
|
||||
IPv4: ptrAddr("100.64.0.4"),
|
||||
IPv6: ptrAddr("fd7a:115c:a1e0::4"),
|
||||
Hostinfo: &tailcfg.Hostinfo{},
|
||||
},
|
||||
{
|
||||
ID: 5,
|
||||
GivenName: "prod",
|
||||
Tags: []string{"tag:prod"},
|
||||
IPv4: ptrAddr("100.64.0.5"),
|
||||
IPv6: ptrAddr("fd7a:115c:a1e0::5"),
|
||||
Hostinfo: &tailcfg.Hostinfo{},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const nodeAttrsTagOwners = `"tag:server": ["alice@example.com"],
|
||||
"tag:client": ["alice@example.com"],
|
||||
"tag:prod": ["alice@example.com"]`
|
||||
|
||||
func TestNodeAttrsCompile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
capMap := func(c tailcfg.NodeCapability) tailcfg.NodeCapMap {
|
||||
return tailcfg.NodeCapMap{c: nil}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
// extra is appended inside the policy block alongside tagOwners.
|
||||
extra string
|
||||
want map[types.NodeID]tailcfg.NodeCapMap
|
||||
}{
|
||||
{
|
||||
name: "wildcard target hits every node",
|
||||
extra: `"nodeAttrs": [{"target": ["*"], "attr": ["randomize-client-port"]}]`,
|
||||
want: map[types.NodeID]tailcfg.NodeCapMap{
|
||||
1: capMap(tailcfg.NodeAttrRandomizeClientPort),
|
||||
2: capMap(tailcfg.NodeAttrRandomizeClientPort),
|
||||
3: capMap(tailcfg.NodeAttrRandomizeClientPort),
|
||||
4: capMap(tailcfg.NodeAttrRandomizeClientPort),
|
||||
5: capMap(tailcfg.NodeAttrRandomizeClientPort),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "user target hits only that user's untagged nodes",
|
||||
extra: `"nodeAttrs": [{"target": ["alice@example.com"], "attr": ["randomize-client-port"]}]`,
|
||||
want: map[types.NodeID]tailcfg.NodeCapMap{
|
||||
1: capMap(tailcfg.NodeAttrRandomizeClientPort),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tag target hits only matching tagged nodes",
|
||||
extra: `"nodeAttrs": [{"target": ["tag:server"], "attr": ["drive:share", "drive:access"]}]`,
|
||||
want: map[types.NodeID]tailcfg.NodeCapMap{
|
||||
3: {
|
||||
tailcfg.NodeAttrsTaildriveShare: nil,
|
||||
tailcfg.NodeAttrsTaildriveAccess: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "autogroup:member hits untagged nodes only",
|
||||
extra: `"nodeAttrs": [{"target": ["autogroup:member"], "attr": ["randomize-client-port"]}]`,
|
||||
want: map[types.NodeID]tailcfg.NodeCapMap{
|
||||
1: capMap(tailcfg.NodeAttrRandomizeClientPort),
|
||||
2: capMap(tailcfg.NodeAttrRandomizeClientPort),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "autogroup:tagged hits tagged nodes only",
|
||||
extra: `"nodeAttrs": [{"target": ["autogroup:tagged"], "attr": ["disable-captive-portal-detection"]}]`,
|
||||
want: map[types.NodeID]tailcfg.NodeCapMap{
|
||||
3: capMap(tailcfg.NodeAttrDisableCaptivePortalDetection),
|
||||
4: capMap(tailcfg.NodeAttrDisableCaptivePortalDetection),
|
||||
5: capMap(tailcfg.NodeAttrDisableCaptivePortalDetection),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "merging two grants on overlapping targets unions attrs",
|
||||
extra: `"nodeAttrs": [
|
||||
{"target": ["*"], "attr": ["drive:access"]},
|
||||
{"target": ["tag:server"], "attr": ["drive:share"]}
|
||||
]`,
|
||||
want: map[types.NodeID]tailcfg.NodeCapMap{
|
||||
1: capMap(tailcfg.NodeAttrsTaildriveAccess),
|
||||
2: capMap(tailcfg.NodeAttrsTaildriveAccess),
|
||||
3: {
|
||||
tailcfg.NodeAttrsTaildriveAccess: nil,
|
||||
tailcfg.NodeAttrsTaildriveShare: nil,
|
||||
},
|
||||
4: capMap(tailcfg.NodeAttrsTaildriveAccess),
|
||||
5: capMap(tailcfg.NodeAttrsTaildriveAccess),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty entry compiles to nothing",
|
||||
extra: `"nodeAttrs": [{"target": ["*"]}]`,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "top-level randomizeClientPort stamps every node",
|
||||
extra: `"randomizeClientPort": true`,
|
||||
want: map[types.NodeID]tailcfg.NodeCapMap{
|
||||
1: capMap(tailcfg.NodeAttrRandomizeClientPort),
|
||||
2: capMap(tailcfg.NodeAttrRandomizeClientPort),
|
||||
3: capMap(tailcfg.NodeAttrRandomizeClientPort),
|
||||
4: capMap(tailcfg.NodeAttrRandomizeClientPort),
|
||||
5: capMap(tailcfg.NodeAttrRandomizeClientPort),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "global randomize plus per-tag entry merges",
|
||||
extra: `"randomizeClientPort": true,
|
||||
"nodeAttrs": [{"target": ["tag:server"], "attr": ["disable-captive-portal-detection"]}]`,
|
||||
want: map[types.NodeID]tailcfg.NodeCapMap{
|
||||
1: capMap(tailcfg.NodeAttrRandomizeClientPort),
|
||||
2: capMap(tailcfg.NodeAttrRandomizeClientPort),
|
||||
3: {
|
||||
tailcfg.NodeAttrRandomizeClientPort: nil,
|
||||
tailcfg.NodeAttrDisableCaptivePortalDetection: nil,
|
||||
},
|
||||
4: capMap(tailcfg.NodeAttrRandomizeClientPort),
|
||||
5: capMap(tailcfg.NodeAttrRandomizeClientPort),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
users := nodeAttrsTestUsers()
|
||||
nodes := nodeAttrsTestNodes(users)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
policy := `{
|
||||
"tagOwners": {` + nodeAttrsTagOwners + `},
|
||||
` + tt.extra + `
|
||||
}`
|
||||
|
||||
pm, err := NewPolicyManager([]byte(policy), users, nodes.ViewSlice())
|
||||
require.NoErrorf(t, err, "policy must parse and validate:\n%s", policy)
|
||||
|
||||
got, err := pm.pol.compileNodeAttrs(users, pm.nodes)
|
||||
require.NoError(t, err)
|
||||
|
||||
if diff := cmp.Diff(tt.want, got, cmpopts.EquateEmpty()); diff != "" {
|
||||
t.Errorf("compileNodeAttrs (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeAttrsValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
extra string
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "autogroup:self target rejected",
|
||||
extra: `"nodeAttrs": [{"target": ["autogroup:self"], "attr": ["randomize-client-port"]}]`,
|
||||
wantErr: ErrNodeAttrsAutogroupNotAllowed,
|
||||
},
|
||||
{
|
||||
name: "autogroup:admin target rejected with user-role hint",
|
||||
extra: `"nodeAttrs": [{"target": ["autogroup:admin"], "attr": ["randomize-client-port"]}]`,
|
||||
wantErr: ErrNodeAttrsAutogroupNotAllowed,
|
||||
},
|
||||
{
|
||||
name: "autogroup:owner target rejected with user-role hint",
|
||||
extra: `"nodeAttrs": [{"target": ["autogroup:owner"], "attr": ["randomize-client-port"]}]`,
|
||||
wantErr: ErrNodeAttrsAutogroupNotAllowed,
|
||||
},
|
||||
{
|
||||
name: "funnel attr rejected as unsupported",
|
||||
extra: `"nodeAttrs": [{"target": ["*"], "attr": ["funnel"]}]`,
|
||||
wantErr: ErrNodeAttrUnsupported,
|
||||
},
|
||||
{
|
||||
name: "ipPool set rejected as unsupported",
|
||||
extra: `"nodeAttrs": [{"target": ["autogroup:member"], "ipPool": ["100.81.0.0/16"]}]`,
|
||||
wantErr: ErrNodeAttrIPPoolUnsupported,
|
||||
},
|
||||
{
|
||||
name: "ipPool overlapping reserved range rejected at validate",
|
||||
extra: `"nodeAttrs": [{"target": ["autogroup:member"], "ipPool": ["100.100.100.0/24"]}]`,
|
||||
wantErr: ErrNodeAttrsIPPoolReserved,
|
||||
},
|
||||
}
|
||||
|
||||
users := nodeAttrsTestUsers()
|
||||
nodes := nodeAttrsTestNodes(users)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
policy := `{
|
||||
"tagOwners": {` + nodeAttrsTagOwners + `},
|
||||
` + tt.extra + `
|
||||
}`
|
||||
|
||||
_, err := NewPolicyManager([]byte(policy), users, nodes.ViewSlice())
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, tt.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeAttrsIPPoolValidator(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
prefix string
|
||||
wantErr error
|
||||
}{
|
||||
{name: "in cgnat", prefix: "100.81.0.0/16"},
|
||||
{name: "outside cgnat", prefix: "10.0.0.0/8", wantErr: ErrNodeAttrsIPPoolOutOfRange},
|
||||
{name: "less specific than cgnat", prefix: "100.0.0.0/8", wantErr: ErrNodeAttrsIPPoolOutOfRange},
|
||||
{name: "whole cgnat overlaps reserved", prefix: "100.64.0.0/10", wantErr: ErrNodeAttrsIPPoolReserved},
|
||||
{name: "overlaps quad100", prefix: "100.100.100.0/24", wantErr: ErrNodeAttrsIPPoolReserved},
|
||||
{name: "overlaps ipn", prefix: "100.115.92.0/24", wantErr: ErrNodeAttrsIPPoolReserved},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := validateNodeAttrIPPool(netip.MustParsePrefix(tt.prefix))
|
||||
if tt.wantErr != nil {
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, tt.wantErr)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodesWithChangedCapMap(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
users := nodeAttrsTestUsers()
|
||||
nodes := nodeAttrsTestNodes(users)
|
||||
|
||||
policyA := `{
|
||||
"tagOwners": {` + nodeAttrsTagOwners + `},
|
||||
"nodeAttrs": [{
|
||||
"target": ["tag:server"],
|
||||
"attr": ["randomize-client-port"]
|
||||
}]
|
||||
}`
|
||||
|
||||
pm, err := NewPolicyManager([]byte(policyA), users, nodes.ViewSlice())
|
||||
require.NoError(t, err)
|
||||
|
||||
initial := pm.NodesWithChangedCapMap()
|
||||
slices.Sort(initial)
|
||||
assert.Equal(t, []types.NodeID{3}, initial,
|
||||
"first build reports every node with a non-empty CapMap")
|
||||
|
||||
// Swap targets: server loses the attr, client and prod gain it.
|
||||
policyB := `{
|
||||
"tagOwners": {` + nodeAttrsTagOwners + `},
|
||||
"nodeAttrs": [{
|
||||
"target": ["tag:client", "tag:prod"],
|
||||
"attr": ["randomize-client-port"]
|
||||
}]
|
||||
}`
|
||||
|
||||
changed, err := pm.SetPolicy([]byte(policyB))
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
|
||||
delta := pm.NodesWithChangedCapMap()
|
||||
slices.Sort(delta)
|
||||
assert.Equal(t, []types.NodeID{3, 4, 5}, delta,
|
||||
"server lost the cap, client and prod gained it -- diff is the symmetric difference")
|
||||
|
||||
assert.Empty(t, pm.NodesWithChangedCapMap(),
|
||||
"NodesWithChangedCapMap drains its buffer on read")
|
||||
|
||||
// Reload the same bytes. updateLocked still runs, but no node's
|
||||
// CapMap hash should change.
|
||||
_, err = pm.SetPolicy([]byte(policyB))
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Empty(t, pm.NodesWithChangedCapMap(),
|
||||
"reloading the same policy must not produce CapMap diffs")
|
||||
}
|
||||
+22
-178
@@ -5,7 +5,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -65,16 +64,6 @@ type PolicyManager struct {
|
||||
// needsPerNodeFilter is true when any compiled grant requires
|
||||
// per-node work (autogroup:self or via grants).
|
||||
needsPerNodeFilter bool
|
||||
|
||||
// nodeAttrsMap is the per-node CapMap compiled from policy.NodeAttrs.
|
||||
// nodeAttrsHashes shadow it for change detection between updateLocked
|
||||
// runs. nodeAttrsChanged accumulates the union of all per-call diffs
|
||||
// since the last drain — refresh APPENDS, never overwrites, so a
|
||||
// concurrent SetUsers/SetNodes between SetPolicy and the drain
|
||||
// cannot silently lose the policy-reload diff.
|
||||
nodeAttrsMap map[types.NodeID]tailcfg.NodeCapMap
|
||||
nodeAttrsHashes map[types.NodeID]deephash.Sum
|
||||
nodeAttrsChanged []types.NodeID
|
||||
}
|
||||
|
||||
// filterAndPolicy combines the compiled filter rules with policy content for hashing.
|
||||
@@ -198,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
|
||||
}
|
||||
|
||||
@@ -313,16 +288,6 @@ func (pm *PolicyManager) updateLocked() (bool, error) {
|
||||
pm.exitSet = exitSet
|
||||
pm.exitSetHash = exitSetHash
|
||||
|
||||
// Recompile per-node nodeAttrs CapMap and append the diff to
|
||||
// pm.nodeAttrsChanged. The drain (NodesWithChangedCapMap) returns
|
||||
// the accumulated union of every change since the last drain;
|
||||
// SetUsers/SetNodes appending between SetPolicy and the drain
|
||||
// cannot lose the policy-reload diff.
|
||||
err = pm.refreshNodeAttrsLocked()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Determine if we need to send updates to nodes
|
||||
// filterChanged now includes policy content changes (via combined hash),
|
||||
// so it will detect changes even for autogroup:self where compiled filter is empty
|
||||
@@ -486,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
|
||||
@@ -505,7 +463,6 @@ func (pm *PolicyManager) SetPolicy(polB []byte) (bool, error) {
|
||||
Int("groups.count", len(pol.Groups)).
|
||||
Int("hosts.count", len(pol.Hosts)).
|
||||
Int("tagOwners.count", len(pol.TagOwners)).
|
||||
Int("nodeAttrs.count", len(pol.NodeAttrs)).
|
||||
Int("autoApprovers.routes.count", len(pol.AutoApprovers.Routes)).
|
||||
Int("tests.count", len(pol.Tests)).
|
||||
Msg("Policy parsed successfully")
|
||||
@@ -1478,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
|
||||
@@ -1595,125 +1561,3 @@ func resolveTagOwners(p *Policy, users types.Users, nodes views.Slice[types.Node
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
// refreshNodeAttrsLocked recompiles the per-node nodeAttrs CapMap and
|
||||
// appends the IDs whose CapMap differs from the previous snapshot
|
||||
// (including newly-targeted nodes and nodes that lost all attrs) to
|
||||
// pm.nodeAttrsChanged. Append, not overwrite: a concurrent
|
||||
// SetUsers/SetNodes between SetPolicy and a NodesWithChangedCapMap
|
||||
// drain cannot clobber the policy-reload diff.
|
||||
//
|
||||
// Caller must hold pm.mu.
|
||||
func (pm *PolicyManager) refreshNodeAttrsLocked() error {
|
||||
// Fast path for the common steady-state shape: tailnet has no
|
||||
// nodeAttrs entries and never had any. Skip the compile + per-node
|
||||
// hash walk entirely. As soon as the operator adds a nodeAttrs
|
||||
// entry pm.nodeAttrsHashes becomes non-empty and the gate opens.
|
||||
if pm.pol != nil &&
|
||||
len(pm.pol.NodeAttrs) == 0 &&
|
||||
!pm.pol.RandomizeClientPort &&
|
||||
len(pm.nodeAttrsHashes) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
newMap, err := pm.pol.compileNodeAttrs(pm.users, pm.nodes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compiling nodeAttrs: %w", err)
|
||||
}
|
||||
|
||||
newHashes := make(map[types.NodeID]deephash.Sum, len(newMap))
|
||||
for id, capMap := range newMap {
|
||||
newHashes[id] = deephash.Hash(&capMap)
|
||||
}
|
||||
|
||||
// Walk the union of old and new node IDs and emit the delta.
|
||||
seen := make(map[types.NodeID]struct{}, len(newHashes)+len(pm.nodeAttrsHashes))
|
||||
|
||||
var changed []types.NodeID
|
||||
|
||||
for id, h := range newHashes {
|
||||
seen[id] = struct{}{}
|
||||
if pm.nodeAttrsHashes[id] != h {
|
||||
changed = append(changed, id)
|
||||
}
|
||||
}
|
||||
|
||||
for id := range pm.nodeAttrsHashes {
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
// Node lost all nodeAttrs since the last update.
|
||||
changed = append(changed, id)
|
||||
}
|
||||
|
||||
pm.nodeAttrsMap = newMap
|
||||
pm.nodeAttrsHashes = newHashes
|
||||
pm.nodeAttrsChanged = append(pm.nodeAttrsChanged, changed...)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NodeCapMap returns the policy-derived CapMap for the given node, or
|
||||
// nil when the node has no nodeAttrs entries that target it. The
|
||||
// returned map is a defensive clone — caller mutations cannot reach
|
||||
// the manager-owned cache.
|
||||
func (pm *PolicyManager) NodeCapMap(id types.NodeID) tailcfg.NodeCapMap {
|
||||
if pm == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
|
||||
src := pm.nodeAttrsMap[id]
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make(tailcfg.NodeCapMap, len(src))
|
||||
maps.Copy(out, src)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// NodeCapMaps returns a snapshot of the per-node policy CapMap. The
|
||||
// mapper calls this once per request to amortise lock acquisitions
|
||||
// over a peer-loop instead of taking the lock per peer. The returned
|
||||
// map is a fresh container; the inner [tailcfg.NodeCapMap] values are
|
||||
// shared with the manager and must be treated as read-only.
|
||||
func (pm *PolicyManager) NodeCapMaps() map[types.NodeID]tailcfg.NodeCapMap {
|
||||
if pm == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
|
||||
out := make(map[types.NodeID]tailcfg.NodeCapMap, len(pm.nodeAttrsMap))
|
||||
maps.Copy(out, pm.nodeAttrsMap)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// NodesWithChangedCapMap returns the IDs of nodes whose nodeAttrs
|
||||
// CapMap shifted across one or more updateLocked calls since the
|
||||
// last drain. The buffer drains on return. The mapper calls this
|
||||
// once per ReloadPolicy to decide which nodes need a SelfUpdate.
|
||||
//
|
||||
// refreshNodeAttrsLocked APPENDS to the buffer; the drain returns
|
||||
// the union of every change since the previous read. A concurrent
|
||||
// SetUsers/SetNodes between SetPolicy and a drain cannot silently
|
||||
// lose the policy-reload diff.
|
||||
func (pm *PolicyManager) NodesWithChangedCapMap() []types.NodeID {
|
||||
if pm == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
|
||||
out := pm.nodeAttrsChanged
|
||||
pm.nodeAttrsChanged = nil
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -1820,8 +1820,8 @@ func TestViaRoutesForPeer(t *testing.T) {
|
||||
// `autogroup:internet` must keep the exit node visible to the source
|
||||
// in BuildPeerMap so the Tailscale client surfaces it in
|
||||
// `tailscale exit-node list`. Authoritative SaaS captures
|
||||
// (routes-b17/b18, 2026-04-28) confirm SaaS includes the exit node
|
||||
// in the source's Peers with 0.0.0.0/0 and ::/0 in AllowedIPs.
|
||||
// (tscap routes-b17/b18, 2026-04-28) confirm SaaS includes the exit
|
||||
// node in the source's Peers with 0.0.0.0/0 and ::/0 in AllowedIPs.
|
||||
func TestBuildPeerMap_AutogroupInternetMakesExitNodeVisible(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
package v2
|
||||
|
||||
// This file enumerates [tailcfg.NodeCapability] values that the
|
||||
// Tailscale-hosted control plane emits where headscale has no
|
||||
// equivalent concept yet. The compat test in
|
||||
// tailscale_nodeattrs_compat_test.go builds the self-view CapMap via
|
||||
// [types.NodeView.TailNode] -- the same call the mapper makes -- and
|
||||
// strips these from BOTH sides before [cmp.Diff]; every other cap is
|
||||
// compared in full as it lands on the wire.
|
||||
//
|
||||
// Each entry documents its purpose (cross-referenced to Tailscale
|
||||
// source), why headscale does not emit it, and a tracking issue where
|
||||
// one exists.
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
// PeerCapMap returns the subset of peerSelfCaps the Tailscale client
|
||||
// reads from the peer view (rather than the self view) given the
|
||||
// peer's state. Returns nil when no peer-consumed cap applies, matching
|
||||
// the empirical wire shape where [tailcfg.Node.CapMap] is omitted for
|
||||
// most peers.
|
||||
//
|
||||
// Caps the client reads from the peer view rather than the self view
|
||||
// (suggest-exit-node, dns-subdomain-resolve — see
|
||||
// ipn/ipnlocal/local.go:7534 and node_backend.go:745) are emitted only
|
||||
// when the peer satisfies the cap's emission condition. This function
|
||||
// encodes those conditions; the mapper calls it from buildTailPeers and
|
||||
// the compat test calls it to compute the expected per-peer wire shape.
|
||||
func PeerCapMap(peer types.NodeView, peerSelfCaps tailcfg.NodeCapMap) tailcfg.NodeCapMap {
|
||||
if len(peerSelfCaps) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var out tailcfg.NodeCapMap
|
||||
|
||||
// suggest-exit-node — surfaced on Peer.CapMap when the peer
|
||||
// advertises exit routes AND those routes are approved. Client
|
||||
// reads at ipn/ipnlocal/local.go:7534. Approval gating prevents
|
||||
// the suggestion from following an advertised-but-not-yet-trusted
|
||||
// node.
|
||||
if peer.IsExitNode() {
|
||||
if v, ok := peerSelfCaps[tailcfg.NodeAttrSuggestExitNode]; ok {
|
||||
if out == nil {
|
||||
out = tailcfg.NodeCapMap{}
|
||||
}
|
||||
|
||||
out[tailcfg.NodeAttrSuggestExitNode] = v
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// unmodelledTailnetStateCaps lists [tailcfg.NodeCapability] values
|
||||
// stripped on both sides of the compat diff. Order:
|
||||
//
|
||||
// 1. Caps gated on a user-role concept headscale does not model.
|
||||
// 2. Caps gated on a tailnet feature headscale does not implement.
|
||||
// 3. Caps that are tailnet-state metadata (display name, key
|
||||
// duration, etc.) where the values are not derivable from
|
||||
// headscale config in a way that round-trips through the
|
||||
// anonymized capture.
|
||||
// 4. Caps that are internal magicsock or embedded-SSH tuning with no
|
||||
// headscale-side equivalent.
|
||||
var unmodelledTailnetStateCaps = []tailcfg.NodeCapability{
|
||||
// --- 1. User-role gated ---
|
||||
|
||||
// [tailcfg.CapabilityAdmin]: the hosted control plane stamps this
|
||||
// on nodes whose owning user has the admin role; tagged nodes
|
||||
// inherit from a tagOwner with the role. Headscale has no
|
||||
// user-role model — [types.Node.TailNode] emits it as part of
|
||||
// the always-on baseline. Stripping on both sides keeps the diff
|
||||
// from failing on every user-owned non-admin node in a capture.
|
||||
// Long-term fix is autogroup:admin support.
|
||||
tailcfg.CapabilityAdmin,
|
||||
|
||||
// [tailcfg.CapabilityOwner]: same shape as is-admin, conditional
|
||||
// on the "owner" role rather than admin. Headscale does not emit
|
||||
// this cap at all. autogroup:owner support is tracked under
|
||||
// NO_USER_ROLES — see the compat skip list.
|
||||
tailcfg.CapabilityOwner,
|
||||
|
||||
// --- 2. Feature not implemented ---
|
||||
|
||||
// [tailcfg.CapabilityTailnetLock]: tailnet-lock signs node keys
|
||||
// with a tailnet-wide signing key so peers can detect silent
|
||||
// re-keying by the control plane. Client reads at
|
||||
// ipn/ipnlocal/local.go:1752 (b.capTailnetLock). Headscale has no
|
||||
// tailnet-lock implementation.
|
||||
tailcfg.CapabilityTailnetLock,
|
||||
|
||||
// [tailcfg.NodeAttrServiceHost]: marks a node as approved to host
|
||||
// VIP services (Tailscale Services). Client reads via
|
||||
// UnmarshalNodeCapViewJSON at ipn/ipnlocal/local.go:2704.
|
||||
// Headscale does not implement Tailscale Services.
|
||||
tailcfg.NodeAttrServiceHost,
|
||||
|
||||
// [tailcfg.NodeAttrStoreAppCRoutes]: tells an app-connector node
|
||||
// to persist learned routes across restarts. Client reads via
|
||||
// controlknobs:148. Headscale does not implement app connectors.
|
||||
tailcfg.NodeAttrStoreAppCRoutes,
|
||||
|
||||
// [tailcfg.CapabilityWarnFunnelNoHTTPS]: deprecated in Tailscale
|
||||
// 2023-08-09. Should not appear in fresh captures — listed
|
||||
// defensively in case a stale tailnet still emits it.
|
||||
tailcfg.CapabilityWarnFunnelNoHTTPS,
|
||||
|
||||
// --- 3. Tailnet-state metadata not derivable from headscale config ---
|
||||
|
||||
// [tailcfg.NodeAttrTailnetDisplayName]: tailnet display name
|
||||
// surfaced in the client UI. The hosted control plane emits the
|
||||
// tailnet admin's email; headscale would have to invent a value
|
||||
// from cfg.Domain() that does not round-trip through the
|
||||
// anonymized capture string. Skip rather than diverge on a value
|
||||
// with no real-world equivalent.
|
||||
tailcfg.NodeAttrTailnetDisplayName,
|
||||
|
||||
// [tailcfg.NodeAttrMaxKeyDuration]: tailnet-wide max key duration
|
||||
// value. Headscale has cfg.Node.Expiry but does not surface it
|
||||
// as a cap today; the hosted control plane emits this only when
|
||||
// a non-default value is configured.
|
||||
tailcfg.NodeAttrMaxKeyDuration,
|
||||
|
||||
// [tailcfg.NodeAttrNativeIPV4]: peer-consumed cap conditional on
|
||||
// tailnet ipv4 reachability state. Out of scope for the current
|
||||
// peer-cap adoption (only suggest-exit-node is wired in this
|
||||
// PR).
|
||||
tailcfg.NodeAttrNativeIPV4,
|
||||
|
||||
// --- 4. Internal tuning, no headscale equivalent ---
|
||||
|
||||
// [tailcfg.NodeAttrProbeUDPLifetime]: tunes magicsock's UDP
|
||||
// path-lifetime probe behavior. Internal performance knob; not
|
||||
// policy-driven. Client reads via controlknobs:147.
|
||||
tailcfg.NodeAttrProbeUDPLifetime,
|
||||
|
||||
// [tailcfg.NodeAttrSSHBehaviorV1]: configures the embedded SSH
|
||||
// server (no su, in-process SFTP). Internal tuning; the embedded
|
||||
// server picks Tailscale-vendored defaults without the cap.
|
||||
tailcfg.NodeAttrSSHBehaviorV1,
|
||||
|
||||
// [tailcfg.NodeAttrSSHEnvironmentVariables]: gates SendEnv
|
||||
// forwarding in the embedded SSH server. Internal; default chosen
|
||||
// by the server.
|
||||
tailcfg.NodeAttrSSHEnvironmentVariables,
|
||||
}
|
||||
|
||||
// strippedCapPrefixes lists URL/string prefixes for parameterized or
|
||||
// pattern-named caps that should be stripped alongside
|
||||
// [unmodelledTailnetStateCaps].
|
||||
var strippedCapPrefixes = []string{
|
||||
// "https://tailscale.com/cap/funnel-ports?…": parameterized cap
|
||||
// (e.g. "?ports=80,443") issued when funnel is configured.
|
||||
// Funnel is not supported.
|
||||
"https://tailscale.com/cap/funnel-ports?",
|
||||
}
|
||||
|
||||
// stripUnmodelledTailnetStateCaps returns a copy of cm with
|
||||
// [unmodelledTailnetStateCaps] and [strippedCapPrefixes] removed. Used
|
||||
// by the compat test on both sides before [cmp.Diff].
|
||||
func stripUnmodelledTailnetStateCaps(cm tailcfg.NodeCapMap) tailcfg.NodeCapMap {
|
||||
if len(cm) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make(tailcfg.NodeCapMap, len(cm))
|
||||
|
||||
for k, v := range cm {
|
||||
if isUnmodelledTailnetStateCap(k) {
|
||||
continue
|
||||
}
|
||||
|
||||
out[k] = v
|
||||
}
|
||||
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func isUnmodelledTailnetStateCap(k tailcfg.NodeCapability) bool {
|
||||
if slices.Contains(unmodelledTailnetStateCaps, k) {
|
||||
return true
|
||||
}
|
||||
|
||||
s := string(k)
|
||||
for _, p := range strippedCapPrefixes {
|
||||
if strings.HasPrefix(s, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// This file implements a data-driven test runner for ACL compatibility tests.
|
||||
// It loads HuJSON golden files from testdata/acl_results/acl-*.hujson and
|
||||
// compares headscale's ACL engine output against the expected packet filter
|
||||
// rules captured from a Tailscale-hosted control plane by an external capture tool.
|
||||
// rules captured from Tailscale SaaS by the tscap tool.
|
||||
//
|
||||
// Each file is a testcapture.Capture containing:
|
||||
// - The full policy that was POSTed to the Tailscale SaaS API
|
||||
@@ -40,8 +40,8 @@ func ptrAddr(s string) *netip.Addr {
|
||||
}
|
||||
|
||||
// setupACLCompatUsers returns the 3 test users for ACL compatibility tests.
|
||||
// Names and emails match the anonymized identifiers the capture tool writes into the
|
||||
// capture files users get
|
||||
// Names and emails match the anonymized identifiers tscap writes into the
|
||||
// capture files (see github.com/kradalby/tscap/anonymize): users get
|
||||
// norse-god names and nodes get original-151 pokémon names.
|
||||
func setupACLCompatUsers() types.Users {
|
||||
return types.Users{
|
||||
@@ -52,7 +52,7 @@ func setupACLCompatUsers() types.Users {
|
||||
}
|
||||
|
||||
// setupACLCompatNodes returns the 8 test nodes for ACL compatibility tests.
|
||||
// Node GivenNames match the anonymized pokémon naming.
|
||||
// Node GivenNames match tscap's anonymized pokémon naming.
|
||||
func setupACLCompatNodes(users types.Users) types.Nodes {
|
||||
return types.Nodes{
|
||||
{
|
||||
@@ -291,7 +291,7 @@ func TestACLCompat(t *testing.T) {
|
||||
}
|
||||
|
||||
// Build nodes per-scenario from this file's topology.
|
||||
// the capture tool uses clean-slate mode, so each scenario has
|
||||
// tscap uses clean-slate mode, so each scenario has
|
||||
// different node IPs; using a shared topology would
|
||||
// cause IP mismatches in filter rule comparisons.
|
||||
users, nodes := buildACLUsersAndNodes(t, tf)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// This file implements a data-driven test runner for grant compatibility
|
||||
// tests. It loads HuJSON golden files from testdata/grant_results/grant-*.hujson
|
||||
// and via-grant-*.hujson, captured from a Tailscale-hosted control plane, and compares
|
||||
// and via-grant-*.hujson, captured from Tailscale SaaS by tscap, and compares
|
||||
// headscale's grants engine output against the captured packet filter rules.
|
||||
//
|
||||
// Each file is a testcapture.Capture containing:
|
||||
@@ -35,8 +35,8 @@ import (
|
||||
|
||||
// setupGrantsCompatUsers returns the 3 test users for grants compatibility tests.
|
||||
// Users get norse-god names; nodes get original-151 pokémon names — matching
|
||||
// the anonymized identifiers the capture tool writes into the capture files
|
||||
// .
|
||||
// the anonymized identifiers tscap writes into the capture files
|
||||
// (see github.com/kradalby/tscap/anonymize).
|
||||
func setupGrantsCompatUsers() types.Users {
|
||||
return types.Users{
|
||||
{Model: gorm.Model{ID: 1}, Name: "odin", Email: "odin@example.com"},
|
||||
@@ -269,7 +269,7 @@ func findGrantsNode(nodes types.Nodes, name string) *types.Node {
|
||||
}
|
||||
|
||||
// buildGrantsNodesFromCapture constructs types.Nodes from a capture's
|
||||
// topology section. Each scenario in the capture tool uses clean-slate mode, so
|
||||
// topology section. Each scenario in tscap uses clean-slate mode, so
|
||||
// node IPs differ between scenarios; this builds the node set with
|
||||
// the IPs that were actually present during that capture.
|
||||
func buildGrantsNodesFromCapture(
|
||||
@@ -332,7 +332,7 @@ func buildGrantsNodesFromCapture(
|
||||
}
|
||||
|
||||
// convertPolicyUserEmails used to map SaaS-side emails to @example.com.
|
||||
// captures anonymize the policy JSON at write time (kratail2tid -> odin,
|
||||
// tscap now anonymizes the policy JSON at write time (kratail2tid -> odin,
|
||||
// kristoffer -> thor, monitorpasskeykradalby -> freya), so the captured
|
||||
// FullPolicy is already in its final form and this is a passthrough that
|
||||
// just adapts the captured string value to the []byte that the policy
|
||||
@@ -405,12 +405,12 @@ func TestGrantsCompat(t *testing.T) {
|
||||
}
|
||||
|
||||
// Build nodes per-scenario from this file's topology.
|
||||
// the capture tool uses clean-slate mode, so each scenario has
|
||||
// tscap uses clean-slate mode, so each scenario has
|
||||
// different node IPs.
|
||||
nodes := buildGrantsNodesFromCapture(users, tf)
|
||||
|
||||
// Use the captured full policy as is (anonymization
|
||||
// the capture tool already rewrote SaaS emails).
|
||||
// in tscap already rewrote SaaS emails).
|
||||
policyJSON := convertPolicyUserEmails(tf.Input.FullPolicy)
|
||||
|
||||
if tf.Input.APIResponseCode == 400 || tf.Error {
|
||||
|
||||
@@ -1,336 +0,0 @@
|
||||
// This file implements a data-driven test runner for nodeAttrs
|
||||
// compatibility tests. It loads HuJSON golden files from
|
||||
// testdata/nodeattrs_results/nodeattrs-*.hujson, captured from a
|
||||
// Tailscale-hosted control plane, and compares headscale's
|
||||
// `compileNodeAttrs` output against each captured netmap's SelfNode.CapMap.
|
||||
//
|
||||
// Each file is a testcapture.Capture containing:
|
||||
// - A full policy with a `nodeAttrs` block (and optionally `ipPool`)
|
||||
// - The expected per-node netmap from SaaS, including the cap map
|
||||
//
|
||||
// Tests known to fail due to unimplemented features are skipped with a
|
||||
// TODO comment explaining the root cause. As headscale's nodeAttrs
|
||||
// implementation grows, tests should be removed from the skip list.
|
||||
//
|
||||
// Test data source: testdata/nodeattrs_results/nodeattrs-*.hujson
|
||||
// Source format: github.com/juanfont/headscale/hscontrol/types/testcapture
|
||||
|
||||
package v2
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/types/testcapture"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/views"
|
||||
)
|
||||
|
||||
// nodeAttrsCompatUsers returns the three norse-god users the capture
|
||||
// tool's anonymizer rewrites the SaaS users into.
|
||||
func nodeAttrsCompatUsers() types.Users {
|
||||
return types.Users{
|
||||
{Model: gorm.Model{ID: 1}, Name: "odin", Email: "odin@example.com"},
|
||||
{Model: gorm.Model{ID: 2}, Name: "thor", Email: "thor@example.org"},
|
||||
{Model: gorm.Model{ID: 3}, Name: "freya", Email: "freya@example.com"},
|
||||
}
|
||||
}
|
||||
|
||||
// buildNodeAttrsNodesFromCapture mirrors the grants compat helper: each
|
||||
// scenario's clean-slate run produces a different IP for the same
|
||||
// hostname, so the node set comes from the capture's topology rather
|
||||
// than a fixed table.
|
||||
//
|
||||
// [tailcfg.Hostinfo.RoutableIPs] and [types.Node.ApprovedRoutes]
|
||||
// round-trip from the topology so [types.NodeView.IsExitNode] reflects
|
||||
// the captured approval state — the suggest-exit-node peer-cap rule
|
||||
// only fires when a peer's exit routes are approved.
|
||||
func buildNodeAttrsNodesFromCapture(
|
||||
t *testing.T,
|
||||
users types.Users,
|
||||
tf *testcapture.Capture,
|
||||
) types.Nodes {
|
||||
t.Helper()
|
||||
|
||||
nodes := make(types.Nodes, 0, len(tf.Topology.Nodes))
|
||||
autoID := 1
|
||||
|
||||
for _, nodeDef := range tf.Topology.Nodes {
|
||||
node := &types.Node{
|
||||
ID: types.NodeID(autoID), //nolint:gosec
|
||||
GivenName: nodeDef.Hostname,
|
||||
IPv4: ptrAddr(nodeDef.IPv4),
|
||||
IPv6: ptrAddr(nodeDef.IPv6),
|
||||
Tags: nodeDef.Tags,
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
RoutableIPs: parsePrefixes(t, nodeDef.Hostname+".RoutableIPs", nodeDef.RoutableIPs),
|
||||
},
|
||||
ApprovedRoutes: parsePrefixes(t, nodeDef.Hostname+".ApprovedRoutes", nodeDef.ApprovedRoutes),
|
||||
}
|
||||
autoID++
|
||||
|
||||
if len(nodeDef.Tags) == 0 && nodeDef.User != "" {
|
||||
for i := range users {
|
||||
if users[i].Name == nodeDef.User {
|
||||
node.User = &users[i]
|
||||
node.UserID = &users[i].ID
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nodes = append(nodes, node)
|
||||
}
|
||||
|
||||
return nodes
|
||||
}
|
||||
|
||||
// parsePrefixes converts a slice of CIDR strings into [netip.Prefix].
|
||||
// Bad entries fail loud through t.Fatalf — the topology files are
|
||||
// authoritative routing data, so a malformed CIDR is a testdata bug
|
||||
// that should surface, not silently drop the route and corrupt
|
||||
// downstream IsExitNode checks.
|
||||
func parsePrefixes(t *testing.T, name string, s []string) []netip.Prefix {
|
||||
t.Helper()
|
||||
|
||||
if len(s) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make([]netip.Prefix, 0, len(s))
|
||||
|
||||
for _, p := range s {
|
||||
pre, err := netip.ParsePrefix(p)
|
||||
if err != nil {
|
||||
t.Fatalf("topology %q: malformed CIDR %q: %v", name, p, err)
|
||||
}
|
||||
|
||||
out = append(out, pre)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// nodeAttrsSkipReasons documents the captured scenarios SaaS accepts and
|
||||
// headscale deliberately rejects at validate time. The rejection itself is
|
||||
// covered by TestNodeAttrsValidate; this list keeps the compat diff focused
|
||||
// on shapes both control planes agree on.
|
||||
//
|
||||
// IPPOOL_ALLOCATOR — `ipPool` is parsed but the allocator that
|
||||
// consumes it is not yet implemented.
|
||||
// FUNNEL_NOT_SUPPORTED — `funnel` cap is rejected pending the DNS /
|
||||
// ACME machinery the feature requires.
|
||||
// NO_USER_ROLES — `autogroup:admin` and `autogroup:owner` depend on
|
||||
// user-role and tailnet-ownership concepts headscale does not
|
||||
// model.
|
||||
var nodeAttrsSkipReasons = map[string]string{
|
||||
"nodeattrs-ippool-g1-admin": "IPPOOL_ALLOCATOR",
|
||||
"nodeattrs-ippool-g2-group": "IPPOOL_ALLOCATOR",
|
||||
"nodeattrs-ippool-g3-mixed": "IPPOOL_ALLOCATOR",
|
||||
"nodeattrs-target-a10-autogroup-admin": "NO_USER_ROLES: autogroup:admin",
|
||||
"nodeattrs-target-a11-autogroup-owner": "NO_USER_ROLES: autogroup:owner",
|
||||
"nodeattrs-attr-c1-funnel": "FUNNEL_NOT_SUPPORTED",
|
||||
"nodeattrs-funnel-f1-tag": "FUNNEL_NOT_SUPPORTED",
|
||||
"nodeattrs-funnel-f2-user": "FUNNEL_NOT_SUPPORTED",
|
||||
}
|
||||
|
||||
// TestNodeAttrsCompat is a data-driven test that loads every captured
|
||||
// nodeAttrs scenario and compares headscale's compiled CapMap against
|
||||
// the corresponding SaaS-rendered netmap.
|
||||
func TestNodeAttrsCompat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
files, err := filepath.Glob(
|
||||
filepath.Join("testdata", "nodeattrs_results", "*.hujson"),
|
||||
)
|
||||
require.NoError(t, err, "failed to glob test files")
|
||||
|
||||
if len(files) == 0 {
|
||||
t.Skip(
|
||||
"testdata/nodeattrs_results is empty — re-run the capture " +
|
||||
"tool against the nodeattrs scenario set and copy the " +
|
||||
"anonymized results into " +
|
||||
"hscontrol/policy/v2/testdata/nodeattrs_results/",
|
||||
)
|
||||
}
|
||||
|
||||
t.Logf("Loaded %d nodeAttrs test files", len(files))
|
||||
|
||||
users := nodeAttrsCompatUsers()
|
||||
|
||||
for _, file := range files {
|
||||
tf := loadGrantTestFile(t, file)
|
||||
|
||||
t.Run(tf.TestID, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if reason, ok := nodeAttrsSkipReasons[tf.TestID]; ok {
|
||||
t.Skipf("TODO: %s", reason)
|
||||
}
|
||||
|
||||
nodes := buildNodeAttrsNodesFromCapture(t, users, tf)
|
||||
policyJSON := convertPolicyUserEmails(tf.Input.FullPolicy)
|
||||
|
||||
if tf.Input.APIResponseCode == 400 || tf.Error {
|
||||
testNodeAttrsError(t, policyJSON, tf)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
testNodeAttrsSuccess(t, policyJSON, tf, users, nodes)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testNodeAttrsError(t *testing.T, policyJSON []byte, tf *testcapture.Capture) {
|
||||
t.Helper()
|
||||
|
||||
// SaaS error wording is not stable enough to compare exactly — the
|
||||
// e3-autogroup-self capture comes back as "internal server error",
|
||||
// for instance. The contract this test enforces is the weaker but
|
||||
// still-meaningful one: headscale must also refuse the policy at
|
||||
// parse or validate time.
|
||||
pol, err := unmarshalPolicy(policyJSON)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = pol.validate()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
wantMsg := ""
|
||||
if tf.Input.APIResponseBody != nil {
|
||||
wantMsg = tf.Input.APIResponseBody.Message
|
||||
}
|
||||
|
||||
// The dispatch in TestNodeAttrsCompat fires for either
|
||||
// APIResponseCode==400 or tf.Error==true; reflect the actual
|
||||
// trigger in the diagnostic so a tf.Error scenario doesn't get
|
||||
// reported as "saas code=0".
|
||||
t.Errorf(
|
||||
"%s: expected error (api_code=%d capture_error=%t msg=%q) "+
|
||||
"but policy parsed and validated successfully",
|
||||
tf.TestID, tf.Input.APIResponseCode, tf.Error, wantMsg,
|
||||
)
|
||||
}
|
||||
|
||||
func testNodeAttrsSuccess(
|
||||
t *testing.T,
|
||||
policyJSON []byte,
|
||||
tf *testcapture.Capture,
|
||||
users types.Users,
|
||||
nodes types.Nodes,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
pol, err := unmarshalPolicy(policyJSON)
|
||||
require.NoErrorf(t, err, "%s: policy should parse", tf.TestID)
|
||||
require.NoErrorf(t, pol.validate(), "%s: policy should validate", tf.TestID)
|
||||
|
||||
got, err := pol.compileNodeAttrs(users, nodes.ViewSlice())
|
||||
require.NoErrorf(t, err, "%s: compileNodeAttrs", tf.TestID)
|
||||
|
||||
// Mirror the prod self-build: route function is irrelevant for CapMap.
|
||||
//
|
||||
// Taildrop.Enabled defaults to true here because every capture is
|
||||
// taken with the SaaS default Send Files state. The Tailscale v2
|
||||
// TailnetSettings API does not expose the Send Files toggle, so
|
||||
// tscap cannot vary it; the off-path is covered directly by
|
||||
// TestTaildropDisabledWithholdsFileSharingCap in servertest.
|
||||
// TODO: wire Taildrop.Enabled from tf.Input.Tailnet.Settings.FileSharing
|
||||
// once the field is added to the public TailnetSettings API.
|
||||
cfg := &types.Config{Taildrop: types.TaildropConfig{Enabled: true}}
|
||||
if v := tf.Input.Tailnet.Settings.DevicesAutoUpdatesOn; v != nil && *v {
|
||||
cfg.AutoUpdate = types.AutoUpdateConfig{Enabled: true}
|
||||
}
|
||||
|
||||
emptyRoutes := func(types.NodeID) []netip.Prefix { return nil }
|
||||
|
||||
selfCapMap := func(t *testing.T, node *types.Node) tailcfg.NodeCapMap {
|
||||
t.Helper()
|
||||
|
||||
tn, err := node.View().TailNode(0, emptyRoutes, cfg, got[node.ID])
|
||||
require.NoErrorf(t, err, "%s/%s: TailNode", tf.TestID, node.GivenName)
|
||||
|
||||
return tn.CapMap
|
||||
}
|
||||
|
||||
for nodeName, capture := range tf.Captures {
|
||||
if capture.Netmap == nil || !capture.Netmap.SelfNode.Valid() {
|
||||
continue
|
||||
}
|
||||
|
||||
t.Run(nodeName, func(t *testing.T) {
|
||||
node := findNodeByGivenName(nodes, nodeName)
|
||||
require.NotNilf(t, node,
|
||||
"node %q from capture not found in test setup", nodeName)
|
||||
|
||||
gotSelf := stripUnmodelledTailnetStateCaps(selfCapMap(t, node))
|
||||
wantSelf := stripUnmodelledTailnetStateCaps(
|
||||
capMapFromView(capture.Netmap.SelfNode.CapMap()),
|
||||
)
|
||||
|
||||
if diff := cmp.Diff(wantSelf, gotSelf, cmpopts.EquateEmpty()); diff != "" {
|
||||
t.Errorf(
|
||||
"%s/%s: SelfNode.CapMap mismatch (-tailscale +headscale):\n%s",
|
||||
tf.TestID, nodeName, diff,
|
||||
)
|
||||
}
|
||||
|
||||
for _, peer := range capture.Netmap.Peers {
|
||||
peerName := peer.ComputedName()
|
||||
|
||||
peerNode := findNodeByGivenName(nodes, peerName)
|
||||
if peerNode == nil {
|
||||
// A captured peer with no matching node in the
|
||||
// constructed topology is almost always topology
|
||||
// drift — fail loud so the gap is visible instead
|
||||
// of silently dropping the comparison.
|
||||
t.Errorf("%s/%s: capture peer %q not found in topology",
|
||||
tf.TestID, nodeName, peerName)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
gotPeer := stripUnmodelledTailnetStateCaps(
|
||||
PeerCapMap(peerNode.View(), got[peerNode.ID]),
|
||||
)
|
||||
wantPeer := stripUnmodelledTailnetStateCaps(
|
||||
capMapFromView(peer.CapMap()),
|
||||
)
|
||||
|
||||
if diff := cmp.Diff(wantPeer, gotPeer, cmpopts.EquateEmpty()); diff != "" {
|
||||
t.Errorf(
|
||||
"%s/%s/peer=%s: Peer.CapMap mismatch (-tailscale +headscale):\n%s",
|
||||
tf.TestID, nodeName, peerName, diff,
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// capMapFromView materialises a captured CapMap view into the
|
||||
// [tailcfg.NodeCapMap] shape headscale renders, so both sides of the
|
||||
// diff have the same concrete type.
|
||||
func capMapFromView(view views.MapSlice[tailcfg.NodeCapability, tailcfg.RawMessage]) tailcfg.NodeCapMap {
|
||||
if view.Len() == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make(tailcfg.NodeCapMap, view.Len())
|
||||
for k, v := range view.All() {
|
||||
out[k] = v.AsSlice()
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// This file implements data-driven test runners for routes compatibility tests.
|
||||
// It loads HuJSON golden files from testdata/routes_results/routes-*.hujson,
|
||||
// captured from a Tailscale-hosted control plane, and compares headscale's route-aware
|
||||
// captured from Tailscale SaaS by tscap, and compares headscale's route-aware
|
||||
// ACL engine output against the captured packet filter rules.
|
||||
//
|
||||
// Each capture file is a testcapture.Capture containing:
|
||||
@@ -57,7 +57,7 @@ func loadRoutesTestFile(t *testing.T, path string) *testcapture.Capture {
|
||||
}
|
||||
|
||||
// convertSaaSEmail used to map SaaS-side emails to @example.com placeholders.
|
||||
// captures are anonymized at write time (norse-god names + pokémon
|
||||
// tscap now anonymizes captures at write time (norse-god names + pokémon
|
||||
// hostnames), so the captured topology emails are already in their final
|
||||
// form and this is a passthrough.
|
||||
func convertSaaSEmail(email string) string {
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
// This file implements a data-driven test runner for SSH compatibility tests.
|
||||
// It loads HuJSON golden files from testdata/ssh_results/ssh-*.hujson, captured
|
||||
// from a Tailscale-hosted control plane, and compares headscale's SSH policy
|
||||
// compilation against the captured SSH rules.
|
||||
// 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
|
||||
@@ -37,8 +33,8 @@ import (
|
||||
|
||||
// setupSSHDataCompatUsers returns the 3 test users for SSH data-driven
|
||||
// compatibility tests. Users get norse-god names; nodes get original-151
|
||||
// pokémon names — matching the anonymized identifiers the capture
|
||||
// tool writes into the capture files.
|
||||
// pokémon names — matching the anonymized identifiers tscap writes into
|
||||
// the capture files (see github.com/kradalby/tscap/anonymize).
|
||||
//
|
||||
// odin and freya live on @example.com; thor lives on @example.org so
|
||||
// that "localpart:*@example.com" resolves to exactly two users
|
||||
@@ -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
@@ -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"`
|
||||
|
||||
Vendored
+2
-2
@@ -229,7 +229,7 @@
|
||||
"Cap": 131,
|
||||
"Tags": ["tag:exit"],
|
||||
"Online": true,
|
||||
"CapMap": {"suggest-exit-node": null},
|
||||
"CapMap": {"suggest-charmander": null},
|
||||
"ComputedName": "charmander",
|
||||
"ComputedNameWithHost": "charmander"
|
||||
}, {
|
||||
@@ -257,7 +257,7 @@
|
||||
"Cap": 131,
|
||||
"Tags": ["tag:exit", "tag:router"],
|
||||
"Online": true,
|
||||
"CapMap": {"suggest-exit-node": null},
|
||||
"CapMap": {"suggest-charmander": null},
|
||||
"ComputedName": "wartortle",
|
||||
"ComputedNameWithHost": "wartortle"
|
||||
}],
|
||||
|
||||
+22
-22
@@ -229,7 +229,7 @@
|
||||
"Cap": 131,
|
||||
"Tags": ["tag:exit"],
|
||||
"Online": true,
|
||||
"CapMap": {"suggest-exit-node": null},
|
||||
"CapMap": {"suggest-charmander": null},
|
||||
"ComputedName": "charmander",
|
||||
"ComputedNameWithHost": "charmander"
|
||||
}, {
|
||||
@@ -257,7 +257,7 @@
|
||||
"Cap": 131,
|
||||
"Tags": ["tag:exit", "tag:router"],
|
||||
"Online": true,
|
||||
"CapMap": {"suggest-exit-node": null},
|
||||
"CapMap": {"suggest-charmander": null},
|
||||
"ComputedName": "wartortle",
|
||||
"ComputedNameWithHost": "wartortle"
|
||||
}],
|
||||
@@ -1176,7 +1176,7 @@
|
||||
"Cap": 131,
|
||||
"Tags": ["tag:exit"],
|
||||
"Online": true,
|
||||
"CapMap": {"suggest-exit-node": null},
|
||||
"CapMap": {"suggest-charmander": null},
|
||||
"ComputedName": "charmander",
|
||||
"ComputedNameWithHost": "charmander"
|
||||
}, {
|
||||
@@ -1204,7 +1204,7 @@
|
||||
"Cap": 131,
|
||||
"Tags": ["tag:exit", "tag:router"],
|
||||
"Online": true,
|
||||
"CapMap": {"suggest-exit-node": null},
|
||||
"CapMap": {"suggest-charmander": null},
|
||||
"ComputedName": "wartortle",
|
||||
"ComputedNameWithHost": "wartortle"
|
||||
}],
|
||||
@@ -2126,7 +2126,7 @@
|
||||
"Cap": 131,
|
||||
"Tags": ["tag:exit"],
|
||||
"Online": true,
|
||||
"CapMap": {"suggest-exit-node": null},
|
||||
"CapMap": {"suggest-charmander": null},
|
||||
"ComputedName": "charmander",
|
||||
"ComputedNameWithHost": "charmander"
|
||||
}, {
|
||||
@@ -2154,7 +2154,7 @@
|
||||
"Cap": 131,
|
||||
"Tags": ["tag:exit", "tag:router"],
|
||||
"Online": true,
|
||||
"CapMap": {"suggest-exit-node": null},
|
||||
"CapMap": {"suggest-charmander": null},
|
||||
"ComputedName": "wartortle",
|
||||
"ComputedNameWithHost": "wartortle"
|
||||
}],
|
||||
@@ -3077,7 +3077,7 @@
|
||||
"Cap": 131,
|
||||
"Tags": ["tag:exit"],
|
||||
"Online": true,
|
||||
"CapMap": {"suggest-exit-node": null},
|
||||
"CapMap": {"suggest-charmander": null},
|
||||
"ComputedName": "charmander",
|
||||
"ComputedNameWithHost": "charmander"
|
||||
}, {
|
||||
@@ -3105,7 +3105,7 @@
|
||||
"Cap": 131,
|
||||
"Tags": ["tag:exit", "tag:router"],
|
||||
"Online": true,
|
||||
"CapMap": {"suggest-exit-node": null},
|
||||
"CapMap": {"suggest-charmander": null},
|
||||
"ComputedName": "wartortle",
|
||||
"ComputedNameWithHost": "wartortle"
|
||||
}],
|
||||
@@ -4204,7 +4204,7 @@
|
||||
"Tags": ["tag:exit", "tag:router"],
|
||||
"PrimaryRoutes": ["172.16.0.0/24"],
|
||||
"Online": true,
|
||||
"CapMap": {"suggest-exit-node": null},
|
||||
"CapMap": {"suggest-charmander": null},
|
||||
"ComputedName": "wartortle",
|
||||
"ComputedNameWithHost": "wartortle"
|
||||
}, {
|
||||
@@ -5410,7 +5410,7 @@
|
||||
"Cap": 131,
|
||||
"Tags": ["tag:exit"],
|
||||
"Online": true,
|
||||
"CapMap": {"suggest-exit-node": null},
|
||||
"CapMap": {"suggest-charmander": null},
|
||||
"ComputedName": "charmander",
|
||||
"ComputedNameWithHost": "charmander"
|
||||
}, {
|
||||
@@ -5438,7 +5438,7 @@
|
||||
"Cap": 131,
|
||||
"Tags": ["tag:exit", "tag:router"],
|
||||
"Online": true,
|
||||
"CapMap": {"suggest-exit-node": null},
|
||||
"CapMap": {"suggest-charmander": null},
|
||||
"ComputedName": "wartortle",
|
||||
"ComputedNameWithHost": "wartortle"
|
||||
}],
|
||||
@@ -6355,7 +6355,7 @@
|
||||
"Cap": 131,
|
||||
"Tags": ["tag:exit"],
|
||||
"Online": true,
|
||||
"CapMap": {"suggest-exit-node": null},
|
||||
"CapMap": {"suggest-charmander": null},
|
||||
"ComputedName": "charmander",
|
||||
"ComputedNameWithHost": "charmander"
|
||||
}, {
|
||||
@@ -6383,7 +6383,7 @@
|
||||
"Cap": 131,
|
||||
"Tags": ["tag:exit", "tag:router"],
|
||||
"Online": true,
|
||||
"CapMap": {"suggest-exit-node": null},
|
||||
"CapMap": {"suggest-charmander": null},
|
||||
"ComputedName": "wartortle",
|
||||
"ComputedNameWithHost": "wartortle"
|
||||
}],
|
||||
@@ -7306,7 +7306,7 @@
|
||||
"Cap": 131,
|
||||
"Tags": ["tag:exit"],
|
||||
"Online": true,
|
||||
"CapMap": {"suggest-exit-node": null},
|
||||
"CapMap": {"suggest-charmander": null},
|
||||
"ComputedName": "charmander",
|
||||
"ComputedNameWithHost": "charmander"
|
||||
}, {
|
||||
@@ -7334,7 +7334,7 @@
|
||||
"Cap": 131,
|
||||
"Tags": ["tag:exit", "tag:router"],
|
||||
"Online": true,
|
||||
"CapMap": {"suggest-exit-node": null},
|
||||
"CapMap": {"suggest-charmander": null},
|
||||
"ComputedName": "wartortle",
|
||||
"ComputedNameWithHost": "wartortle"
|
||||
}],
|
||||
@@ -8257,7 +8257,7 @@
|
||||
"Cap": 131,
|
||||
"Tags": ["tag:exit"],
|
||||
"Online": true,
|
||||
"CapMap": {"suggest-exit-node": null},
|
||||
"CapMap": {"suggest-charmander": null},
|
||||
"ComputedName": "charmander",
|
||||
"ComputedNameWithHost": "charmander"
|
||||
}, {
|
||||
@@ -8285,7 +8285,7 @@
|
||||
"Cap": 131,
|
||||
"Tags": ["tag:exit", "tag:router"],
|
||||
"Online": true,
|
||||
"CapMap": {"suggest-exit-node": null},
|
||||
"CapMap": {"suggest-charmander": null},
|
||||
"ComputedName": "wartortle",
|
||||
"ComputedNameWithHost": "wartortle"
|
||||
}],
|
||||
@@ -9202,7 +9202,7 @@
|
||||
"Cap": 131,
|
||||
"Tags": ["tag:exit"],
|
||||
"Online": true,
|
||||
"CapMap": {"suggest-exit-node": null},
|
||||
"CapMap": {"suggest-charmander": null},
|
||||
"ComputedName": "charmander",
|
||||
"ComputedNameWithHost": "charmander"
|
||||
}, {
|
||||
@@ -9230,7 +9230,7 @@
|
||||
"Cap": 131,
|
||||
"Tags": ["tag:exit", "tag:router"],
|
||||
"Online": true,
|
||||
"CapMap": {"suggest-exit-node": null},
|
||||
"CapMap": {"suggest-charmander": null},
|
||||
"ComputedName": "wartortle",
|
||||
"ComputedNameWithHost": "wartortle"
|
||||
}],
|
||||
@@ -10274,7 +10274,7 @@
|
||||
"Cap": 131,
|
||||
"Tags": ["tag:exit"],
|
||||
"Online": true,
|
||||
"CapMap": {"suggest-exit-node": null},
|
||||
"CapMap": {"suggest-charmander": null},
|
||||
"ComputedName": "charmander",
|
||||
"ComputedNameWithHost": "charmander"
|
||||
}, {
|
||||
@@ -11535,7 +11535,7 @@
|
||||
"Cap": 131,
|
||||
"Tags": ["tag:exit"],
|
||||
"Online": true,
|
||||
"CapMap": {"suggest-exit-node": null},
|
||||
"CapMap": {"suggest-charmander": null},
|
||||
"ComputedName": "charmander",
|
||||
"ComputedNameWithHost": "charmander"
|
||||
}, {
|
||||
@@ -11563,7 +11563,7 @@
|
||||
"Cap": 131,
|
||||
"Tags": ["tag:exit", "tag:router"],
|
||||
"Online": true,
|
||||
"CapMap": {"suggest-exit-node": null},
|
||||
"CapMap": {"suggest-charmander": null},
|
||||
"ComputedName": "wartortle",
|
||||
"ComputedNameWithHost": "wartortle"
|
||||
}],
|
||||
|
||||
-7774
File diff suppressed because it is too large
Load Diff
Vendored
-8837
File diff suppressed because it is too large
Load Diff
-8823
File diff suppressed because it is too large
Load Diff
-8823
File diff suppressed because it is too large
Load Diff
-8823
File diff suppressed because it is too large
Load Diff
Vendored
-8831
File diff suppressed because it is too large
Load Diff
-9725
File diff suppressed because it is too large
Load Diff
Vendored
-9766
File diff suppressed because it is too large
Load Diff
-8837
File diff suppressed because it is too large
Load Diff
-8837
File diff suppressed because it is too large
Load Diff
-8837
File diff suppressed because it is too large
Load Diff
-7735
File diff suppressed because it is too large
Load Diff
Vendored
-8823
File diff suppressed because it is too large
Load Diff
-8821
File diff suppressed because it is too large
Load Diff
-8831
File diff suppressed because it is too large
Load Diff
-8813
File diff suppressed because it is too large
Load Diff
-8818
File diff suppressed because it is too large
Load Diff
-8823
File diff suppressed because it is too large
Load Diff
-8823
File diff suppressed because it is too large
Load Diff
-8823
File diff suppressed because it is too large
Load Diff
Vendored
-8821
File diff suppressed because it is too large
Load Diff
-8823
File diff suppressed because it is too large
Load Diff
-8818
File diff suppressed because it is too large
Load Diff
-8827
File diff suppressed because it is too large
Load Diff
-8827
File diff suppressed because it is too large
Load Diff
-8816
File diff suppressed because it is too large
Load Diff
-7308
File diff suppressed because it is too large
Load Diff
-7408
File diff suppressed because it is too large
Load Diff
-8821
File diff suppressed because it is too large
Load Diff
-8826
File diff suppressed because it is too large
Load Diff
-8828
File diff suppressed because it is too large
Load Diff
-8825
File diff suppressed because it is too large
Load Diff
-8831
File diff suppressed because it is too large
Load Diff
-8825
File diff suppressed because it is too large
Load Diff
-8837
File diff suppressed because it is too large
Load Diff
-8824
File diff suppressed because it is too large
Load Diff
-8837
File diff suppressed because it is too large
Load Diff
-8852
File diff suppressed because it is too large
Load Diff
-8817
File diff suppressed because it is too large
Load Diff
Vendored
-8818
File diff suppressed because it is too large
Load Diff
Vendored
-8818
File diff suppressed because it is too large
Load Diff
-9202
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
Reference in New Issue
Block a user