mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-19 14:30:44 +09:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dcd95698e4 | |||
| 073f11b6bf | |||
| 9b6bfd05bc | |||
| fa94bd949c | |||
| cad0cb2f5c | |||
| c278418b94 | |||
| 3a81dbd1c0 | |||
| 9d4af0009d | |||
| 077621eef3 | |||
| d11be77f1f | |||
| f60f89647b | |||
| 387dcd679e | |||
| f2f7c5704a | |||
| c26cf2f2d8 | |||
| f34765ad1e | |||
| 7feb5a52e4 | |||
| 0cb9536784 | |||
| 960219e52c | |||
| 8201be9b4b | |||
| f55d6c3cb0 | |||
| 1ea3b676f2 | |||
| ac8fc70e7d | |||
| f678bcb6c2 | |||
| 3e2dd93c33 | |||
| 8038c39e23 | |||
| b9a9b5026f | |||
| 5816a5a8b1 | |||
| 1d57358314 | |||
| 3c125e62a3 | |||
| 8d3ae36675 |
+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:
|
||||
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
nix develop --command -- ko build \
|
||||
--bare \
|
||||
--platform=linux/amd64,linux/arm64 \
|
||||
--tags=main-${GITHUB_SHA::7},development \
|
||||
--tags=main-${GITHUB_SHA::7} \
|
||||
./cmd/headscale
|
||||
|
||||
- name: Push to Docker Hub
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
nix develop --command -- ko build \
|
||||
--bare \
|
||||
--platform=linux/amd64,linux/arm64 \
|
||||
--tags=main-${GITHUB_SHA::7},development \
|
||||
--tags=main-${GITHUB_SHA::7} \
|
||||
./cmd/headscale
|
||||
|
||||
binaries:
|
||||
|
||||
@@ -68,7 +68,6 @@ func findTests() []string {
|
||||
args := []string{
|
||||
"--type", "go",
|
||||
"--regexp", "func (Test.+)\\(.*",
|
||||
"--max-depth", "1",
|
||||
"../../integration/",
|
||||
"--replace", "$1",
|
||||
"--sort", "path",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -58,16 +58,15 @@ jobs:
|
||||
|
||||
# Find when needs-more-info was last added
|
||||
let events = (gh api $"repos/($env.GH_REPO)/issues/($number)/events"
|
||||
--paginate | from json)
|
||||
--paginate | from json | flatten)
|
||||
let label_event = ($events
|
||||
| where event == "labeled"
|
||||
| where label.name == "needs-more-info"
|
||||
| where event == "labeled" and label.name == "needs-more-info"
|
||||
| last)
|
||||
let label_added_at = ($label_event.created_at | into datetime)
|
||||
|
||||
# Check for non-bot comments after the label was added
|
||||
let comments = (gh api $"repos/($env.GH_REPO)/issues/($number)/comments"
|
||||
--paginate | from json)
|
||||
--paginate | from json | flatten)
|
||||
let human_responses = ($comments
|
||||
| where user.type != "Bot"
|
||||
| where { ($in.created_at | into datetime) > $label_added_at })
|
||||
|
||||
@@ -202,8 +202,6 @@ jobs:
|
||||
- TestAuthWebFlowAuthenticationPingAll
|
||||
- TestAuthWebFlowLogoutAndReloginSameUser
|
||||
- TestAuthWebFlowLogoutAndReloginNewUser
|
||||
- TestPolicyCheckCommand
|
||||
- TestSSHTestsRejectFailingPolicy
|
||||
- TestUserCommand
|
||||
- TestPreAuthKeyCommand
|
||||
- TestPreAuthKeyCommandWithoutExpiry
|
||||
@@ -241,7 +239,6 @@ jobs:
|
||||
- TestHASubnetRouterFailover
|
||||
- TestSubnetRouteACL
|
||||
- TestEnablingExitRoutes
|
||||
- TestExitRoutesWithAutogroupInternetACL
|
||||
- TestSubnetRouterMultiNetwork
|
||||
- TestSubnetRouterMultiNetworkExitNode
|
||||
- TestAutoApproveMultiNetwork/authkey-tag.*
|
||||
@@ -252,10 +249,6 @@ jobs:
|
||||
- TestAutoApproveMultiNetwork/webauth-group.*
|
||||
- TestSubnetRouteACLFiltering
|
||||
- TestGrantViaSubnetSteering
|
||||
- TestHASubnetRouterPingFailover
|
||||
- TestHASubnetRouterFailoverBothOffline
|
||||
- TestHASubnetRouterFailoverBothOfflineCablePull
|
||||
- TestHASubnetRouterFailoverDockerDisconnect
|
||||
- TestHeadscale
|
||||
- TestTailscaleNodesJoiningHeadcale
|
||||
- TestSSHOneUserToAll
|
||||
@@ -303,7 +296,6 @@ jobs:
|
||||
- TestTagsAuthKeyWithoutUserInheritsTags
|
||||
- TestTagsAuthKeyWithoutUserRejectsAdvertisedTags
|
||||
- TestTagsAuthKeyConvertToUserViaCLIRegister
|
||||
- TestTailscaleRustAxum
|
||||
uses: ./.github/workflows/integration-test-template.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
|
||||
+3
-4
@@ -42,9 +42,10 @@ source:
|
||||
- "vendor/"
|
||||
|
||||
nfpms:
|
||||
# Configure nFPM for .deb releases
|
||||
# Configure nFPM for .deb and .rpm releases
|
||||
#
|
||||
# See https://goreleaser.com/customization/package/nfpm/
|
||||
# See https://nfpm.goreleaser.com/configuration/
|
||||
# and https://goreleaser.com/customization/nfpm/
|
||||
#
|
||||
# Useful tools for debugging .debs:
|
||||
# List file contents: dpkg -c dist/headscale...deb
|
||||
@@ -78,8 +79,6 @@ nfpms:
|
||||
dst: /usr/lib/systemd/system/headscale.service
|
||||
- dst: /var/lib/headscale
|
||||
type: dir
|
||||
- src: ./config-example.yaml
|
||||
dst: /usr/share/doc/headscale/examples/config-example.yaml
|
||||
- src: LICENSE
|
||||
dst: /usr/share/doc/headscale/copyright
|
||||
scripts:
|
||||
|
||||
@@ -13,7 +13,6 @@ repos:
|
||||
rev: v6.0.0
|
||||
hooks:
|
||||
- id: check-added-large-files
|
||||
args: [--maxkb=1024]
|
||||
- id: check-case-conflict
|
||||
- id: check-executables-have-shebangs
|
||||
- id: check-json
|
||||
@@ -61,11 +60,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
|
||||
|
||||
@@ -198,7 +198,7 @@ databases. The `migrationsRequiringFKDisabled` map in
|
||||
|
||||
Headscale enforces **tags XOR user ownership**: every node is either
|
||||
tagged (owned by tags) or user-owned (owned by a user namespace), never
|
||||
both. This is a load-bearing architectural rule.
|
||||
both. This is a load-bearing architectural invariant.
|
||||
|
||||
- **Use `node.IsTagged()`** (`hscontrol/types/node.go:221`) to determine
|
||||
ownership, not `node.UserID().Valid()`. A tagged node may still have
|
||||
|
||||
+49
-276
@@ -27,62 +27,13 @@ A new `headscale auth` CLI command group supports the approval flow:
|
||||
[#1850](https://github.com/juanfont/headscale/pull/1850)
|
||||
[#3180](https://github.com/juanfont/headscale/pull/3180)
|
||||
|
||||
### Policy tests (beta)
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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)
|
||||
alongside ACLs. Grants extend what you can express in a policy beyond packet filtering: the `app`
|
||||
field controls application-level features like Taildrive file sharing and peer relay, and the `via`
|
||||
field steers traffic through specific tagged subnet routers or exit nodes. The `ip` field works like
|
||||
an ACL rule. Grants can be mixed with ACLs in the same policy file.
|
||||
We now support [Tailscale grants](https://tailscale.com/kb/1324/grants) alongside ACLs. Grants
|
||||
extend what you can express in a policy beyond packet filtering: the `app` field controls
|
||||
application-level features like Taildrive file sharing and peer relay, and the `via` field steers
|
||||
traffic through specific tagged subnet routers or exit nodes. The `ip` field works like an ACL rule.
|
||||
Grants can be mixed with ACLs in the same policy file.
|
||||
[#2180](https://github.com/juanfont/headscale/pull/2180)
|
||||
|
||||
As part of this, we added `autogroup:danger-all`. It resolves to `0.0.0.0/0` and `::/0` — all IP
|
||||
@@ -90,244 +41,66 @@ 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.
|
||||
Headscale previously had three overlapping regexes and two disagreeing entry points
|
||||
(registration vs map-request update), which caused a recurring class of bugs: names
|
||||
containing apostrophes, spaces, dots, or non-ASCII characters were alternately rejected
|
||||
(dropping updates with log spam) or stored as `invalid-<rand>` surrogates
|
||||
([#3188](https://github.com/juanfont/headscale/issues/3188),
|
||||
[#2926](https://github.com/juanfont/headscale/issues/2926),
|
||||
[#2343](https://github.com/juanfont/headscale/issues/2343),
|
||||
[#2762](https://github.com/juanfont/headscale/issues/2762),
|
||||
[#2177](https://github.com/juanfont/headscale/issues/2177),
|
||||
[#2121](https://github.com/juanfont/headscale/issues/2121),
|
||||
[#2449](https://github.com/juanfont/headscale/issues/2449),
|
||||
[#363](https://github.com/juanfont/headscale/issues/363)).
|
||||
|
||||
What changed:
|
||||
|
||||
- Sanitisation and validation now come directly from
|
||||
`tailscale.com/util/dnsname.SanitizeHostname` / `ValidLabel`.
|
||||
- Admin rename (`headscale nodes rename`) now validates via `dnsname.ValidLabel` and
|
||||
rejects labels already held by another node (previously coerced invalid input silently).
|
||||
|
||||
Examples that previously regressed and now work:
|
||||
|
||||
| Input | Raw (Hostname) | DNS label (GivenName) |
|
||||
| -------------------- | -------------------- | --------------------- |
|
||||
| `Joe's Mac mini` | `Joe's Mac mini` | `joes-mac-mini` |
|
||||
| `Yuri's MacBook Pro` | `Yuri's MacBook Pro` | `yuris-macbook-pro` |
|
||||
| `Test@Host` | `Test@Host` | `test-host` |
|
||||
| `mail.server` | `mail.server` | `mail-server` |
|
||||
| `My-PC!` | `My-PC!` | `my-pc` |
|
||||
| `我的电脑` | `我的电脑` | `node` |
|
||||
|
||||
### BREAKING
|
||||
|
||||
#### Hostname handling
|
||||
|
||||
- The `GivenName` collision policy changed from an 8-char random hash suffix (`laptop-abc12xyz`) to a monotonic numeric suffix (`laptop`, `laptop-1`, `laptop-2`, …), matching Tailscale SaaS. Empty / all-non-ASCII hostnames now fall back to the literal `node` instead of `invalid-<rand>`. MagicDNS names change on upgrade for any node whose previous label was a random-suffix form; the raw `Hostname` column is unchanged.
|
||||
|
||||
#### ACL Policy
|
||||
|
||||
- Wildcard (`*`) in ACL sources and destinations now resolves to Tailscale's CGNAT range (`100.64.0.0/10`) and ULA range (`fd7a:115c:a1e0::/48`) instead of all IPs (`0.0.0.0/0` and `::/0`) [#3036](https://github.com/juanfont/headscale/pull/3036)
|
||||
- **ACL Policy**: Wildcard (`*`) in ACL sources and destinations now resolves to Tailscale's CGNAT range (`100.64.0.0/10`) and ULA range (`fd7a:115c:a1e0::/48`) instead of all IPs (`0.0.0.0/0` and `::/0`) [#3036](https://github.com/juanfont/headscale/pull/3036)
|
||||
- This better matches Tailscale's security model where `*` means "any node in the tailnet" rather than "any IP address"
|
||||
- Policies that need to match all IP addresses including non-Tailscale IPs should use `autogroup:danger-all` as a source, or explicit CIDR ranges as destinations [#2180](https://github.com/juanfont/headscale/pull/2180)
|
||||
- `autogroup:danger-all` can only be used as a source; it cannot be used as a destination
|
||||
- **Note**: Users with non-standard IP ranges configured in `prefixes.ipv4` or `prefixes.ipv6` (which is unsupported and produces a warning) will need to explicitly specify their CIDR ranges in ACL rules instead of using `*`
|
||||
- Validate autogroup:self source restrictions matching Tailscale behavior - tags, hosts, and IPs are rejected as sources for autogroup:self destinations [#3036](https://github.com/juanfont/headscale/pull/3036)
|
||||
- **ACL Policy**: Validate autogroup:self source restrictions matching Tailscale behavior - tags, hosts, and IPs are rejected as sources for autogroup:self destinations [#3036](https://github.com/juanfont/headscale/pull/3036)
|
||||
- Policies using tags, hosts, or IP addresses as sources for autogroup:self destinations will now fail validation
|
||||
- The `proto:icmp` protocol name now only includes ICMPv4 (protocol 1), matching Tailscale behavior [#3036](https://github.com/juanfont/headscale/pull/3036)
|
||||
- Previously, `proto:icmp` included both ICMPv4 and ICMPv6
|
||||
- Use `proto:ipv6-icmp` or protocol number `58` explicitly for ICMPv6
|
||||
|
||||
#### Upgrade Path
|
||||
|
||||
- Headscale now enforces a strict version upgrade path [#3083](https://github.com/juanfont/headscale/pull/3083)
|
||||
- **Upgrade path**: Headscale now enforces a strict version upgrade path [#3083](https://github.com/juanfont/headscale/pull/3083)
|
||||
- Skipping minor versions (e.g. 0.27 → 0.29) is blocked; upgrade one minor version at a time
|
||||
- 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)
|
||||
- **ACL Policy**: The `proto:icmp` protocol name now only includes ICMPv4 (protocol 1), matching Tailscale behavior [#3036](https://github.com/juanfont/headscale/pull/3036)
|
||||
- Previously, `proto:icmp` included both ICMPv4 and ICMPv6
|
||||
- Use `proto:ipv6-icmp` or protocol number `58` explicitly for ICMPv6
|
||||
- **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)
|
||||
- The old command continues to work but will be removed in a future release
|
||||
|
||||
### HA subnet router health probing
|
||||
|
||||
Headscale now actively probes HA subnet routers to detect nodes that are connected but not
|
||||
forwarding traffic. The control plane periodically pings HA subnet routers via the Noise
|
||||
control channel and fails over to a healthy standby if the primary stops responding. This is
|
||||
enabled by default (`node.routes.ha.probe_interval: 10s`, `probe_timeout: 5s`) and only
|
||||
active when HA routes exist (2+ nodes advertising the same prefix). Set `probe_interval` to
|
||||
`0` to disable. This complements the existing disconnect-based failover, catching "zombie
|
||||
connected" routers that maintain their control session but cannot route packets.
|
||||
|
||||
### Changes
|
||||
|
||||
#### ACL Policy
|
||||
|
||||
- Fix subnet-to-subnet peer visibility — subnet routers now correctly become peers when ACL rules reference only subnet CIDRs as sources, without requiring node IP rules [#3175](https://github.com/juanfont/headscale/pull/3175)
|
||||
- Fix filter rule reduction to use only approved subnet routes instead of all advertised routes, matching Tailscale SaaS behavior [#3175](https://github.com/juanfont/headscale/pull/3175)
|
||||
- Add ICMP and IPv6-ICMP protocols to default filter rules when no protocol is specified [#3036](https://github.com/juanfont/headscale/pull/3036)
|
||||
- Fix autogroup:self handling for tagged nodes - tagged nodes no longer incorrectly receive autogroup:self filter rules [#3036](https://github.com/juanfont/headscale/pull/3036)
|
||||
- Use CIDR format for autogroup:self destination IPs matching Tailscale behavior [#3036](https://github.com/juanfont/headscale/pull/3036)
|
||||
- Merge filter rules with identical SrcIPs and IPProto matching Tailscale behavior - multiple ACL rules with the same source now produce a single FilterRule with combined DstPorts [#3036](https://github.com/juanfont/headscale/pull/3036)
|
||||
- Fix exit nodes incorrectly receiving filter rules for destinations that only overlap via exit routes [#3169](https://github.com/juanfont/headscale/issues/3169) [#3175](https://github.com/juanfont/headscale/pull/3175)
|
||||
- **OIDC registration**: Add a confirmation page before completing node registration, showing the device hostname and machine key fingerprint [#3180](https://github.com/juanfont/headscale/pull/3180)
|
||||
- **Debug endpoints**: Omit secret fields (`Pass`, `ClientSecret`, `APIKey`) from `/debug/config` JSON output [#3180](https://github.com/juanfont/headscale/pull/3180)
|
||||
- **Debug endpoints**: Route `statsviz` through `tsweb.Protected` [#3180](https://github.com/juanfont/headscale/pull/3180)
|
||||
- Remove gRPC reflection from the remote (TCP) server [#3180](https://github.com/juanfont/headscale/pull/3180)
|
||||
- **Node Expiry**: Add `node.expiry` configuration option to set a default node key expiry for nodes registered via auth key [#3122](https://github.com/juanfont/headscale/pull/3122)
|
||||
- Tagged nodes (registered with tagged pre-auth keys) are exempt from default expiry
|
||||
- `oidc.expiry` has been removed; use `node.expiry` instead (applies to all registration methods including OIDC)
|
||||
- `ephemeral_node_inactivity_timeout` is deprecated in favour of `node.ephemeral.inactivity_timeout`
|
||||
- **SSH Policy**: Add support for `localpart:*@<domain>` in SSH rule `users` field, mapping each matching user's email local-part as their OS username [#3091](https://github.com/juanfont/headscale/pull/3091)
|
||||
- **ACL Policy**: Add ICMP and IPv6-ICMP protocols to default filter rules when no protocol is specified [#3036](https://github.com/juanfont/headscale/pull/3036)
|
||||
- **ACL Policy**: Fix autogroup:self handling for tagged nodes - tagged nodes no longer incorrectly receive autogroup:self filter rules [#3036](https://github.com/juanfont/headscale/pull/3036)
|
||||
- **ACL Policy**: Use CIDR format for autogroup:self destination IPs matching Tailscale behavior [#3036](https://github.com/juanfont/headscale/pull/3036)
|
||||
- **ACL Policy**: Merge filter rules with identical SrcIPs and IPProto matching Tailscale behavior - multiple ACL rules with the same source now produce a single FilterRule with combined DstPorts [#3036](https://github.com/juanfont/headscale/pull/3036)
|
||||
- Remove deprecated `--namespace` flag from `nodes list`, `nodes register`, and `debug create-node` commands (use `--user` instead) [#3093](https://github.com/juanfont/headscale/pull/3093)
|
||||
- Remove deprecated `namespace`/`ns` command aliases for `users` and `machine`/`machines` aliases for `nodes` [#3093](https://github.com/juanfont/headscale/pull/3093)
|
||||
- Add SSH `check` action support with OIDC and CLI-based approval flows [#1850](https://github.com/juanfont/headscale/pull/1850)
|
||||
- Add `headscale auth register`, `headscale auth approve`, and `headscale auth reject` CLI commands [#1850](https://github.com/juanfont/headscale/pull/1850)
|
||||
- Add `auth` related routes to the API. The `auth/register` endpoint now expects data as JSON [#1850](https://github.com/juanfont/headscale/pull/1850)
|
||||
- Deprecate `headscale nodes register --key` in favour of `headscale auth register --auth-id` [#1850](https://github.com/juanfont/headscale/pull/1850)
|
||||
- Generalise auth templates into reusable `AuthSuccess` and `AuthWeb` components [#1850](https://github.com/juanfont/headscale/pull/1850)
|
||||
- Unify auth pipeline with `AuthVerdict` type, supporting registration, reauthentication, and SSH checks [#1850](https://github.com/juanfont/headscale/pull/1850)
|
||||
- Add support for policy grants with `ip`, `app`, and `via` fields [#2180](https://github.com/juanfont/headscale/pull/2180)
|
||||
- Add `autogroup:danger-all` as a source-only autogroup resolving to all IP addresses [#2180](https://github.com/juanfont/headscale/pull/2180)
|
||||
- Add capability grants for Taildrive (`cap/drive`) and peer relay (`cap/relay`) with automatic companion capabilities [#2180](https://github.com/juanfont/headscale/pull/2180)
|
||||
- Add per-viewer via route steering — grants with `via` tags control which subnet router or exit node handles traffic for each group of viewers [#2180](https://github.com/juanfont/headscale/pull/2180)
|
||||
- Enable Taildrive node attributes on all nodes; actual access is controlled by `cap/drive` grants [#2180](https://github.com/juanfont/headscale/pull/2180)
|
||||
- Fix exit nodes incorrectly receiving filter rules for destinations that only overlap via exit routes [#2180](https://github.com/juanfont/headscale/pull/2180)
|
||||
- Fix address-based aliases (hosts, raw IPs) incorrectly expanding to include the matching node's other address family [#2180](https://github.com/juanfont/headscale/pull/2180)
|
||||
- Fix identity-based aliases (tags, users, groups) resolving to IPv4 only; they now include both IPv4 and IPv6 matching Tailscale behavior [#2180](https://github.com/juanfont/headscale/pull/2180)
|
||||
- Fix wildcard (`*`) source in ACLs now using actually-approved subnet routes instead of autoApprover policy prefixes [#2180](https://github.com/juanfont/headscale/pull/2180)
|
||||
- Fix non-wildcard source IPs being dropped when combined with wildcard `*` in the same ACL rule [#2180](https://github.com/juanfont/headscale/pull/2180)
|
||||
- Fix exit node approval not triggering filter rule recalculation for peers [#2180](https://github.com/juanfont/headscale/pull/2180)
|
||||
- Policy validation error messages now include field context (e.g., `src=`, `dst=`) and are more descriptive [#2180](https://github.com/juanfont/headscale/pull/2180)
|
||||
- Reject policies whose `user@` tokens match multiple DB users; rename the duplicate via `headscale users rename` to load [#3160](https://github.com/juanfont/headscale/issues/3160)
|
||||
- Evaluate the policy `tests` block on user-initiated writes across both `acls` and `grants`; reject policies whose tests fail (beta) [#1803](https://github.com/juanfont/headscale/issues/1803)
|
||||
|
||||
#### Grants
|
||||
## 0.28.1 (202x-xx-xx)
|
||||
|
||||
- Add support for policy grants with `ip`, `app`, and `via` fields [#2180](https://github.com/juanfont/headscale/pull/2180)
|
||||
- Add `autogroup:danger-all` as a source-only autogroup resolving to all IP addresses [#2180](https://github.com/juanfont/headscale/pull/2180)
|
||||
- Add capability grants for Taildrive (`cap/drive`) and peer relay (`cap/relay`) with automatic companion capabilities [#2180](https://github.com/juanfont/headscale/pull/2180)
|
||||
- Add per-viewer via route steering — grants with `via` tags control which subnet router or exit node handles traffic for each group of viewers [#2180](https://github.com/juanfont/headscale/pull/2180)
|
||||
- Enable Taildrive node attributes on all nodes; actual access is controlled by `cap/drive` grants [#2180](https://github.com/juanfont/headscale/pull/2180)
|
||||
### Changes
|
||||
|
||||
#### SSH Policy
|
||||
|
||||
- Add support for `localpart:*@<domain>` in SSH rule `users` field, mapping each matching user's email local-part as their OS username [#3091](https://github.com/juanfont/headscale/pull/3091)
|
||||
- Add SSH `check` action support with OIDC and CLI-based approval flows [#1850](https://github.com/juanfont/headscale/pull/1850)
|
||||
|
||||
#### CLI
|
||||
|
||||
- Add `headscale auth register`, `headscale auth approve`, and `headscale auth reject` CLI commands [#1850](https://github.com/juanfont/headscale/pull/1850)
|
||||
- Deprecate `headscale nodes register --key` in favour of `headscale auth register --auth-id` [#1850](https://github.com/juanfont/headscale/pull/1850)
|
||||
- `headscale policy check --bypass-grpc-and-access-database-directly` validates `user@` tokens against the live user database [#3160](https://github.com/juanfont/headscale/issues/3160)
|
||||
- Remove deprecated `--namespace` flag from `nodes list`, `nodes register`, and `debug create-node` commands (use `--user` instead) [#3093](https://github.com/juanfont/headscale/pull/3093)
|
||||
- Remove deprecated `namespace`/`ns` command aliases for `users` and `machine`/`machines` aliases for `nodes` [#3093](https://github.com/juanfont/headscale/pull/3093)
|
||||
- **User deletion**: Fix `DestroyUser` deleting all pre-auth keys in the database instead of only the target user's keys [#3155](https://github.com/juanfont/headscale/pull/3155)
|
||||
- `headscale policy check` evaluates the `tests` block when invoked with `--bypass-grpc-and-access-database-directly`; without the flag it warns instead of running the tests against empty data [#1803](https://github.com/juanfont/headscale/issues/1803)
|
||||
|
||||
#### API
|
||||
|
||||
- Add `auth` related routes. The `auth/register` endpoint now expects data as JSON [#1850](https://github.com/juanfont/headscale/pull/1850)
|
||||
- Remove gRPC reflection from the remote (TCP) server [#3180](https://github.com/juanfont/headscale/pull/3180)
|
||||
|
||||
#### OIDC
|
||||
|
||||
- Add a confirmation page before completing node registration, showing the device hostname and machine key fingerprint [#3180](https://github.com/juanfont/headscale/pull/3180)
|
||||
- Generalise auth templates into reusable `AuthSuccess` and `AuthWeb` components [#1850](https://github.com/juanfont/headscale/pull/1850)
|
||||
- Unify auth pipeline with `AuthVerdict` type, supporting registration, reauthentication, and SSH checks [#1850](https://github.com/juanfont/headscale/pull/1850)
|
||||
|
||||
#### Configuration
|
||||
|
||||
- Add `node.expiry` configuration option to set a default node key expiry for nodes registered via auth key [#3122](https://github.com/juanfont/headscale/pull/3122)
|
||||
- Tagged nodes (registered with tagged pre-auth keys) are exempt from default expiry
|
||||
- `oidc.expiry` has been removed; use `node.expiry` instead (applies to all registration methods including OIDC)
|
||||
- `ephemeral_node_inactivity_timeout` is deprecated in favour of `node.ephemeral.inactivity_timeout`
|
||||
|
||||
#### Debug
|
||||
|
||||
- Add node connectivity ping page for verifying control-plane reachability [#3183](https://github.com/juanfont/headscale/pull/3183)
|
||||
- Omit secret fields (`Pass`, `ClientSecret`, `APIKey`) from `/debug/config` JSON output [#3180](https://github.com/juanfont/headscale/pull/3180)
|
||||
- Route `statsviz` through `tsweb.Protected` [#3180](https://github.com/juanfont/headscale/pull/3180)
|
||||
|
||||
#### Other
|
||||
|
||||
- Remove old migrations for the debian package [#3185](https://github.com/juanfont/headscale/pull/3185)
|
||||
- Install `config-example.yaml` as example for the debian package [#3186](https://github.com/juanfont/headscale/pull/3186)
|
||||
- **Node Expiry**: Fix user owned re registration with zero client expiry and no default storing `0001-01-01 00:00:00` in the database instead of NULL [#3199](https://github.com/juanfont/headscale/pull/3199)
|
||||
- Pre-existing rows with `0001-01-01 00:00:00` are not backfilled; they clear themselves the next time the node re-registers
|
||||
|
||||
## 0.28.0 (2026-02-04)
|
||||
|
||||
@@ -338,7 +111,7 @@ connected" routers that maintain their control session but cannot route packets.
|
||||
Tags are now implemented following the Tailscale model where tags and user ownership are mutually exclusive. Devices can be either
|
||||
user-owned (authenticated via web/OIDC) or tagged (authenticated via tagged PreAuthKeys). Tagged devices receive their identity from
|
||||
tags rather than users, making them suitable for servers and infrastructure. Applying a tag to a device removes user-based
|
||||
ownership. See the [Tailscale tags documentation](https://tailscale.com/docs/features/tags) for details on how tags work.
|
||||
ownership. See the [Tailscale tags documentation](https://tailscale.com/kb/1068/tags) for details on how tags work.
|
||||
|
||||
User-owned nodes can now request tags during registration using `--advertise-tags`. Tags are validated against the `tagOwners` policy
|
||||
and applied at registration time. Tags can be managed via the CLI or API after registration. Tagged nodes can return to user-owned
|
||||
@@ -437,7 +210,7 @@ sequentially through each stable release, selecting the latest patch version ava
|
||||
|
||||
- **SSH Policy**: SSH source/destination validation now enforces Tailscale's security model [#3010](https://github.com/juanfont/headscale/issues/3010)
|
||||
|
||||
Per [Tailscale SSH documentation](https://tailscale.com/docs/features/tailscale-ssh), the following rules are now enforced:
|
||||
Per [Tailscale SSH documentation](https://tailscale.com/kb/1193/tailscale-ssh), the following rules are now enforced:
|
||||
1. **Tags cannot SSH to user-owned devices**: SSH rules with `tag:*` or `autogroup:tagged` as source cannot have username destinations (e.g., `alice@`) or `autogroup:member`/`autogroup:self` as destination
|
||||
2. **Username destinations require same-user source**: If destination is a specific username (e.g., `alice@`), the source must be that exact same user only. Use `autogroup:self` for same-user SSH access instead
|
||||
|
||||
@@ -566,8 +339,8 @@ DERPMap updates when upstream is changed.
|
||||
|
||||
This release adds support for the three missing autogroups: `self`
|
||||
(experimental), `member`, and `tagged`. Please refer to the
|
||||
[documentation](https://tailscale.com/docs/reference/targets-and-selectors#autogroups)
|
||||
for a detailed explanation.
|
||||
[documentation](https://tailscale.com/kb/1018/autogroups/) for a detailed
|
||||
explanation.
|
||||
|
||||
`autogroup:self` is marked as experimental and should be used with caution, but
|
||||
we need help testing it. Experimental here means two things; first, generating
|
||||
@@ -730,7 +503,7 @@ The SSH policy has been reworked to be more consistent with the rest of the
|
||||
policy. In addition, several inconsistencies between our implementation and
|
||||
Tailscale's upstream has been closed and this might be a breaking change for
|
||||
some users. Please refer to the
|
||||
[upstream documentation](https://tailscale.com/docs/reference/syntax/policy-file#tailscale-ssh)
|
||||
[upstream documentation](https://tailscale.com/kb/1337/acl-syntax#tailscale-ssh)
|
||||
for more information on which types are allowed in `src`, `dst` and `users`.
|
||||
|
||||
There is one large inconsistency left, we allow `*` as a destination as we
|
||||
@@ -1244,7 +1017,7 @@ part of adopting [#1460](https://github.com/juanfont/headscale/pull/1460).
|
||||
|
||||
- Added support for Tailscale TS2021 protocol [#738](https://github.com/juanfont/headscale/pull/738)
|
||||
- Add experimental support for
|
||||
[SSH ACL](https://tailscale.com/docs/reference/syntax/policy-file#tailscale-ssh) (see docs for
|
||||
[SSH ACL](https://tailscale.com/kb/1018/acls/#tailscale-ssh) (see docs for
|
||||
limitations) [#847](https://github.com/juanfont/headscale/pull/847)
|
||||
- Please note that this support should be considered _partially_ implemented
|
||||
- SSH ACLs status:
|
||||
@@ -1321,7 +1094,7 @@ part of adopting [#1460](https://github.com/juanfont/headscale/pull/1460).
|
||||
### BREAKING
|
||||
|
||||
- Old ACL syntax is no longer supported ("users" & "ports" -> "src" & "dst").
|
||||
Please check [the new syntax](https://tailscale.com/docs/features/access-control/acls).
|
||||
Please check [the new syntax](https://tailscale.com/kb/1018/acls/).
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -1351,7 +1124,7 @@ part of adopting [#1460](https://github.com/juanfont/headscale/pull/1460).
|
||||
- Add -c option to specify config file from command line [#285](https://github.com/juanfont/headscale/issues/285)
|
||||
[#612](https://github.com/juanfont/headscale/pull/601)
|
||||
- Add configuration option to allow Tailscale clients to use a random WireGuard
|
||||
port. [Tailscale docs](https://tailscale.com/docs/reference/syntax/policy-file#randomizeclientport)
|
||||
port. [kb/1181/firewalls](https://tailscale.com/kb/1181/firewalls)
|
||||
[#624](https://github.com/juanfont/headscale/pull/624)
|
||||
- Improve obtuse UX regarding missing configuration
|
||||
(`ephemeral_node_inactivity_timeout` not set)
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# For testing purposes only
|
||||
|
||||
FROM golang:1.26.3-alpine AS build-env
|
||||
FROM golang:1.26.2-alpine AS build-env
|
||||
|
||||
WORKDIR /go/src
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# and are in no way endorsed by Headscale's maintainers as an
|
||||
# official nor supported release or distribution.
|
||||
|
||||
FROM docker.io/golang:1.26.2-trixie AS builder
|
||||
FROM docker.io/golang:1.26.1-trixie AS builder
|
||||
ARG VERSION=dev
|
||||
ENV GOPATH /go
|
||||
WORKDIR /go/src/headscale
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# This Dockerfile is more or less lifted from tailscale/tailscale
|
||||
# to ensure a similar build process when testing the HEAD of tailscale.
|
||||
|
||||
FROM golang:1.26.3-alpine AS build-env
|
||||
FROM golang:1.26.2-alpine AS build-env
|
||||
|
||||
WORKDIR /go/src
|
||||
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
FROM rust:1.94-bookworm AS builder
|
||||
|
||||
ARG TAILSCALE_RS_REPO=https://github.com/tailscale/tailscale-rs.git
|
||||
ARG TAILSCALE_RS_REF=main
|
||||
|
||||
WORKDIR /app
|
||||
RUN git clone --depth 1 --branch "$TAILSCALE_RS_REF" "$TAILSCALE_RS_REPO" .
|
||||
|
||||
# Re-export ts_control's insecure-keyfetch feature through the tailscale
|
||||
# crate so the axum example can fetch the headscale control key over
|
||||
# plain HTTP. The integration harness serves the control plane without
|
||||
# TLS, and upstream only allows plain-HTTP key fetches when this Cargo
|
||||
# feature is compiled in.
|
||||
RUN sed -i '/^axum = \["dep:axum"\]/a insecure-keyfetch = ["ts_control/insecure-keyfetch"]' Cargo.toml
|
||||
|
||||
RUN cargo build --release --features axum,insecure-keyfetch --example axum
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
iproute2 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /app/target/release/examples/axum /usr/local/bin/axum
|
||||
@@ -105,11 +105,6 @@ clean:
|
||||
.PHONY: dev
|
||||
dev: fmt lint test build
|
||||
|
||||
# Start a local headscale dev server (use mts to add nodes)
|
||||
.PHONY: dev-server
|
||||
dev-server:
|
||||
go run ./cmd/dev
|
||||
|
||||
# Help target
|
||||
.PHONY: help
|
||||
help:
|
||||
|
||||
@@ -30,8 +30,8 @@ nodes in the Tailscale network. It assigns the IP addresses of the clients,
|
||||
creates the boundaries between each user, enables sharing machines between users,
|
||||
and exposes the advertised routes of your nodes.
|
||||
|
||||
A [Tailscale network (tailnet)](https://tailscale.com/docs/concepts/tailnet) is
|
||||
private network which Tailscale assigns to a user in terms of private users or an
|
||||
A [Tailscale network (tailnet)](https://tailscale.com/kb/1136/tailnet/) is private
|
||||
network which Tailscale assigns to a user in terms of private users or an
|
||||
organisation.
|
||||
|
||||
## Design goal
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
# cmd/dev -- Local Development Environment
|
||||
|
||||
Starts a headscale server on localhost with a pre-created user and
|
||||
pre-auth key. Pair with `mts` to add real tailscale nodes.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# Terminal 1: start headscale
|
||||
go run ./cmd/dev
|
||||
|
||||
# Terminal 2: start mts server
|
||||
go tool mts server run
|
||||
|
||||
# Terminal 3: add and connect nodes
|
||||
go tool mts server add node1
|
||||
go tool mts server add node2
|
||||
|
||||
# Disable logtail (avoids startup delays, see "Known issues" below)
|
||||
for n in node1 node2; do
|
||||
cat > ~/.config/multi-tailscale-dev/$n/env.txt << 'EOF'
|
||||
TS_NO_LOGS_NO_SUPPORT=true
|
||||
EOF
|
||||
done
|
||||
|
||||
# Restart nodes so env.txt takes effect
|
||||
go tool mts server stop node1 && go tool mts server start node1
|
||||
go tool mts server stop node2 && go tool mts server start node2
|
||||
|
||||
# Connect to headscale (use the auth key printed by cmd/dev)
|
||||
go tool mts node1 up --login-server=http://127.0.0.1:8080 --authkey=<KEY> --reset
|
||||
go tool mts node2 up --login-server=http://127.0.0.1:8080 --authkey=<KEY> --reset
|
||||
|
||||
# Verify
|
||||
go tool mts node1 status
|
||||
```
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Default | Description |
|
||||
| -------- | ------- | ---------------------------- |
|
||||
| `--port` | 8080 | Headscale listen port |
|
||||
| `--keep` | false | Keep state directory on exit |
|
||||
|
||||
The metrics/debug port is `port + 1010` (default 9090) and the gRPC
|
||||
port is `port + 42363` (default 50443).
|
||||
|
||||
## What it does
|
||||
|
||||
1. Builds the headscale binary into a temp directory
|
||||
2. Writes a minimal dev config (SQLite, public DERP, debug logging)
|
||||
3. Starts `headscale serve` as a subprocess
|
||||
4. Creates a "dev" user and a reusable 24h pre-auth key via the CLI
|
||||
5. Prints a banner with server URL, auth key, and usage instructions
|
||||
6. Blocks until Ctrl+C, then kills headscale
|
||||
|
||||
State lives in `/tmp/headscale-dev-*/`. Pass `--keep` to preserve it
|
||||
across restarts (useful for inspecting the database or reusing keys).
|
||||
|
||||
## Useful endpoints
|
||||
|
||||
- `http://127.0.0.1:8080/health` -- health check
|
||||
- `http://127.0.0.1:9090/debug/ping` -- interactive ping UI
|
||||
- `http://127.0.0.1:9090/debug/ping?node=1` -- quick-ping a node
|
||||
- `POST http://127.0.0.1:9090/debug/ping` with `node=<id>` -- trigger ping
|
||||
|
||||
## Managing headscale
|
||||
|
||||
The banner prints the full path to the built binary and config. Use it
|
||||
for any headscale CLI command:
|
||||
|
||||
```bash
|
||||
/tmp/headscale-dev-*/headscale -c /tmp/headscale-dev-*/config.yaml nodes list
|
||||
/tmp/headscale-dev-*/headscale -c /tmp/headscale-dev-*/config.yaml users list
|
||||
```
|
||||
|
||||
## Known issues
|
||||
|
||||
### Logtail delays on mts nodes
|
||||
|
||||
Freshly created `mts` instances may take 30+ seconds to start if
|
||||
`~/.local/share/tailscale/` contains stale logtail cache from previous
|
||||
tailscaled runs. The daemon blocks trying to upload old logs before
|
||||
creating its socket.
|
||||
|
||||
Fix: write `TS_NO_LOGS_NO_SUPPORT=true` to each instance's `env.txt`
|
||||
before starting (or restart after writing). See the quick start above.
|
||||
|
||||
### mts node cleanup
|
||||
|
||||
`mts` stores state in `~/.config/multi-tailscale-dev/`. Old instances
|
||||
accumulate over time. Clean them with:
|
||||
|
||||
```bash
|
||||
go tool mts server rm <name>
|
||||
```
|
||||
-314
@@ -1,314 +0,0 @@
|
||||
// cmd/dev starts a local headscale development server with a pre-created
|
||||
// user and pre-auth key, ready for connecting tailscale nodes via mts.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
port = flag.Int("port", 8080, "headscale listen port")
|
||||
keep = flag.Bool("keep", false, "keep state directory on exit")
|
||||
)
|
||||
|
||||
var errHealthTimeout = errors.New("health check timed out")
|
||||
|
||||
var errEmptyAuthKey = errors.New("empty auth key in response")
|
||||
|
||||
// maxDevPort is the highest --port value that keeps both the derived
|
||||
// metrics port (port+1010) and gRPC port (port+42363) inside the valid
|
||||
// 1..65535 TCP range.
|
||||
const maxDevPort = 23172
|
||||
|
||||
const devConfig = `---
|
||||
server_url: http://127.0.0.1:%d
|
||||
listen_addr: 127.0.0.1:%d
|
||||
metrics_listen_addr: 127.0.0.1:%d
|
||||
grpc_listen_addr: 127.0.0.1:%d
|
||||
grpc_allow_insecure: true
|
||||
|
||||
noise:
|
||||
private_key_path: %s/noise_private.key
|
||||
|
||||
prefixes:
|
||||
v4: 100.64.0.0/10
|
||||
v6: fd7a:115c:a1e0::/48
|
||||
allocation: sequential
|
||||
|
||||
database:
|
||||
type: sqlite
|
||||
sqlite:
|
||||
path: %s/db.sqlite
|
||||
write_ahead_log: true
|
||||
|
||||
derp:
|
||||
server:
|
||||
enabled: false
|
||||
urls:
|
||||
- https://controlplane.tailscale.com/derpmap/default
|
||||
auto_update_enabled: false
|
||||
|
||||
dns:
|
||||
magic_dns: true
|
||||
base_domain: headscale.dev
|
||||
override_local_dns: false
|
||||
|
||||
log:
|
||||
level: debug
|
||||
format: text
|
||||
|
||||
policy:
|
||||
mode: database
|
||||
|
||||
unix_socket: %s/headscale.sock
|
||||
unix_socket_permission: "0770"
|
||||
`
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
log.SetFlags(0)
|
||||
|
||||
if *port < 1 || *port > maxDevPort {
|
||||
log.Fatalf(
|
||||
"--port must be in 1..%d (higher values overflow the derived gRPC port); got %d",
|
||||
maxDevPort, *port,
|
||||
)
|
||||
}
|
||||
|
||||
http.DefaultClient.Timeout = 2 * time.Second
|
||||
http.DefaultClient.CheckRedirect = func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
|
||||
err := run()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
metricsPort := *port + 1010 // default 9090
|
||||
grpcPort := *port + 42363 // default 50443
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "headscale-dev-")
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating temp dir: %w", err)
|
||||
}
|
||||
|
||||
if !*keep {
|
||||
defer os.RemoveAll(tmpDir)
|
||||
}
|
||||
|
||||
// Write config.
|
||||
configPath := filepath.Join(tmpDir, "config.yaml")
|
||||
configContent := fmt.Sprintf(devConfig,
|
||||
*port, *port, metricsPort, grpcPort,
|
||||
tmpDir, tmpDir, tmpDir,
|
||||
)
|
||||
|
||||
err = os.WriteFile(configPath, []byte(configContent), 0o600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("writing config: %w", err)
|
||||
}
|
||||
|
||||
// Build headscale.
|
||||
fmt.Println("Building headscale...")
|
||||
|
||||
hsBin := filepath.Join(tmpDir, "headscale")
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
build := exec.CommandContext(ctx, "go", "build", "-o", hsBin, "./cmd/headscale")
|
||||
build.Stdout = os.Stdout
|
||||
build.Stderr = os.Stderr
|
||||
|
||||
err = build.Run()
|
||||
if err != nil {
|
||||
return fmt.Errorf("building headscale: %w", err)
|
||||
}
|
||||
|
||||
// Start headscale serve.
|
||||
fmt.Println("Starting headscale server...")
|
||||
|
||||
serve := exec.CommandContext(ctx, hsBin, "serve", "-c", configPath)
|
||||
serve.Stdout = os.Stdout
|
||||
serve.Stderr = os.Stderr
|
||||
|
||||
err = serve.Start()
|
||||
if err != nil {
|
||||
return fmt.Errorf("starting headscale: %w", err)
|
||||
}
|
||||
|
||||
// Wait for server to be ready.
|
||||
healthURL := fmt.Sprintf("http://127.0.0.1:%d/health", *port)
|
||||
|
||||
err = waitForHealth(ctx, healthURL, 30*time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("waiting for headscale: %w", err)
|
||||
}
|
||||
|
||||
// Create user.
|
||||
fmt.Println("Creating user and pre-auth key...")
|
||||
|
||||
userJSON, err := runHS(ctx, hsBin, configPath, "users", "create", "dev", "-o", "json")
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating user: %w", err)
|
||||
}
|
||||
|
||||
userID, err := extractUserID(userJSON)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing user: %w", err)
|
||||
}
|
||||
|
||||
// Create pre-auth key.
|
||||
keyJSON, err := runHS(
|
||||
ctx, hsBin, configPath,
|
||||
"preauthkeys", "create",
|
||||
"-u", strconv.FormatUint(userID, 10),
|
||||
"--reusable",
|
||||
"-e", "24h",
|
||||
"-o", "json",
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating pre-auth key: %w", err)
|
||||
}
|
||||
|
||||
authKey, err := extractAuthKey(keyJSON)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing pre-auth key: %w", err)
|
||||
}
|
||||
|
||||
// Print banner.
|
||||
fmt.Printf(`
|
||||
=== Headscale Dev Environment ===
|
||||
Server: http://127.0.0.1:%d
|
||||
Metrics: http://127.0.0.1:%d
|
||||
Debug: http://127.0.0.1:%d/debug/ping
|
||||
Config: %s
|
||||
State: %s
|
||||
|
||||
Pre-auth key: %s
|
||||
|
||||
Connect nodes with mts:
|
||||
go tool mts server run # start mts (once, another terminal)
|
||||
go tool mts server add node1 # create a node
|
||||
go tool mts node1 up --login-server=http://127.0.0.1:%d --authkey=%s
|
||||
go tool mts node1 status # check connection
|
||||
|
||||
Manage headscale:
|
||||
%s -c %s nodes list
|
||||
%s -c %s users list
|
||||
|
||||
Press Ctrl+C to stop.
|
||||
`,
|
||||
*port, metricsPort, metricsPort,
|
||||
configPath, tmpDir,
|
||||
authKey,
|
||||
*port, authKey,
|
||||
hsBin, configPath,
|
||||
hsBin, configPath,
|
||||
)
|
||||
|
||||
// Wait for headscale to exit.
|
||||
err = serve.Wait()
|
||||
if err != nil {
|
||||
// Context cancellation is expected on Ctrl+C.
|
||||
if ctx.Err() != nil {
|
||||
fmt.Println("\nShutting down...")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("headscale exited: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// waitForHealth polls the health endpoint until it returns 200 or the
|
||||
// timeout expires.
|
||||
func waitForHealth(ctx context.Context, url string, timeout time.Duration) error {
|
||||
deadline := time.Now().Add(timeout)
|
||||
|
||||
for time.Now().Before(deadline) {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Busy-wait is acceptable for a dev tool polling a local server.
|
||||
time.Sleep(200 * time.Millisecond) //nolint:forbidigo
|
||||
}
|
||||
|
||||
return errHealthTimeout
|
||||
}
|
||||
|
||||
// runHS executes a headscale CLI command and returns its stdout.
|
||||
func runHS(ctx context.Context, bin, config string, args ...string) ([]byte, error) {
|
||||
fullArgs := append([]string{"-c", config}, args...)
|
||||
cmd := exec.CommandContext(ctx, bin, fullArgs...)
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
return cmd.Output()
|
||||
}
|
||||
|
||||
// extractUserID parses the JSON output of "users create" and returns the
|
||||
// user ID.
|
||||
func extractUserID(data []byte) (uint64, error) {
|
||||
var user struct {
|
||||
ID uint64 `json:"id"`
|
||||
}
|
||||
|
||||
err := json.Unmarshal(data, &user)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("unmarshalling user JSON: %w (raw: %s)", err, data)
|
||||
}
|
||||
|
||||
return user.ID, nil
|
||||
}
|
||||
|
||||
// extractAuthKey parses the JSON output of "preauthkeys create" and
|
||||
// returns the key string.
|
||||
func extractAuthKey(data []byte) (string, error) {
|
||||
var key struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
err := json.Unmarshal(data, &key)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("unmarshalling key JSON: %w (raw: %s)", err, data)
|
||||
}
|
||||
|
||||
if key.Key == "" {
|
||||
return "", errEmptyAuthKey
|
||||
}
|
||||
|
||||
return key.Key, nil
|
||||
}
|
||||
@@ -81,7 +81,7 @@ var createAPIKeyCmd = &cobra.Command{
|
||||
Long: `
|
||||
Creates a new Api key, the Api key is only visible on creation
|
||||
and cannot be retrieved again.
|
||||
If you lose a key, create a new one and revoke (expire) the old one.`,
|
||||
If you loose a key, create a new one and revoke (expire) the old one.`,
|
||||
Aliases: []string{"c", "new"},
|
||||
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
|
||||
expiration, err := expirationFromFlag(cmd)
|
||||
|
||||
@@ -175,10 +175,8 @@ Use --disable to disable key expiry (node will never expire).`,
|
||||
now := time.Now()
|
||||
|
||||
expiryTime := now
|
||||
|
||||
if expiry != "" {
|
||||
var err error
|
||||
|
||||
expiryTime, err = time.Parse(time.RFC3339, expiry)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing expiry time: %w", err)
|
||||
@@ -399,7 +397,6 @@ func nodesToPtables(
|
||||
}
|
||||
|
||||
var ipBuilder strings.Builder
|
||||
|
||||
for _, addr := range node.GetIpAddresses() {
|
||||
ip, err := netip.ParseAddr(addr)
|
||||
if err == nil {
|
||||
|
||||
@@ -48,7 +48,6 @@ 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.")
|
||||
mustMarkRequired(checkPolicy, "file")
|
||||
policyCmd.AddCommand(checkPolicy)
|
||||
}
|
||||
@@ -64,7 +63,6 @@ var getPolicy = &cobra.Command{
|
||||
Aliases: []string{"show", "view", "fetch"},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
var policyData string
|
||||
|
||||
if bypass, _ := cmd.Flags().GetBool(bypassFlag); bypass {
|
||||
if !confirmAction(cmd, "DO NOT run this command if an instance of headscale is running, are you sure headscale is not running?") {
|
||||
return errAborted
|
||||
@@ -171,11 +169,6 @@ var setPolicy = &cobra.Command{
|
||||
var checkPolicy = &cobra.Command{
|
||||
Use: "check",
|
||||
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
|
||||
open the database directly when headscale is not running.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
policyPath, _ := cmd.Flags().GetString("file")
|
||||
|
||||
@@ -184,56 +177,9 @@ var checkPolicy = &cobra.Command{
|
||||
return fmt.Errorf("reading policy file: %w", err)
|
||||
}
|
||||
|
||||
if bypass, _ := cmd.Flags().GetBool(bypassFlag); bypass {
|
||||
if !confirmAction(cmd, "DO NOT run this command if an instance of headscale is running, are you sure headscale is not running?") {
|
||||
return errAborted
|
||||
}
|
||||
|
||||
d, err := bypassDatabase()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
users, err := d.ListUsers()
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading users: %w", err)
|
||||
}
|
||||
|
||||
nodes, err := d.ListNodes()
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading nodes: %w", err)
|
||||
}
|
||||
|
||||
// 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.
|
||||
pm, err := policy.NewPolicyManager(policyBytes, users, nodes.ViewSlice())
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing policy file: %w", err)
|
||||
}
|
||||
|
||||
_, err = pm.SetPolicy(policyBytes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("Policy is valid")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, client, conn, cancel, err := newHeadscaleCLIWithConfig()
|
||||
_, err = policy.NewPolicyManager(policyBytes, nil, views.Slice[types.NodeView]{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("connecting to headscale: %w", err)
|
||||
}
|
||||
defer cancel()
|
||||
defer conn.Close()
|
||||
|
||||
_, err = client.CheckPolicy(ctx, &v1.CheckPolicyRequest{Policy: string(policyBytes)})
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("parsing policy file: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Policy is valid")
|
||||
|
||||
@@ -3,6 +3,7 @@ package cli
|
||||
import (
|
||||
"os"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
@@ -21,6 +22,11 @@ func init() {
|
||||
return
|
||||
}
|
||||
|
||||
if slices.Contains(os.Args, "policy") && slices.Contains(os.Args, "check") {
|
||||
zerolog.SetGlobalLevel(zerolog.Disabled)
|
||||
return
|
||||
}
|
||||
|
||||
cobra.OnInitialize(initConfig)
|
||||
rootCmd.PersistentFlags().
|
||||
StringVarP(&cfgFile, "config", "c", "", "config file (default is /etc/headscale/config.yaml)")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+29
-54
@@ -20,7 +20,7 @@ listen_addr: 127.0.0.1:8080
|
||||
|
||||
# Address to listen to /metrics and /debug, you may want
|
||||
# to keep this endpoint private to your internal network
|
||||
# Use an empty value to disable the metrics listener.
|
||||
# Use an emty value to disable the metrics listener.
|
||||
metrics_listen_addr: 127.0.0.1:9090
|
||||
|
||||
# Address to listen for gRPC.
|
||||
@@ -128,7 +128,7 @@ derp:
|
||||
#
|
||||
# This option is mostly interesting for people hosting
|
||||
# their own DERP servers:
|
||||
# https://tailscale.com/docs/reference/derp-servers/custom-derp-servers
|
||||
# https://tailscale.com/kb/1118/custom-derp-servers/
|
||||
#
|
||||
# paths:
|
||||
# - /etc/headscale/derp-example.yaml
|
||||
@@ -165,26 +165,6 @@ node:
|
||||
# Time before an inactive ephemeral node is deleted.
|
||||
inactivity_timeout: 30m
|
||||
|
||||
# HA subnet router health probing.
|
||||
#
|
||||
# When HA routes exist (2+ nodes advertising the same prefix), headscale
|
||||
# pings each HA node every probe_interval via the Noise channel. If a node
|
||||
# fails to respond within probe_timeout it is marked unhealthy and the
|
||||
# primary role moves to the next healthy node. A node that later responds
|
||||
# is marked healthy again but does NOT reclaim primary (avoids flapping).
|
||||
#
|
||||
# Worst-case detection time is probe_interval + probe_timeout (15s default).
|
||||
# No-op when no HA routes exist. Set probe_interval to 0 to disable.
|
||||
routes:
|
||||
ha:
|
||||
# How often to ping HA subnet routers. Set to 0 to disable probing.
|
||||
# Must be >= 2s when enabled.
|
||||
probe_interval: 10s
|
||||
|
||||
# How long to wait for a ping response before marking a node unhealthy.
|
||||
# Must be >= 1s and less than probe_interval.
|
||||
probe_timeout: 5s
|
||||
|
||||
database:
|
||||
# Database type. Available options: sqlite, postgres
|
||||
# Please note that using Postgres is highly discouraged as it is only supported for legacy reasons.
|
||||
@@ -281,25 +261,25 @@ 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/kb/1018/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
|
||||
# - https://tailscale.com/blog/2021-09-private-dns-with-magicdns
|
||||
# - https://tailscale.com/kb/1054/dns/
|
||||
# - https://tailscale.com/kb/1081/magicdns/
|
||||
# - https://tailscale.com/blog/2021-09-private-dns-with-magicdns/
|
||||
#
|
||||
# Please note that for the DNS configuration to have any effect,
|
||||
# clients must have the `--accept-dns=true` option enabled. This is the
|
||||
@@ -309,12 +289,12 @@ policy:
|
||||
# Setting _any_ of the configuration and `--accept-dns=true` on the
|
||||
# clients will integrate with the DNS manager on the client or
|
||||
# overwrite /etc/resolv.conf.
|
||||
# https://tailscale.com/docs/reference/faq/dns-resolv-conf
|
||||
# https://tailscale.com/kb/1235/resolv-conf
|
||||
#
|
||||
# If you want stop Headscale from managing the DNS configuration
|
||||
# all the fields under `dns` should be set to empty values.
|
||||
dns:
|
||||
# Whether to use [MagicDNS](https://tailscale.com/docs/features/magicdns).
|
||||
# Whether to use [MagicDNS](https://tailscale.com/kb/1081/magicdns/).
|
||||
magic_dns: true
|
||||
|
||||
# Defines the base domain to create the hostnames for MagicDNS.
|
||||
@@ -336,11 +316,11 @@ dns:
|
||||
- 2606:4700:4700::1111
|
||||
- 2606:4700:4700::1001
|
||||
|
||||
# NextDNS (see https://tailscale.com/docs/integrations/nextdns).
|
||||
# NextDNS (see https://tailscale.com/kb/1218/nextdns/).
|
||||
# "abc123" is example NextDNS ID, replace with yours.
|
||||
# - https://dns.nextdns.io/abc123
|
||||
|
||||
# Split DNS (see https://tailscale.com/docs/reference/dns-in-tailscale#restricted-nameservers),
|
||||
# Split DNS (see https://tailscale.com/kb/1054/dns/),
|
||||
# a map of domains and which DNS server to use for each.
|
||||
split: {}
|
||||
# foo.bar.com:
|
||||
@@ -445,31 +425,26 @@ unix_socket_permission: "0770"
|
||||
# Logtail is Tailscales logging and auditing infrastructure, it allows the
|
||||
# control panel to instruct tailscale nodes to log their activity to a remote
|
||||
# server. To disable logging on the client side, please refer to:
|
||||
# https://tailscale.com/docs/features/logging#opt-out-of-client-logging
|
||||
# https://tailscale.com/kb/1011/log-mesh-traffic#opting-out-of-client-logging
|
||||
logtail:
|
||||
# Enable logtail for tailscale nodes of this Headscale instance.
|
||||
# As there is currently no support for overriding the log server in Headscale, this is
|
||||
# disabled by default. Enabling this will make your clients send logs to Tailscale Inc.
|
||||
enabled: false
|
||||
|
||||
# Taildrop configuration
|
||||
# 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.
|
||||
enabled: true
|
||||
# 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/kb/1181/firewalls/ for more information.
|
||||
randomize_client_port: false
|
||||
|
||||
# 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
|
||||
# Taildrop configuration
|
||||
# Taildrop is the file sharing feature of Tailscale, allowing nodes to send files to each other.
|
||||
# https://tailscale.com/kb/1106/taildrop/
|
||||
taildrop:
|
||||
# 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
|
||||
# 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.
|
||||
|
||||
+1
-2
@@ -1,5 +1,4 @@
|
||||
# If you plan to somehow use headscale, please deploy your own DERP infra.
|
||||
# See: https://tailscale.com/docs/reference/derp-servers/custom-derp-servers
|
||||
# If you plan to somehow use headscale, please deploy your own DERP infra: https://tailscale.com/kb/1118/custom-derp-servers/
|
||||
regions:
|
||||
1: null # Disable DERP region with ID 1
|
||||
900:
|
||||
|
||||
+7
-7
@@ -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,10 +142,10 @@ 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>.
|
||||
See also <https://tailscale.com/kb/1087/device-visibility>.
|
||||
|
||||
## My policy is stored in the database and Headscale refuses to start due to an invalid policy. How can I recover?
|
||||
|
||||
@@ -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.
|
||||
@@ -199,7 +199,7 @@ Nodes should reconnect within a few seconds and pickup their newly assigned IP a
|
||||
## How can I avoid to send logs to Tailscale Inc?
|
||||
|
||||
A Tailscale client [collects logs about its operation and connection attempts with other
|
||||
clients](https://tailscale.com/docs/features/logging#client-logs) and sends them to a central log service operated by
|
||||
clients](https://tailscale.com/kb/1011/log-mesh-traffic#client-logs) and sends them to a central log service operated by
|
||||
Tailscale Inc.
|
||||
|
||||
Headscale, by default, instructs clients to disable log submission to the central log service. This configuration is
|
||||
@@ -209,5 +209,5 @@ applied by a client once it successfully connected with Headscale. See the confi
|
||||
Alternatively, logging can also be disabled on the client side. This is independent of Headscale and opting out of
|
||||
client logging disables log submission early during client startup. The configuration is operating system specific and
|
||||
is usually achieved by setting the environment variable `TS_NO_LOGS_NO_SUPPORT=true` or by passing the flag
|
||||
`--no-logs-no-support` to `tailscaled`. See <https://tailscale.com/docs/features/logging#opt-out-of-client-logging> for
|
||||
details.
|
||||
`--no-logs-no-support` to `tailscaled`. See
|
||||
<https://tailscale.com/kb/1011/log-mesh-traffic#opting-out-of-client-logging> for details.
|
||||
|
||||
+13
-15
@@ -9,32 +9,30 @@ provides on overview of Headscale's feature and compatibility with the Tailscale
|
||||
- [x] [Web authentication](../ref/registration.md#web-authentication)
|
||||
- [x] [Pre authenticated key](../ref/registration.md#pre-authenticated-key)
|
||||
- [x] [DNS](../ref/dns.md)
|
||||
- [x] [MagicDNS](https://tailscale.com/docs/features/magicdns)
|
||||
- [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] [MagicDNS](https://tailscale.com/kb/1081/magicdns)
|
||||
- [x] [Global and restricted nameservers (split DNS)](https://tailscale.com/kb/1054/dns#nameservers)
|
||||
- [x] [search domains](https://tailscale.com/kb/1054/dns#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/kb/1106/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] [Auto approvers](https://tailscale.com/docs/reference/syntax/policy-file#auto-approvers) for [subnet
|
||||
- [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/kb/1396/targets#autogroups), currently: `autogroup:internet`,
|
||||
`autogroup:nonroot`, `autogroup:member`, `autogroup:tagged`, `autogroup:self`
|
||||
- [x] [Auto approvers](https://tailscale.com/kb/1337/acl-syntax#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)
|
||||
- [x] [Tailscale SSH](https://tailscale.com/docs/features/tailscale-ssh)
|
||||
- [x] [Tailscale SSH](https://tailscale.com/kb/1193/tailscale-ssh)
|
||||
- [x] [Node registration using Single-Sign-On (OpenID Connect)](../ref/oidc.md) ([GitHub label "OIDC"](https://github.com/juanfont/headscale/labels/OIDC))
|
||||
- [x] Basic registration
|
||||
- [x] Update user profile from identity provider
|
||||
- [ ] OIDC groups cannot be used in ACLs
|
||||
- [ ] [Funnel](https://tailscale.com/docs/features/tailscale-funnel) ([#1040](https://github.com/juanfont/headscale/issues/1040))
|
||||
- [ ] [Serve](https://tailscale.com/docs/features/tailscale-serve) ([#1234](https://github.com/juanfont/headscale/issues/1921))
|
||||
- [ ] [Network flow logs](https://tailscale.com/docs/features/logging/network-flow-logs) ([#1687](https://github.com/juanfont/headscale/issues/1687))
|
||||
- [ ] [Funnel](https://tailscale.com/kb/1223/funnel) ([#1040](https://github.com/juanfont/headscale/issues/1040))
|
||||
- [ ] [Serve](https://tailscale.com/kb/1312/serve) ([#1234](https://github.com/juanfont/headscale/issues/1921))
|
||||
- [ ] [Network flow logs](https://tailscale.com/kb/1219/network-flow-logs) ([#1687](https://github.com/juanfont/headscale/issues/1687))
|
||||
|
||||
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,288 @@
|
||||
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 https://tailscale.com/kb/1018/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
|
||||
[here](https://tailscale.com/kb/1018/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/kb/1192/acl-samples#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/kb/1192/acl-samples#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/kb/1068/acl-tags#defining-a-tag)
|
||||
// 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"]
|
||||
}
|
||||
```
|
||||
+8
-8
@@ -3,16 +3,16 @@
|
||||
Headscale and Tailscale provide debug and introspection capabilities that can be helpful when things don't work as
|
||||
expected. This page explains some debugging techniques to help pinpoint problems.
|
||||
|
||||
Please also have a look at [Tailscale's Troubleshooting guide](https://tailscale.com/docs/reference/troubleshooting). It
|
||||
offers a many tips and suggestions to troubleshoot common issues.
|
||||
Please also have a look at [Tailscale's Troubleshooting guide](https://tailscale.com/kb/1023/troubleshooting). It offers
|
||||
a many tips and suggestions to troubleshoot common issues.
|
||||
|
||||
## Tailscale
|
||||
|
||||
The Tailscale client itself offers many commands to introspect its state as well as the state of the network:
|
||||
|
||||
- [Check local network conditions](https://tailscale.com/docs/reference/tailscale-cli#netcheck): `tailscale netcheck`
|
||||
- [Get the client status](https://tailscale.com/docs/reference/tailscale-cli#status): `tailscale status --json`
|
||||
- [Get DNS status](https://tailscale.com/docs/reference/tailscale-cli#dns): `tailscale dns status --all`
|
||||
- [Check local network conditions](https://tailscale.com/kb/1080/cli#netcheck): `tailscale netcheck`
|
||||
- [Get the client status](https://tailscale.com/kb/1080/cli#status): `tailscale status --json`
|
||||
- [Get DNS status](https://tailscale.com/kb/1080/cli#dns): `tailscale dns status --all`
|
||||
- Client logs: `tailscale debug daemon-logs`
|
||||
- Client netmap: `tailscale debug netmap`
|
||||
- Test DERP connection: `tailscale debug derp headscale`
|
||||
@@ -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
|
||||
|
||||
+11
-11
@@ -1,13 +1,13 @@
|
||||
# DERP
|
||||
|
||||
A [DERP (Designated Encrypted Relay for Packets) server](https://tailscale.com/docs/reference/derp-servers) is mainly
|
||||
used to relay traffic between two nodes in case a direct connection can't be established. Headscale provides an embedded
|
||||
DERP server to ensure seamless connectivity between nodes.
|
||||
A [DERP (Designated Encrypted Relay for Packets) server](https://tailscale.com/kb/1232/derp-servers) is mainly used to
|
||||
relay traffic between two nodes in case a direct connection can't be established. Headscale provides an embedded 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
|
||||
@@ -31,8 +31,8 @@ traversal. [Check DERP server connectivity](#check-derp-server-connectivity) to
|
||||
### Remove Tailscale's DERP servers
|
||||
|
||||
Once enabled, Headscale's embedded DERP is added to the list of free-to-use [DERP
|
||||
servers](https://tailscale.com/docs/reference/derp-servers) offered by Tailscale Inc. To only use Headscale's embedded
|
||||
DERP server, disable the loading of the default DERP map:
|
||||
servers](https://tailscale.com/kb/1232/derp-servers) offered by Tailscale Inc. To only use Headscale's embedded DERP
|
||||
server, disable the loading of the default DERP map:
|
||||
|
||||
```yaml title="config.yaml" hl_lines="6"
|
||||
derp:
|
||||
@@ -59,8 +59,8 @@ maps fetched via URL or to offer your own, custom DERP servers to nodes.
|
||||
|
||||
=== "Remove specific DERP regions"
|
||||
|
||||
The free-to-use [DERP servers](https://tailscale.com/docs/reference/derp-servers) are organized into regions via a
|
||||
region ID. You can explicitly disable a specific region by setting its region ID to `null`. The following sample
|
||||
The free-to-use [DERP servers](https://tailscale.com/kb/1232/derp-servers) are organized into regions via a region
|
||||
ID. You can explicitly disable a specific region by setting its region ID to `null`. The following sample
|
||||
`derp.yaml` disables the New York DERP region (which has the region ID 1):
|
||||
|
||||
```yaml title="derp.yaml"
|
||||
@@ -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.
|
||||
|
||||
+6
-6
@@ -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:
|
||||
[MagicDNS](https://tailscale.com/kb/1081/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:
|
||||
|
||||
- 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,9 +19,8 @@ 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
|
||||
|
||||
You can ask for support on our [Discord server](https://discord.gg/c84AZQhmpx) in the "web-interfaces" channel.
|
||||
|
||||
+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.
|
||||
@@ -11,8 +11,8 @@ Tailscale's identity model distinguishes between personal and tagged nodes:
|
||||
workstations or mobile phones. End-user devices are managed by a single user.
|
||||
- A tagged node (or service-based node or non-human node) provides services to the network. Common examples include web-
|
||||
and database servers. Those nodes are typically managed by a team of users. Some additional restrictions apply for
|
||||
tagged nodes, e.g. a tagged node is not allowed to [Tailscale SSH](https://tailscale.com/docs/features/tailscale-ssh)
|
||||
into a personal node.
|
||||
tagged nodes, e.g. a tagged node is not allowed to [Tailscale SSH](https://tailscale.com/kb/1193/tailscale-ssh) into a
|
||||
personal node.
|
||||
|
||||
Headscale implements Tailscale's identity model and distinguishes between personal and tagged nodes where a personal
|
||||
node is owned by a Headscale user and a tagged node is owned by a tag. Tagged devices are grouped under the special user
|
||||
@@ -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/kb/1337/policy-syntax#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"
|
||||
{
|
||||
|
||||
+61
-56
@@ -1,8 +1,7 @@
|
||||
# Routes
|
||||
|
||||
Headscale supports route advertising and can be used to manage [subnet
|
||||
routers](https://tailscale.com/docs/features/subnet-routers) and [exit
|
||||
nodes](https://tailscale.com/docs/features/exit-nodes) for a tailnet.
|
||||
Headscale supports route advertising and can be used to manage [subnet routers](https://tailscale.com/kb/1019/subnets)
|
||||
and [exit nodes](https://tailscale.com/kb/1103/exit-nodes) for a tailnet.
|
||||
|
||||
- [Subnet routers](#subnet-router) may be used to connect an existing network such as a virtual
|
||||
private cloud or an on-premise network with your tailnet. Use a subnet router to access devices where Tailscale can't
|
||||
@@ -73,32 +72,32 @@ $ sudo tailscale set --accept-routes
|
||||
```
|
||||
|
||||
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.
|
||||
documentation](https://tailscale.com/kb/1019/subnets#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 +106,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 +123,7 @@ that advertises the tag `tag:router`.
|
||||
"192.168.0.0/24": ["tag:router"]
|
||||
}
|
||||
},
|
||||
"grants": [
|
||||
"acls": [
|
||||
// more rules
|
||||
]
|
||||
}
|
||||
@@ -136,9 +135,8 @@ Advertise the route `192.168.0.0/24` from a subnet router that also advertises t
|
||||
$ sudo tailscale up --login-server <YOUR_HEADSCALE_URL> --advertise-tags tag:router --advertise-routes 192.168.0.0/24
|
||||
```
|
||||
|
||||
Please see the [official Tailscale
|
||||
documentation](https://tailscale.com/docs/reference/syntax/policy-file#auto-approvers) for more information on auto
|
||||
approvers.
|
||||
Please see the [official Tailscale documentation](https://tailscale.com/kb/1337/acl-syntax#autoapprovers) for more
|
||||
information on auto approvers.
|
||||
|
||||
## Exit node
|
||||
|
||||
@@ -201,22 +199,22 @@ The exit node can now be used on a node with:
|
||||
$ 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.
|
||||
Please refer to the official [Tailscale documentation](https://tailscale.com/kb/1103/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 +222,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 +270,7 @@ in the `autoApprovers` section. A new exit node that advertises the tag `tag:exi
|
||||
"autoApprovers": {
|
||||
"exitNode": ["tag:exit"]
|
||||
},
|
||||
"grants": [
|
||||
"acls": [
|
||||
// more rules
|
||||
]
|
||||
}
|
||||
@@ -280,23 +282,26 @@ Advertise a node as exit node and also advertise the tag `tag:exit` when joining
|
||||
$ sudo tailscale up --login-server <YOUR_HEADSCALE_URL> --advertise-tags tag:exit --advertise-exit-node
|
||||
```
|
||||
|
||||
Please see the [official Tailscale documentation](https://tailscale.com/docs/reference/syntax/policy-file#autoapprovers)
|
||||
for more information on auto approvers.
|
||||
Please see the [official Tailscale documentation](https://tailscale.com/kb/1337/acl-syntax#autoapprovers) for more
|
||||
information on auto approvers.
|
||||
|
||||
## High availability
|
||||
|
||||
Headscale supports high availability routing. Multiple subnet routers with overlapping routes or multiple exit nodes can
|
||||
be used to provide high availability for users. If one router node goes offline, another one can serve the same routes
|
||||
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.
|
||||
Headscale has limited support for high availability routing. Multiple subnet routers with overlapping routes or multiple
|
||||
exit nodes can be used to provide high availability for users. If one router node goes offline, another one can serve
|
||||
the same routes to clients. Please see the official [Tailscale documentation on high
|
||||
availability](https://tailscale.com/kb/1115/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.
|
||||
!!! bug
|
||||
|
||||
In certain situations it might take up to 16 minutes for Headscale to detect a node as offline. A failover node
|
||||
might not be selected fast enough, if such a node is used as subnet router or exit node causing service
|
||||
interruptions for clients. See [issue 2129](https://github.com/juanfont/headscale/issues/2129) for more information.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Enable IP forwarding
|
||||
|
||||
A subnet router or exit node is routing traffic on behalf of other nodes and thus requires IP forwarding. Check the
|
||||
official [Tailscale documentation](https://tailscale.com/docs/features/subnet-routers#enable-ip-forwarding) for how to
|
||||
official [Tailscale documentation](https://tailscale.com/kb/1019/subnets/?tab=linux#enable-ip-forwarding) for how to
|
||||
enable IP forwarding.
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
# Tags
|
||||
|
||||
Headscale supports Tailscale tags. Please read [Tailscale's tag documentation](https://tailscale.com/docs/features/tags)
|
||||
to learn how tags work and how to use them.
|
||||
Headscale supports Tailscale tags. Please read [Tailscale's tag documentation](https://tailscale.com/kb/1068/tags) to
|
||||
learn how tags work and how to use them.
|
||||
|
||||
Tags can be applied during [node registration](registration.md):
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -24,8 +24,7 @@ distributions are Ubuntu 22.04 or newer, Debian 12 or newer.
|
||||
sudo apt install ./headscale.deb
|
||||
```
|
||||
|
||||
1. [Configure headscale by editing the configuration file](../../ref/configuration.md). An up-to date example
|
||||
configuration file is also available in `/usr/share/doc/headscale/examples/config-example.yaml`:
|
||||
1. [Configure headscale by editing the configuration file](../../ref/configuration.md):
|
||||
|
||||
```shell
|
||||
sudo nano /etc/headscale/config.yaml
|
||||
@@ -51,7 +50,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
|
||||
|
||||
@@ -17,7 +17,7 @@ The ports in use vary with the intended scenario and enabled features. Some of t
|
||||
- tcp/80
|
||||
- Expose publicly: yes
|
||||
- HTTP, used by Let's Encrypt to verify ownership via the HTTP-01 challenge.
|
||||
- Only required if the built-in Let's Encrypt client with the HTTP-01 challenge is used. See [TLS](../ref/tls.md) for
|
||||
- Only required if the built-in Let's Enrypt client with the HTTP-01 challenge is used. See [TLS](../ref/tls.md) for
|
||||
details.
|
||||
- tcp/443
|
||||
- Expose publicly: yes
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -25,8 +25,7 @@ Install the official Tailscale iOS client from the [App Store](https://apps.appl
|
||||
|
||||
### Installation
|
||||
|
||||
Choose one of the available [Tailscale clients for macOS](https://tailscale.com/docs/concepts/macos-variants) and
|
||||
install it.
|
||||
Choose one of the available [Tailscale clients for macOS](https://tailscale.com/kb/1065/macos-variants) and install it.
|
||||
|
||||
### Configuring the headscale URL
|
||||
|
||||
|
||||
@@ -33,8 +33,7 @@ all the time, please enable "Unattended mode":
|
||||
- Enable `Run unattended`
|
||||
- Confirm the "Unattended mode" message
|
||||
|
||||
See also [Keep Tailscale running when I'm not logged in to my
|
||||
computer](https://tailscale.com/docs/how-to/run-unattended).
|
||||
See also [Keep Tailscale running when I'm not logged in to my computer](https://tailscale.com/kb/1088/run-unattended)
|
||||
|
||||
### Failing node registration
|
||||
|
||||
|
||||
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-x0xXxa7sjyDwWLq8fO0Z/pbPefctzctK3TAdBea7FtY=";
|
||||
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="
|
||||
}
|
||||
}
|
||||
@@ -109,7 +109,7 @@ const file_headscale_v1_headscale_proto_rawDesc = "" +
|
||||
"\x1cheadscale/v1/headscale.proto\x12\fheadscale.v1\x1a\x1cgoogle/api/annotations.proto\x1a\x17headscale/v1/user.proto\x1a\x1dheadscale/v1/preauthkey.proto\x1a\x17headscale/v1/node.proto\x1a\x19headscale/v1/apikey.proto\x1a\x17headscale/v1/auth.proto\x1a\x19headscale/v1/policy.proto\"\x0f\n" +
|
||||
"\rHealthRequest\"E\n" +
|
||||
"\x0eHealthResponse\x123\n" +
|
||||
"\x15database_connectivity\x18\x01 \x01(\bR\x14databaseConnectivity2\xe0\x1a\n" +
|
||||
"\x15database_connectivity\x18\x01 \x01(\bR\x14databaseConnectivity2\xeb\x19\n" +
|
||||
"\x10HeadscaleService\x12h\n" +
|
||||
"\n" +
|
||||
"CreateUser\x12\x1f.headscale.v1.CreateUserRequest\x1a .headscale.v1.CreateUserResponse\"\x17\x82\xd3\xe4\x93\x02\x11:\x01*\"\f/api/v1/user\x12\x80\x01\n" +
|
||||
@@ -144,8 +144,7 @@ const file_headscale_v1_headscale_proto_rawDesc = "" +
|
||||
"\vListApiKeys\x12 .headscale.v1.ListApiKeysRequest\x1a!.headscale.v1.ListApiKeysResponse\"\x16\x82\xd3\xe4\x93\x02\x10\x12\x0e/api/v1/apikey\x12v\n" +
|
||||
"\fDeleteApiKey\x12!.headscale.v1.DeleteApiKeyRequest\x1a\".headscale.v1.DeleteApiKeyResponse\"\x1f\x82\xd3\xe4\x93\x02\x19*\x17/api/v1/apikey/{prefix}\x12d\n" +
|
||||
"\tGetPolicy\x12\x1e.headscale.v1.GetPolicyRequest\x1a\x1f.headscale.v1.GetPolicyResponse\"\x16\x82\xd3\xe4\x93\x02\x10\x12\x0e/api/v1/policy\x12g\n" +
|
||||
"\tSetPolicy\x12\x1e.headscale.v1.SetPolicyRequest\x1a\x1f.headscale.v1.SetPolicyResponse\"\x19\x82\xd3\xe4\x93\x02\x13:\x01*\x1a\x0e/api/v1/policy\x12s\n" +
|
||||
"\vCheckPolicy\x12 .headscale.v1.CheckPolicyRequest\x1a!.headscale.v1.CheckPolicyResponse\"\x1f\x82\xd3\xe4\x93\x02\x19:\x01*\"\x14/api/v1/policy/check\x12[\n" +
|
||||
"\tSetPolicy\x12\x1e.headscale.v1.SetPolicyRequest\x1a\x1f.headscale.v1.SetPolicyResponse\"\x19\x82\xd3\xe4\x93\x02\x13:\x01*\x1a\x0e/api/v1/policy\x12[\n" +
|
||||
"\x06Health\x12\x1b.headscale.v1.HealthRequest\x1a\x1c.headscale.v1.HealthResponse\"\x16\x82\xd3\xe4\x93\x02\x10\x12\x0e/api/v1/healthB)Z'github.com/juanfont/headscale/gen/go/v1b\x06proto3"
|
||||
|
||||
var (
|
||||
@@ -191,35 +190,33 @@ var file_headscale_v1_headscale_proto_goTypes = []any{
|
||||
(*DeleteApiKeyRequest)(nil), // 26: headscale.v1.DeleteApiKeyRequest
|
||||
(*GetPolicyRequest)(nil), // 27: headscale.v1.GetPolicyRequest
|
||||
(*SetPolicyRequest)(nil), // 28: headscale.v1.SetPolicyRequest
|
||||
(*CheckPolicyRequest)(nil), // 29: headscale.v1.CheckPolicyRequest
|
||||
(*CreateUserResponse)(nil), // 30: headscale.v1.CreateUserResponse
|
||||
(*RenameUserResponse)(nil), // 31: headscale.v1.RenameUserResponse
|
||||
(*DeleteUserResponse)(nil), // 32: headscale.v1.DeleteUserResponse
|
||||
(*ListUsersResponse)(nil), // 33: headscale.v1.ListUsersResponse
|
||||
(*CreatePreAuthKeyResponse)(nil), // 34: headscale.v1.CreatePreAuthKeyResponse
|
||||
(*ExpirePreAuthKeyResponse)(nil), // 35: headscale.v1.ExpirePreAuthKeyResponse
|
||||
(*DeletePreAuthKeyResponse)(nil), // 36: headscale.v1.DeletePreAuthKeyResponse
|
||||
(*ListPreAuthKeysResponse)(nil), // 37: headscale.v1.ListPreAuthKeysResponse
|
||||
(*DebugCreateNodeResponse)(nil), // 38: headscale.v1.DebugCreateNodeResponse
|
||||
(*GetNodeResponse)(nil), // 39: headscale.v1.GetNodeResponse
|
||||
(*SetTagsResponse)(nil), // 40: headscale.v1.SetTagsResponse
|
||||
(*SetApprovedRoutesResponse)(nil), // 41: headscale.v1.SetApprovedRoutesResponse
|
||||
(*RegisterNodeResponse)(nil), // 42: headscale.v1.RegisterNodeResponse
|
||||
(*DeleteNodeResponse)(nil), // 43: headscale.v1.DeleteNodeResponse
|
||||
(*ExpireNodeResponse)(nil), // 44: headscale.v1.ExpireNodeResponse
|
||||
(*RenameNodeResponse)(nil), // 45: headscale.v1.RenameNodeResponse
|
||||
(*ListNodesResponse)(nil), // 46: headscale.v1.ListNodesResponse
|
||||
(*BackfillNodeIPsResponse)(nil), // 47: headscale.v1.BackfillNodeIPsResponse
|
||||
(*AuthRegisterResponse)(nil), // 48: headscale.v1.AuthRegisterResponse
|
||||
(*AuthApproveResponse)(nil), // 49: headscale.v1.AuthApproveResponse
|
||||
(*AuthRejectResponse)(nil), // 50: headscale.v1.AuthRejectResponse
|
||||
(*CreateApiKeyResponse)(nil), // 51: headscale.v1.CreateApiKeyResponse
|
||||
(*ExpireApiKeyResponse)(nil), // 52: headscale.v1.ExpireApiKeyResponse
|
||||
(*ListApiKeysResponse)(nil), // 53: headscale.v1.ListApiKeysResponse
|
||||
(*DeleteApiKeyResponse)(nil), // 54: headscale.v1.DeleteApiKeyResponse
|
||||
(*GetPolicyResponse)(nil), // 55: headscale.v1.GetPolicyResponse
|
||||
(*SetPolicyResponse)(nil), // 56: headscale.v1.SetPolicyResponse
|
||||
(*CheckPolicyResponse)(nil), // 57: headscale.v1.CheckPolicyResponse
|
||||
(*CreateUserResponse)(nil), // 29: headscale.v1.CreateUserResponse
|
||||
(*RenameUserResponse)(nil), // 30: headscale.v1.RenameUserResponse
|
||||
(*DeleteUserResponse)(nil), // 31: headscale.v1.DeleteUserResponse
|
||||
(*ListUsersResponse)(nil), // 32: headscale.v1.ListUsersResponse
|
||||
(*CreatePreAuthKeyResponse)(nil), // 33: headscale.v1.CreatePreAuthKeyResponse
|
||||
(*ExpirePreAuthKeyResponse)(nil), // 34: headscale.v1.ExpirePreAuthKeyResponse
|
||||
(*DeletePreAuthKeyResponse)(nil), // 35: headscale.v1.DeletePreAuthKeyResponse
|
||||
(*ListPreAuthKeysResponse)(nil), // 36: headscale.v1.ListPreAuthKeysResponse
|
||||
(*DebugCreateNodeResponse)(nil), // 37: headscale.v1.DebugCreateNodeResponse
|
||||
(*GetNodeResponse)(nil), // 38: headscale.v1.GetNodeResponse
|
||||
(*SetTagsResponse)(nil), // 39: headscale.v1.SetTagsResponse
|
||||
(*SetApprovedRoutesResponse)(nil), // 40: headscale.v1.SetApprovedRoutesResponse
|
||||
(*RegisterNodeResponse)(nil), // 41: headscale.v1.RegisterNodeResponse
|
||||
(*DeleteNodeResponse)(nil), // 42: headscale.v1.DeleteNodeResponse
|
||||
(*ExpireNodeResponse)(nil), // 43: headscale.v1.ExpireNodeResponse
|
||||
(*RenameNodeResponse)(nil), // 44: headscale.v1.RenameNodeResponse
|
||||
(*ListNodesResponse)(nil), // 45: headscale.v1.ListNodesResponse
|
||||
(*BackfillNodeIPsResponse)(nil), // 46: headscale.v1.BackfillNodeIPsResponse
|
||||
(*AuthRegisterResponse)(nil), // 47: headscale.v1.AuthRegisterResponse
|
||||
(*AuthApproveResponse)(nil), // 48: headscale.v1.AuthApproveResponse
|
||||
(*AuthRejectResponse)(nil), // 49: headscale.v1.AuthRejectResponse
|
||||
(*CreateApiKeyResponse)(nil), // 50: headscale.v1.CreateApiKeyResponse
|
||||
(*ExpireApiKeyResponse)(nil), // 51: headscale.v1.ExpireApiKeyResponse
|
||||
(*ListApiKeysResponse)(nil), // 52: headscale.v1.ListApiKeysResponse
|
||||
(*DeleteApiKeyResponse)(nil), // 53: headscale.v1.DeleteApiKeyResponse
|
||||
(*GetPolicyResponse)(nil), // 54: headscale.v1.GetPolicyResponse
|
||||
(*SetPolicyResponse)(nil), // 55: headscale.v1.SetPolicyResponse
|
||||
}
|
||||
var file_headscale_v1_headscale_proto_depIdxs = []int32{
|
||||
2, // 0: headscale.v1.HeadscaleService.CreateUser:input_type -> headscale.v1.CreateUserRequest
|
||||
@@ -249,39 +246,37 @@ var file_headscale_v1_headscale_proto_depIdxs = []int32{
|
||||
26, // 24: headscale.v1.HeadscaleService.DeleteApiKey:input_type -> headscale.v1.DeleteApiKeyRequest
|
||||
27, // 25: headscale.v1.HeadscaleService.GetPolicy:input_type -> headscale.v1.GetPolicyRequest
|
||||
28, // 26: headscale.v1.HeadscaleService.SetPolicy:input_type -> headscale.v1.SetPolicyRequest
|
||||
29, // 27: headscale.v1.HeadscaleService.CheckPolicy:input_type -> headscale.v1.CheckPolicyRequest
|
||||
0, // 28: headscale.v1.HeadscaleService.Health:input_type -> headscale.v1.HealthRequest
|
||||
30, // 29: headscale.v1.HeadscaleService.CreateUser:output_type -> headscale.v1.CreateUserResponse
|
||||
31, // 30: headscale.v1.HeadscaleService.RenameUser:output_type -> headscale.v1.RenameUserResponse
|
||||
32, // 31: headscale.v1.HeadscaleService.DeleteUser:output_type -> headscale.v1.DeleteUserResponse
|
||||
33, // 32: headscale.v1.HeadscaleService.ListUsers:output_type -> headscale.v1.ListUsersResponse
|
||||
34, // 33: headscale.v1.HeadscaleService.CreatePreAuthKey:output_type -> headscale.v1.CreatePreAuthKeyResponse
|
||||
35, // 34: headscale.v1.HeadscaleService.ExpirePreAuthKey:output_type -> headscale.v1.ExpirePreAuthKeyResponse
|
||||
36, // 35: headscale.v1.HeadscaleService.DeletePreAuthKey:output_type -> headscale.v1.DeletePreAuthKeyResponse
|
||||
37, // 36: headscale.v1.HeadscaleService.ListPreAuthKeys:output_type -> headscale.v1.ListPreAuthKeysResponse
|
||||
38, // 37: headscale.v1.HeadscaleService.DebugCreateNode:output_type -> headscale.v1.DebugCreateNodeResponse
|
||||
39, // 38: headscale.v1.HeadscaleService.GetNode:output_type -> headscale.v1.GetNodeResponse
|
||||
40, // 39: headscale.v1.HeadscaleService.SetTags:output_type -> headscale.v1.SetTagsResponse
|
||||
41, // 40: headscale.v1.HeadscaleService.SetApprovedRoutes:output_type -> headscale.v1.SetApprovedRoutesResponse
|
||||
42, // 41: headscale.v1.HeadscaleService.RegisterNode:output_type -> headscale.v1.RegisterNodeResponse
|
||||
43, // 42: headscale.v1.HeadscaleService.DeleteNode:output_type -> headscale.v1.DeleteNodeResponse
|
||||
44, // 43: headscale.v1.HeadscaleService.ExpireNode:output_type -> headscale.v1.ExpireNodeResponse
|
||||
45, // 44: headscale.v1.HeadscaleService.RenameNode:output_type -> headscale.v1.RenameNodeResponse
|
||||
46, // 45: headscale.v1.HeadscaleService.ListNodes:output_type -> headscale.v1.ListNodesResponse
|
||||
47, // 46: headscale.v1.HeadscaleService.BackfillNodeIPs:output_type -> headscale.v1.BackfillNodeIPsResponse
|
||||
48, // 47: headscale.v1.HeadscaleService.AuthRegister:output_type -> headscale.v1.AuthRegisterResponse
|
||||
49, // 48: headscale.v1.HeadscaleService.AuthApprove:output_type -> headscale.v1.AuthApproveResponse
|
||||
50, // 49: headscale.v1.HeadscaleService.AuthReject:output_type -> headscale.v1.AuthRejectResponse
|
||||
51, // 50: headscale.v1.HeadscaleService.CreateApiKey:output_type -> headscale.v1.CreateApiKeyResponse
|
||||
52, // 51: headscale.v1.HeadscaleService.ExpireApiKey:output_type -> headscale.v1.ExpireApiKeyResponse
|
||||
53, // 52: headscale.v1.HeadscaleService.ListApiKeys:output_type -> headscale.v1.ListApiKeysResponse
|
||||
54, // 53: headscale.v1.HeadscaleService.DeleteApiKey:output_type -> headscale.v1.DeleteApiKeyResponse
|
||||
55, // 54: headscale.v1.HeadscaleService.GetPolicy:output_type -> headscale.v1.GetPolicyResponse
|
||||
56, // 55: headscale.v1.HeadscaleService.SetPolicy:output_type -> headscale.v1.SetPolicyResponse
|
||||
57, // 56: headscale.v1.HeadscaleService.CheckPolicy:output_type -> headscale.v1.CheckPolicyResponse
|
||||
1, // 57: headscale.v1.HeadscaleService.Health:output_type -> headscale.v1.HealthResponse
|
||||
29, // [29:58] is the sub-list for method output_type
|
||||
0, // [0:29] is the sub-list for method input_type
|
||||
0, // 27: headscale.v1.HeadscaleService.Health:input_type -> headscale.v1.HealthRequest
|
||||
29, // 28: headscale.v1.HeadscaleService.CreateUser:output_type -> headscale.v1.CreateUserResponse
|
||||
30, // 29: headscale.v1.HeadscaleService.RenameUser:output_type -> headscale.v1.RenameUserResponse
|
||||
31, // 30: headscale.v1.HeadscaleService.DeleteUser:output_type -> headscale.v1.DeleteUserResponse
|
||||
32, // 31: headscale.v1.HeadscaleService.ListUsers:output_type -> headscale.v1.ListUsersResponse
|
||||
33, // 32: headscale.v1.HeadscaleService.CreatePreAuthKey:output_type -> headscale.v1.CreatePreAuthKeyResponse
|
||||
34, // 33: headscale.v1.HeadscaleService.ExpirePreAuthKey:output_type -> headscale.v1.ExpirePreAuthKeyResponse
|
||||
35, // 34: headscale.v1.HeadscaleService.DeletePreAuthKey:output_type -> headscale.v1.DeletePreAuthKeyResponse
|
||||
36, // 35: headscale.v1.HeadscaleService.ListPreAuthKeys:output_type -> headscale.v1.ListPreAuthKeysResponse
|
||||
37, // 36: headscale.v1.HeadscaleService.DebugCreateNode:output_type -> headscale.v1.DebugCreateNodeResponse
|
||||
38, // 37: headscale.v1.HeadscaleService.GetNode:output_type -> headscale.v1.GetNodeResponse
|
||||
39, // 38: headscale.v1.HeadscaleService.SetTags:output_type -> headscale.v1.SetTagsResponse
|
||||
40, // 39: headscale.v1.HeadscaleService.SetApprovedRoutes:output_type -> headscale.v1.SetApprovedRoutesResponse
|
||||
41, // 40: headscale.v1.HeadscaleService.RegisterNode:output_type -> headscale.v1.RegisterNodeResponse
|
||||
42, // 41: headscale.v1.HeadscaleService.DeleteNode:output_type -> headscale.v1.DeleteNodeResponse
|
||||
43, // 42: headscale.v1.HeadscaleService.ExpireNode:output_type -> headscale.v1.ExpireNodeResponse
|
||||
44, // 43: headscale.v1.HeadscaleService.RenameNode:output_type -> headscale.v1.RenameNodeResponse
|
||||
45, // 44: headscale.v1.HeadscaleService.ListNodes:output_type -> headscale.v1.ListNodesResponse
|
||||
46, // 45: headscale.v1.HeadscaleService.BackfillNodeIPs:output_type -> headscale.v1.BackfillNodeIPsResponse
|
||||
47, // 46: headscale.v1.HeadscaleService.AuthRegister:output_type -> headscale.v1.AuthRegisterResponse
|
||||
48, // 47: headscale.v1.HeadscaleService.AuthApprove:output_type -> headscale.v1.AuthApproveResponse
|
||||
49, // 48: headscale.v1.HeadscaleService.AuthReject:output_type -> headscale.v1.AuthRejectResponse
|
||||
50, // 49: headscale.v1.HeadscaleService.CreateApiKey:output_type -> headscale.v1.CreateApiKeyResponse
|
||||
51, // 50: headscale.v1.HeadscaleService.ExpireApiKey:output_type -> headscale.v1.ExpireApiKeyResponse
|
||||
52, // 51: headscale.v1.HeadscaleService.ListApiKeys:output_type -> headscale.v1.ListApiKeysResponse
|
||||
53, // 52: headscale.v1.HeadscaleService.DeleteApiKey:output_type -> headscale.v1.DeleteApiKeyResponse
|
||||
54, // 53: headscale.v1.HeadscaleService.GetPolicy:output_type -> headscale.v1.GetPolicyResponse
|
||||
55, // 54: headscale.v1.HeadscaleService.SetPolicy:output_type -> headscale.v1.SetPolicyResponse
|
||||
1, // 55: headscale.v1.HeadscaleService.Health:output_type -> headscale.v1.HealthResponse
|
||||
28, // [28:56] is the sub-list for method output_type
|
||||
0, // [0:28] is the sub-list for method input_type
|
||||
0, // [0:0] is the sub-list for extension type_name
|
||||
0, // [0:0] is the sub-list for extension extendee
|
||||
0, // [0:0] is the sub-list for field type_name
|
||||
|
||||
@@ -966,33 +966,6 @@ func local_request_HeadscaleService_SetPolicy_0(ctx context.Context, marshaler r
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func request_HeadscaleService_CheckPolicy_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq CheckPolicyRequest
|
||||
metadata runtime.ServerMetadata
|
||||
)
|
||||
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
if req.Body != nil {
|
||||
_, _ = io.Copy(io.Discard, req.Body)
|
||||
}
|
||||
msg, err := client.CheckPolicy(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func local_request_HeadscaleService_CheckPolicy_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq CheckPolicyRequest
|
||||
metadata runtime.ServerMetadata
|
||||
)
|
||||
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
msg, err := server.CheckPolicy(ctx, &protoReq)
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func request_HeadscaleService_Health_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq HealthRequest
|
||||
@@ -1560,26 +1533,6 @@ func RegisterHeadscaleServiceHandlerServer(ctx context.Context, mux *runtime.Ser
|
||||
}
|
||||
forward_HeadscaleService_SetPolicy_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_HeadscaleService_CheckPolicy_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
var stream runtime.ServerTransportStream
|
||||
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/CheckPolicy", runtime.WithHTTPPathPattern("/api/v1/policy/check"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := local_request_HeadscaleService_CheckPolicy_0(annotatedContext, inboundMarshaler, server, req, pathParams)
|
||||
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_HeadscaleService_CheckPolicy_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodGet, pattern_HeadscaleService_Health_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
@@ -2099,23 +2052,6 @@ func RegisterHeadscaleServiceHandlerClient(ctx context.Context, mux *runtime.Ser
|
||||
}
|
||||
forward_HeadscaleService_SetPolicy_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_HeadscaleService_CheckPolicy_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/CheckPolicy", runtime.WithHTTPPathPattern("/api/v1/policy/check"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := request_HeadscaleService_CheckPolicy_0(annotatedContext, inboundMarshaler, client, req, pathParams)
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_HeadscaleService_CheckPolicy_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodGet, pattern_HeadscaleService_Health_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
@@ -2164,7 +2100,6 @@ var (
|
||||
pattern_HeadscaleService_DeleteApiKey_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3}, []string{"api", "v1", "apikey", "prefix"}, ""))
|
||||
pattern_HeadscaleService_GetPolicy_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "policy"}, ""))
|
||||
pattern_HeadscaleService_SetPolicy_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "policy"}, ""))
|
||||
pattern_HeadscaleService_CheckPolicy_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"api", "v1", "policy", "check"}, ""))
|
||||
pattern_HeadscaleService_Health_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "health"}, ""))
|
||||
)
|
||||
|
||||
@@ -2196,6 +2131,5 @@ var (
|
||||
forward_HeadscaleService_DeleteApiKey_0 = runtime.ForwardResponseMessage
|
||||
forward_HeadscaleService_GetPolicy_0 = runtime.ForwardResponseMessage
|
||||
forward_HeadscaleService_SetPolicy_0 = runtime.ForwardResponseMessage
|
||||
forward_HeadscaleService_CheckPolicy_0 = runtime.ForwardResponseMessage
|
||||
forward_HeadscaleService_Health_0 = runtime.ForwardResponseMessage
|
||||
)
|
||||
|
||||
@@ -46,7 +46,6 @@ const (
|
||||
HeadscaleService_DeleteApiKey_FullMethodName = "/headscale.v1.HeadscaleService/DeleteApiKey"
|
||||
HeadscaleService_GetPolicy_FullMethodName = "/headscale.v1.HeadscaleService/GetPolicy"
|
||||
HeadscaleService_SetPolicy_FullMethodName = "/headscale.v1.HeadscaleService/SetPolicy"
|
||||
HeadscaleService_CheckPolicy_FullMethodName = "/headscale.v1.HeadscaleService/CheckPolicy"
|
||||
HeadscaleService_Health_FullMethodName = "/headscale.v1.HeadscaleService/Health"
|
||||
)
|
||||
|
||||
@@ -87,7 +86,6 @@ type HeadscaleServiceClient interface {
|
||||
// --- Policy start ---
|
||||
GetPolicy(ctx context.Context, in *GetPolicyRequest, opts ...grpc.CallOption) (*GetPolicyResponse, error)
|
||||
SetPolicy(ctx context.Context, in *SetPolicyRequest, opts ...grpc.CallOption) (*SetPolicyResponse, error)
|
||||
CheckPolicy(ctx context.Context, in *CheckPolicyRequest, opts ...grpc.CallOption) (*CheckPolicyResponse, error)
|
||||
// --- Health start ---
|
||||
Health(ctx context.Context, in *HealthRequest, opts ...grpc.CallOption) (*HealthResponse, error)
|
||||
}
|
||||
@@ -370,16 +368,6 @@ func (c *headscaleServiceClient) SetPolicy(ctx context.Context, in *SetPolicyReq
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *headscaleServiceClient) CheckPolicy(ctx context.Context, in *CheckPolicyRequest, opts ...grpc.CallOption) (*CheckPolicyResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CheckPolicyResponse)
|
||||
err := c.cc.Invoke(ctx, HeadscaleService_CheckPolicy_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *headscaleServiceClient) Health(ctx context.Context, in *HealthRequest, opts ...grpc.CallOption) (*HealthResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(HealthResponse)
|
||||
@@ -427,7 +415,6 @@ type HeadscaleServiceServer interface {
|
||||
// --- Policy start ---
|
||||
GetPolicy(context.Context, *GetPolicyRequest) (*GetPolicyResponse, error)
|
||||
SetPolicy(context.Context, *SetPolicyRequest) (*SetPolicyResponse, error)
|
||||
CheckPolicy(context.Context, *CheckPolicyRequest) (*CheckPolicyResponse, error)
|
||||
// --- Health start ---
|
||||
Health(context.Context, *HealthRequest) (*HealthResponse, error)
|
||||
mustEmbedUnimplementedHeadscaleServiceServer()
|
||||
@@ -521,9 +508,6 @@ func (UnimplementedHeadscaleServiceServer) GetPolicy(context.Context, *GetPolicy
|
||||
func (UnimplementedHeadscaleServiceServer) SetPolicy(context.Context, *SetPolicyRequest) (*SetPolicyResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method SetPolicy not implemented")
|
||||
}
|
||||
func (UnimplementedHeadscaleServiceServer) CheckPolicy(context.Context, *CheckPolicyRequest) (*CheckPolicyResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method CheckPolicy not implemented")
|
||||
}
|
||||
func (UnimplementedHeadscaleServiceServer) Health(context.Context, *HealthRequest) (*HealthResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method Health not implemented")
|
||||
}
|
||||
@@ -1034,24 +1018,6 @@ func _HeadscaleService_SetPolicy_Handler(srv interface{}, ctx context.Context, d
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _HeadscaleService_CheckPolicy_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(CheckPolicyRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(HeadscaleServiceServer).CheckPolicy(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: HeadscaleService_CheckPolicy_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(HeadscaleServiceServer).CheckPolicy(ctx, req.(*CheckPolicyRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _HeadscaleService_Health_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(HealthRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@@ -1185,10 +1151,6 @@ var HeadscaleService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "SetPolicy",
|
||||
Handler: _HeadscaleService_SetPolicy_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "CheckPolicy",
|
||||
Handler: _HeadscaleService_CheckPolicy_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "Health",
|
||||
Handler: _HeadscaleService_Health_Handler,
|
||||
|
||||
@@ -206,86 +206,6 @@ func (x *GetPolicyResponse) GetUpdatedAt() *timestamppb.Timestamp {
|
||||
return nil
|
||||
}
|
||||
|
||||
type CheckPolicyRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Policy string `protobuf:"bytes,1,opt,name=policy,proto3" json:"policy,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *CheckPolicyRequest) Reset() {
|
||||
*x = CheckPolicyRequest{}
|
||||
mi := &file_headscale_v1_policy_proto_msgTypes[4]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *CheckPolicyRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*CheckPolicyRequest) ProtoMessage() {}
|
||||
|
||||
func (x *CheckPolicyRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_headscale_v1_policy_proto_msgTypes[4]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use CheckPolicyRequest.ProtoReflect.Descriptor instead.
|
||||
func (*CheckPolicyRequest) Descriptor() ([]byte, []int) {
|
||||
return file_headscale_v1_policy_proto_rawDescGZIP(), []int{4}
|
||||
}
|
||||
|
||||
func (x *CheckPolicyRequest) GetPolicy() string {
|
||||
if x != nil {
|
||||
return x.Policy
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type CheckPolicyResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *CheckPolicyResponse) Reset() {
|
||||
*x = CheckPolicyResponse{}
|
||||
mi := &file_headscale_v1_policy_proto_msgTypes[5]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *CheckPolicyResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*CheckPolicyResponse) ProtoMessage() {}
|
||||
|
||||
func (x *CheckPolicyResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_headscale_v1_policy_proto_msgTypes[5]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use CheckPolicyResponse.ProtoReflect.Descriptor instead.
|
||||
func (*CheckPolicyResponse) Descriptor() ([]byte, []int) {
|
||||
return file_headscale_v1_policy_proto_rawDescGZIP(), []int{5}
|
||||
}
|
||||
|
||||
var File_headscale_v1_policy_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_headscale_v1_policy_proto_rawDesc = "" +
|
||||
@@ -301,10 +221,7 @@ const file_headscale_v1_policy_proto_rawDesc = "" +
|
||||
"\x11GetPolicyResponse\x12\x16\n" +
|
||||
"\x06policy\x18\x01 \x01(\tR\x06policy\x129\n" +
|
||||
"\n" +
|
||||
"updated_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\",\n" +
|
||||
"\x12CheckPolicyRequest\x12\x16\n" +
|
||||
"\x06policy\x18\x01 \x01(\tR\x06policy\"\x15\n" +
|
||||
"\x13CheckPolicyResponseB)Z'github.com/juanfont/headscale/gen/go/v1b\x06proto3"
|
||||
"updated_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAtB)Z'github.com/juanfont/headscale/gen/go/v1b\x06proto3"
|
||||
|
||||
var (
|
||||
file_headscale_v1_policy_proto_rawDescOnce sync.Once
|
||||
@@ -318,19 +235,17 @@ func file_headscale_v1_policy_proto_rawDescGZIP() []byte {
|
||||
return file_headscale_v1_policy_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_headscale_v1_policy_proto_msgTypes = make([]protoimpl.MessageInfo, 6)
|
||||
var file_headscale_v1_policy_proto_msgTypes = make([]protoimpl.MessageInfo, 4)
|
||||
var file_headscale_v1_policy_proto_goTypes = []any{
|
||||
(*SetPolicyRequest)(nil), // 0: headscale.v1.SetPolicyRequest
|
||||
(*SetPolicyResponse)(nil), // 1: headscale.v1.SetPolicyResponse
|
||||
(*GetPolicyRequest)(nil), // 2: headscale.v1.GetPolicyRequest
|
||||
(*GetPolicyResponse)(nil), // 3: headscale.v1.GetPolicyResponse
|
||||
(*CheckPolicyRequest)(nil), // 4: headscale.v1.CheckPolicyRequest
|
||||
(*CheckPolicyResponse)(nil), // 5: headscale.v1.CheckPolicyResponse
|
||||
(*timestamppb.Timestamp)(nil), // 6: google.protobuf.Timestamp
|
||||
(*timestamppb.Timestamp)(nil), // 4: google.protobuf.Timestamp
|
||||
}
|
||||
var file_headscale_v1_policy_proto_depIdxs = []int32{
|
||||
6, // 0: headscale.v1.SetPolicyResponse.updated_at:type_name -> google.protobuf.Timestamp
|
||||
6, // 1: headscale.v1.GetPolicyResponse.updated_at:type_name -> google.protobuf.Timestamp
|
||||
4, // 0: headscale.v1.SetPolicyResponse.updated_at:type_name -> google.protobuf.Timestamp
|
||||
4, // 1: headscale.v1.GetPolicyResponse.updated_at:type_name -> google.protobuf.Timestamp
|
||||
2, // [2:2] is the sub-list for method output_type
|
||||
2, // [2:2] is the sub-list for method input_type
|
||||
2, // [2:2] is the sub-list for extension type_name
|
||||
@@ -349,7 +264,7 @@ func file_headscale_v1_policy_proto_init() {
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_headscale_v1_policy_proto_rawDesc), len(file_headscale_v1_policy_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 6,
|
||||
NumMessages: 4,
|
||||
NumExtensions: 0,
|
||||
NumServices: 0,
|
||||
},
|
||||
|
||||
@@ -660,38 +660,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/policy/check": {
|
||||
"post": {
|
||||
"operationId": "HeadscaleService_CheckPolicy",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "A successful response.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/v1CheckPolicyResponse"
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "An unexpected error response.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/rpcStatus"
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/v1CheckPolicyRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"HeadscaleService"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/preauthkey": {
|
||||
"get": {
|
||||
"operationId": "HeadscaleService_ListPreAuthKeys",
|
||||
@@ -1076,17 +1044,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"v1CheckPolicyRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"policy": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"v1CheckPolicyResponse": {
|
||||
"type": "object"
|
||||
},
|
||||
"v1CreateApiKeyRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -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
|
||||
@@ -54,8 +54,7 @@ require (
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
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
|
||||
)
|
||||
|
||||
@@ -97,14 +96,11 @@ require (
|
||||
atomicgo.dev/schedule v0.1.0 // indirect
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
fyne.io/systray v1.11.1-0.20250812065214-4856ac3adc3c // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||
github.com/Kodeworks/golang-image-ico v0.0.0-20141118225523-73f0f4cfade9 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect
|
||||
github.com/akutz/memconn v0.1.0 // indirect
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.7 // indirect
|
||||
@@ -115,7 +111,6 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssm v1.45.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
|
||||
@@ -134,13 +129,12 @@ 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
|
||||
github.com/felixge/fgprof v0.9.5 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fogleman/gg v1.3.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
github.com/gaissmai/bart v0.26.1 // indirect
|
||||
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
||||
@@ -151,7 +145,6 @@ require (
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/google/btree v1.1.3 // indirect
|
||||
@@ -172,11 +165,9 @@ require (
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||
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 +177,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
|
||||
@@ -198,7 +189,6 @@ require (
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/opencontainers/runc v1.3.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/peterbourgon/ff/v3 v3.4.0 // indirect
|
||||
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect
|
||||
github.com/pires/go-proxyproto v0.9.2 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
@@ -210,18 +200,16 @@ require (
|
||||
github.com/safchain/ethtool v0.7.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
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/toqueteos/webbrowser v1.2.0 // indirect
|
||||
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
|
||||
@@ -236,25 +224,19 @@ require (
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect
|
||||
golang.org/x/image v0.27.0 // indirect
|
||||
golang.org/x/mod v0.35.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
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
|
||||
k8s.io/client-go v0.34.0 // indirect
|
||||
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||
software.sslmate.com/src/go-pkcs12 v0.4.0 // indirect
|
||||
)
|
||||
|
||||
tool (
|
||||
golang.org/x/tools/cmd/stress
|
||||
golang.org/x/tools/cmd/stringer
|
||||
tailscale.com/cmd/tailscale
|
||||
tailscale.com/cmd/viewer
|
||||
tailscale.com/tstest/mts
|
||||
)
|
||||
|
||||
@@ -14,14 +14,10 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc=
|
||||
filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA=
|
||||
fyne.io/systray v1.11.1-0.20250812065214-4856ac3adc3c h1:km4PIleGtbbF1oxmFQuO93CyNCldwuRTPB8WlzNWNZs=
|
||||
fyne.io/systray v1.11.1-0.20250812065214-4856ac3adc3c/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/Kodeworks/golang-image-ico v0.0.0-20141118225523-73f0f4cfade9 h1:1ltqoej5GtaWF8jaiA49HwsZD459jqm9YFz9ZtMFpQA=
|
||||
github.com/Kodeworks/golang-image-ico v0.0.0-20141118225523-73f0f4cfade9/go.mod h1:7uhhqiBaR4CpN0k9rMjOtjpcfGd6DG2m04zQxKnWQ0I=
|
||||
github.com/MarvinJWendt/testza v0.1.0/go.mod h1:7AxNvlfeHP7Z/hDQ5JtE3OKYT3XFUeLCDE2DQninSqs=
|
||||
github.com/MarvinJWendt/testza v0.2.1/go.mod h1:God7bhG8n6uQxwdScay+gjm9/LnO4D3kkcZX4hv9Rp8=
|
||||
github.com/MarvinJWendt/testza v0.2.8/go.mod h1:nwIcjmr0Zz+Rcwfh3/4UhBp7ePKVhuBExvZqnKYWlII=
|
||||
@@ -44,8 +40,6 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuW
|
||||
github.com/arl/statsviz v0.8.0 h1:O6GjjVxEDxcByAucOSl29HaGYLXsuwA3ujJw8H9E7/U=
|
||||
github.com/arl/statsviz v0.8.0/go.mod h1:XlrbiT7xYT03xaW9JMMfD8KFUhBOESJwfyNJu83PbB0=
|
||||
github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
|
||||
@@ -155,8 +149,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=
|
||||
@@ -170,8 +164,6 @@ github.com/felixge/fgprof v0.9.5 h1:8+vR6yu2vvSKn08urWyEuxx75NWPEvybbkBirEpsbVY=
|
||||
github.com/felixge/fgprof v0.9.5/go.mod h1:yKl+ERSa++RYOs32d8K6WEXCB4uXdLls4ZaZPpayhMM=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
|
||||
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
@@ -220,8 +212,6 @@ github.com/gofrs/uuid/v5 v5.4.0 h1:EfbpCTjqMuGyq5ZJwxqzn3Cbr2d0rUZU7v5ycAk/e/0=
|
||||
github.com/gofrs/uuid/v5 v5.4.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
@@ -294,17 +284,13 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
|
||||
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
|
||||
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
|
||||
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/jsimonetti/rtnetlink v1.4.2 h1:Df9w9TZ3npHTyDn0Ev9e1uzmN2odmXd0QX+J5GTEn90=
|
||||
github.com/jsimonetti/rtnetlink v1.4.2/go.mod h1:92s6LJdE+1iOrw+F2/RO7LYI2Qd8pPpFNNUYW06gcoM=
|
||||
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 +337,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=
|
||||
@@ -384,8 +370,6 @@ github.com/ory/dockertest/v3 v3.12.0 h1:3oV9d0sDzlSQfHtIaB5k6ghUCVMVLpAY8hwrqoCy
|
||||
github.com/ory/dockertest/v3 v3.12.0/go.mod h1:aKNDTva3cp8dwOWwb9cWuX84aH5akkxXRvO7KCwWVjE=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/peterbourgon/ff/v3 v3.4.0 h1:QBvM/rizZM1cB0p0lGMdmR7HxZeI/ZrBWB4DqLkMUBc=
|
||||
github.com/peterbourgon/ff/v3 v3.4.0/go.mod h1:zjJVUhx+twciwfDl0zBcFzl4dW8axCRyXE/eKY9RztQ=
|
||||
github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
||||
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14=
|
||||
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
||||
@@ -446,8 +430,6 @@ github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
@@ -473,10 +455,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 +477,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=
|
||||
@@ -507,8 +487,6 @@ github.com/tcnksm/go-latest v0.0.0-20170313132115-e3007ae9052e h1:IWllFTiDjjLIf2
|
||||
github.com/tcnksm/go-latest v0.0.0-20170313132115-e3007ae9052e/go.mod h1:d7u6HkTYKSv5m6MCKkOQlHwaShTMl3HjqSGW3XtVhXM=
|
||||
github.com/tink-crypto/tink-go/v2 v2.6.0 h1:+KHNBHhWH33Vn+igZWcsgdEPUxKwBMEe0QC60t388v4=
|
||||
github.com/tink-crypto/tink-go/v2 v2.6.0/go.mod h1:2WbBA6pfNsAfBwDCggboaHeB2X29wkU8XHtGwh2YIk8=
|
||||
github.com/toqueteos/webbrowser v1.2.0 h1:tVP/gpK69Fx+qMJKsLE7TD8LuGWPnEV71wBN9rrstGQ=
|
||||
github.com/toqueteos/webbrowser v1.2.0/go.mod h1:XWoZq4cyp9WeUeak7w7LXRUQf1F1ATJMir8RTqb4ayM=
|
||||
github.com/u-root/u-root v0.14.0 h1:Ka4T10EEML7dQ5XDvO9c3MBN8z4nuSnGjcd1jmU2ivg=
|
||||
github.com/u-root/u-root v0.14.0/go.mod h1:hAyZorapJe4qzbLWlAkmSVCJGbfoU9Pu4jpJ1WMluqE=
|
||||
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM=
|
||||
@@ -561,8 +539,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 +556,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 +608,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=
|
||||
@@ -653,9 +631,6 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
@@ -672,8 +647,6 @@ honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU=
|
||||
honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc=
|
||||
howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM=
|
||||
howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
||||
k8s.io/client-go v0.34.0 h1:YoWv5r7bsBfb0Hs2jh8SOvFbKzzxyNo0nSb0zC19KZo=
|
||||
k8s.io/client-go v0.34.0/go.mod h1:ozgMnEKXkRjeMvBZdV1AijMHLTh3pbACPvK7zFR+QQY=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
||||
@@ -704,11 +677,9 @@ modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
|
||||
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
|
||||
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=
|
||||
|
||||
@@ -276,31 +276,6 @@ func (h *Headscale) scheduledTasks(ctx context.Context) {
|
||||
extraRecordsUpdate = make(chan []tailcfg.DNSRecord)
|
||||
}
|
||||
|
||||
var (
|
||||
haProber *state.HAHealthProber
|
||||
haHealthChan <-chan time.Time
|
||||
)
|
||||
if h.cfg.Node.Routes.HA.ProbeInterval > 0 {
|
||||
haProber = state.NewHAHealthProber(
|
||||
h.state,
|
||||
h.cfg.Node.Routes.HA,
|
||||
h.cfg.ServerURL,
|
||||
h.mapBatcher.IsConnected,
|
||||
)
|
||||
|
||||
haTicker := time.NewTicker(h.cfg.Node.Routes.HA.ProbeInterval)
|
||||
defer haTicker.Stop()
|
||||
|
||||
haHealthChan = haTicker.C
|
||||
|
||||
log.Info().
|
||||
Dur("interval", h.cfg.Node.Routes.HA.ProbeInterval).
|
||||
Dur("timeout", h.cfg.Node.Routes.HA.ProbeTimeout).
|
||||
Msg("HA subnet router health probing enabled")
|
||||
} else {
|
||||
haHealthChan = make(<-chan time.Time)
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -357,9 +332,6 @@ func (h *Headscale) scheduledTasks(ctx context.Context) {
|
||||
h.cfg.TailcfgDNSConfig.ExtraRecords = records
|
||||
|
||||
h.Change(change.ExtraRecords())
|
||||
|
||||
case <-haHealthChan:
|
||||
haProber.ProbeOnce(ctx, h.Change)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -488,20 +460,6 @@ func (h *Headscale) ensureUnixSocketIsAbsent() error {
|
||||
return os.Remove(h.cfg.UnixSocket)
|
||||
}
|
||||
|
||||
// securityHeaders sets baseline response headers on every HTTP response:
|
||||
// deny framing (clickjacking), forbid MIME-type sniffing, drop the Referer
|
||||
// header on outbound navigation. Cheap defense-in-depth for HTML surfaces.
|
||||
func securityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := w.Header()
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
h.Set("Content-Security-Policy", "frame-ancestors 'none'")
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("Referrer-Policy", "no-referrer")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Headscale) createRouter(grpcMux *grpcRuntime.ServeMux) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
r.Use(metrics.Collector(metrics.CollectorOpts{
|
||||
@@ -515,7 +473,6 @@ func (h *Headscale) createRouter(grpcMux *grpcRuntime.ServeMux) *chi.Mux {
|
||||
r.Use(middleware.RealIP)
|
||||
r.Use(middleware.RequestLogger(&zerologRequestLogger{}))
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(securityHeaders)
|
||||
|
||||
r.Post(ts2021UpgradePath, h.NoiseUpgradeHandler)
|
||||
|
||||
@@ -552,10 +509,6 @@ func (h *Headscale) createRouter(grpcMux *grpcRuntime.ServeMux) *chi.Mux {
|
||||
r.Use(h.httpAuthenticationMiddleware)
|
||||
r.HandleFunc("/v1/*", grpcMux.ServeHTTP)
|
||||
})
|
||||
// Ping response endpoint: receives HEAD from clients responding
|
||||
// to a PingRequest. The unguessable ping ID serves as authentication.
|
||||
r.Head("/machine/ping-response", h.PingResponseHandler)
|
||||
|
||||
r.Get("/favicon.ico", FaviconHandler)
|
||||
r.Get("/", BlankHandler)
|
||||
|
||||
@@ -1156,11 +1109,6 @@ func (h *Headscale) StartBatcherForTest(tb testing.TB) {
|
||||
tb.Cleanup(func() { h.mapBatcher.Close() })
|
||||
}
|
||||
|
||||
// MapBatcher returns the map response batcher (for test use).
|
||||
func (h *Headscale) MapBatcher() *mapper.Batcher {
|
||||
return h.mapBatcher
|
||||
}
|
||||
|
||||
// StartEphemeralGCForTest starts the ephemeral node garbage collector.
|
||||
// It registers a cleanup function on tb to stop the collector.
|
||||
// It panics when called outside of tests.
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
package hscontrol
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSecurityHeaders(t *testing.T) {
|
||||
handler := securityHeaders(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil)
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
h := rec.Result().Header
|
||||
assert.Equal(t, "DENY", h.Get("X-Frame-Options"))
|
||||
assert.Equal(t, "frame-ancestors 'none'", h.Get("Content-Security-Policy"))
|
||||
assert.Equal(t, "nosniff", h.Get("X-Content-Type-Options"))
|
||||
assert.Equal(t, "no-referrer", h.Get("Referrer-Policy"))
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 7.8 KiB |
@@ -11,46 +11,11 @@
|
||||
--md-typeset-a-color: var(--md-primary-fg-color);
|
||||
--md-text-font: "Roboto", -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif;
|
||||
--md-code-font: "Roboto Mono", "SF Mono", Monaco, "Cascadia Code", Consolas, "Courier New", monospace;
|
||||
--hs-success: #059669;
|
||||
--hs-success-bg: #d1fae5;
|
||||
--hs-error: #dc2626;
|
||||
--hs-error-bg: #fee2e2;
|
||||
--hs-warning-text: #92400e;
|
||||
--hs-warning-bg: #fef3c7;
|
||||
--hs-warning-border: #f59e0b;
|
||||
--hs-border: #e5e7eb;
|
||||
--hs-bg: #ffffff;
|
||||
--hs-focus-ring: #4051b5;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--md-default-fg-color: rgba(255, 255, 255, 0.87);
|
||||
--md-default-fg-color--light: rgba(255, 255, 255, 0.6);
|
||||
--md-default-fg-color--lighter: rgba(255, 255, 255, 0.38);
|
||||
--md-default-fg-color--lightest: rgba(255, 255, 255, 0.07);
|
||||
--md-code-fg-color: #c9d1d9;
|
||||
--md-code-bg-color: #1e1e1e;
|
||||
--md-primary-fg-color: #7b8fdb;
|
||||
--md-accent-fg-color: #8fa4ff;
|
||||
--md-typeset-a-color: var(--md-primary-fg-color);
|
||||
--hs-success: #34d399;
|
||||
--hs-success-bg: #064e3b;
|
||||
--hs-error: #f87171;
|
||||
--hs-error-bg: #450a0a;
|
||||
--hs-warning-text: #fbbf24;
|
||||
--hs-warning-bg: #451a03;
|
||||
--hs-warning-border: #d97706;
|
||||
--hs-border: #374151;
|
||||
--hs-bg: #111827;
|
||||
--hs-focus-ring: #7b8fdb;
|
||||
}
|
||||
}
|
||||
|
||||
/* Base Typography - 1rem (16px) avoids iOS auto-zoom on inputs */
|
||||
/* Base Typography */
|
||||
.md-typeset {
|
||||
font-size: 1rem;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.6;
|
||||
color: var(--md-default-fg-color);
|
||||
font-family: var(--md-text-font);
|
||||
@@ -111,33 +76,16 @@
|
||||
padding-left: 2em;
|
||||
}
|
||||
|
||||
.md-typeset li {
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
|
||||
/* Links - underline for accessibility (don't rely on color alone) */
|
||||
/* Links */
|
||||
.md-typeset a {
|
||||
color: var(--md-typeset-a-color);
|
||||
text-decoration: underline;
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 2px;
|
||||
text-decoration: none;
|
||||
word-break: break-word;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.md-typeset a:hover,
|
||||
.md-typeset a:focus {
|
||||
color: var(--md-accent-fg-color);
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
|
||||
/* Focus styles - visible ring for keyboard navigation */
|
||||
.md-typeset a:focus-visible,
|
||||
button:focus-visible,
|
||||
input:focus-visible {
|
||||
outline: 2px solid var(--hs-focus-ring);
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Code (inline) */
|
||||
@@ -170,7 +118,6 @@ input:focus-visible {
|
||||
overflow-wrap: break-word;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
/* Links in code */
|
||||
@@ -178,36 +125,6 @@ input:focus-visible {
|
||||
color: currentcolor;
|
||||
}
|
||||
|
||||
/* Buttons - styled via CSS for hover/active/focus pseudo-classes */
|
||||
.md-typeset button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
font-family: var(--md-text-font);
|
||||
line-height: 1;
|
||||
color: #ffffff;
|
||||
background-color: var(--md-primary-fg-color);
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
min-height: 44px;
|
||||
transition:
|
||||
background-color 150ms ease-out,
|
||||
box-shadow 150ms ease-out;
|
||||
}
|
||||
|
||||
.md-typeset button:hover {
|
||||
background-color: var(--md-accent-fg-color);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.md-typeset button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
/* Logo */
|
||||
.headscale-logo {
|
||||
display: block;
|
||||
@@ -221,17 +138,6 @@ input:focus-visible {
|
||||
@media (max-width: 768px) {
|
||||
.headscale-logo {
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
+6
-4
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/util"
|
||||
"github.com/rs/zerolog/log"
|
||||
"gorm.io/gorm"
|
||||
"tailscale.com/tailcfg"
|
||||
@@ -335,10 +336,11 @@ func registrationDataFromRequest(
|
||||
req tailcfg.RegisterRequest,
|
||||
machineKey key.MachinePublic,
|
||||
) *types.RegistrationData {
|
||||
var hostname string
|
||||
if req.Hostinfo != nil {
|
||||
hostname = req.Hostinfo.Hostname
|
||||
}
|
||||
hostname := util.EnsureHostname(
|
||||
req.Hostinfo.View(),
|
||||
machineKey.String(),
|
||||
req.NodeKey.String(),
|
||||
)
|
||||
|
||||
regData := &types.RegistrationData{
|
||||
MachineKey: machineKey,
|
||||
|
||||
@@ -1119,72 +1119,3 @@ func TestReregistrationAppliesDefaultExpiry(t *testing.T) {
|
||||
assert.True(t, node2.Expiry().Get().After(firstExpiry),
|
||||
"re-registration expiry should be later than initial registration expiry")
|
||||
}
|
||||
|
||||
// TestReregistrationZeroExpiryStaysNil tests that when a user-owned node
|
||||
// re-registers with zero client expiry and node.expiry is disabled (0),
|
||||
// the node's expiry stays nil rather than being set to a pointer to zero
|
||||
// time. Regression test for the else branch introduced in commit 6337a3db
|
||||
// which assigned `®Req.Expiry` (pointer to time.Time{}) instead of nil,
|
||||
// causing the database row to hold `0001-01-01 00:00:00` instead of NULL.
|
||||
//
|
||||
// The same !regReq.Expiry.IsZero() gate at state.go:2221-2228 is shared by
|
||||
// the tags-only PreAuthKey path (createAndSaveNewNode also receives nil
|
||||
// when the client sends zero expiry), so this regression is covered for
|
||||
// tagged nodes by inspection.
|
||||
func TestReregistrationZeroExpiryStaysNil(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// node.expiry = 0 means "no default expiry"
|
||||
app := createTestAppWithNodeExpiry(t, 0)
|
||||
|
||||
user := app.state.CreateUserForTest("node-owner")
|
||||
|
||||
pak, err := app.state.CreatePreAuthKey(user.TypedID(), true, false, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
machineKey := key.NewMachine()
|
||||
nodeKey := key.NewNode()
|
||||
|
||||
// Initial registration with zero client expiry
|
||||
regReq := tailcfg.RegisterRequest{
|
||||
Auth: &tailcfg.RegisterResponseAuth{
|
||||
AuthKey: pak.Key,
|
||||
},
|
||||
NodeKey: nodeKey.Public(),
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
Hostname: "reregister-zero-expiry",
|
||||
},
|
||||
Expiry: time.Time{},
|
||||
}
|
||||
|
||||
resp, err := app.handleRegisterWithAuthKey(regReq, machineKey.Public())
|
||||
require.NoError(t, err)
|
||||
require.True(t, resp.MachineAuthorized)
|
||||
|
||||
node, found := app.state.GetNodeByNodeKey(nodeKey.Public())
|
||||
require.True(t, found)
|
||||
assert.False(t, node.Expiry().Valid(),
|
||||
"initial registration with zero expiry and no default should leave expiry nil")
|
||||
|
||||
// Re-register with a new node key but same machine key + user
|
||||
nodeKey2 := key.NewNode()
|
||||
regReq2 := tailcfg.RegisterRequest{
|
||||
Auth: &tailcfg.RegisterResponseAuth{
|
||||
AuthKey: pak.Key,
|
||||
},
|
||||
NodeKey: nodeKey2.Public(),
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
Hostname: "reregister-zero-expiry",
|
||||
},
|
||||
Expiry: time.Time{}, // still zero
|
||||
}
|
||||
|
||||
resp2, err := app.handleRegisterWithAuthKey(regReq2, machineKey.Public())
|
||||
require.NoError(t, err)
|
||||
require.True(t, resp2.MachineAuthorized)
|
||||
|
||||
node2, found := app.state.GetNodeByNodeKey(nodeKey2.Public())
|
||||
require.True(t, found)
|
||||
assert.False(t, node2.Expiry().Valid(),
|
||||
"re-registration with zero client expiry and no default should leave expiry nil, not pointer to zero time")
|
||||
}
|
||||
|
||||
+9
-124
@@ -816,12 +816,10 @@ func TestAuthenticationFlows(t *testing.T) {
|
||||
validate: func(t *testing.T, resp *tailcfg.RegisterResponse, app *Headscale) { //nolint:thelper //nolint:thelper
|
||||
assert.True(t, resp.MachineAuthorized)
|
||||
|
||||
// Raw hostname is preserved (empty in, empty stored), and
|
||||
// GivenName falls back to the literal "node" per SaaS.
|
||||
// Node should be created with generated hostname
|
||||
node, found := app.state.GetNodeByNodeKey(nodeKey1.Public())
|
||||
assert.True(t, found)
|
||||
assert.Empty(t, node.Hostname())
|
||||
assert.Equal(t, "node", node.GivenName())
|
||||
assert.NotEmpty(t, node.Hostname())
|
||||
},
|
||||
},
|
||||
// TEST: Nil hostinfo is handled with defensive code
|
||||
@@ -856,12 +854,12 @@ func TestAuthenticationFlows(t *testing.T) {
|
||||
validate: func(t *testing.T, resp *tailcfg.RegisterResponse, app *Headscale) { //nolint:thelper //nolint:thelper
|
||||
assert.True(t, resp.MachineAuthorized)
|
||||
|
||||
// With nil Hostinfo the raw hostname stays empty and GivenName
|
||||
// falls back to the literal "node" per the SaaS spec.
|
||||
// Node should be created with generated hostname from defensive code
|
||||
node, found := app.state.GetNodeByNodeKey(nodeKey1.Public())
|
||||
assert.True(t, found)
|
||||
assert.Empty(t, node.Hostname())
|
||||
assert.Equal(t, "node", node.GivenName())
|
||||
assert.NotEmpty(t, node.Hostname())
|
||||
// Hostname should start with "node-" (generated from machine key)
|
||||
assert.True(t, strings.HasPrefix(node.Hostname(), "node-"))
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2253,9 +2251,9 @@ func TestAuthenticationFlows(t *testing.T) {
|
||||
assert.True(t, found, "node should be registered despite nil hostinfo")
|
||||
|
||||
if found {
|
||||
// Raw hostname stays empty; GivenName falls back to "node".
|
||||
assert.Empty(t, node.Hostname())
|
||||
assert.Equal(t, "node", node.GivenName())
|
||||
// Should have some default hostname or handle nil gracefully
|
||||
hostname := node.Hostname()
|
||||
assert.NotEmpty(t, hostname, "should have some hostname even with nil hostinfo")
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -3988,116 +3986,3 @@ func TestTaggedNodeWithoutUserToDifferentUser(t *testing.T) {
|
||||
nodeAfterReauth.ID().Uint64(), nodeAfterReauth.Tags().AsSlice(),
|
||||
nodeAfterReauth.IsTagged(), nodeAfterReauth.UserID().Get())
|
||||
}
|
||||
|
||||
// TestHandleNodeFromPreAuthKey_OldUserNil_NoPanic asserts that
|
||||
// HandleNodeFromPreAuthKey does not panic when the in-memory NodeStore
|
||||
// holds a non-tagged node whose UserID points at a user but whose User
|
||||
// pointer is nil (orphan snapshot, e.g. a Preload("User") join missed
|
||||
// the row). Re-registering the same machine key under a different user
|
||||
// enters the "different user" branch and would otherwise crash at
|
||||
// oldUser.Name() in UserView.Name when the backing pointer is nil.
|
||||
func TestHandleNodeFromPreAuthKey_OldUserNil_NoPanic(t *testing.T) {
|
||||
app := createTestApp(t)
|
||||
|
||||
userA := app.state.CreateUserForTest("preauth-orphan-old")
|
||||
userB := app.state.CreateUserForTest("preauth-orphan-new")
|
||||
|
||||
machineKey := key.NewMachine()
|
||||
orphanNodeKey := key.NewNode()
|
||||
|
||||
userIDA := userA.ID
|
||||
orphan := types.Node{
|
||||
ID: 99001,
|
||||
MachineKey: machineKey.Public(),
|
||||
NodeKey: orphanNodeKey.Public(),
|
||||
Hostname: "preauth-orphan",
|
||||
GivenName: "preauth-orphan",
|
||||
UserID: &userIDA,
|
||||
User: nil,
|
||||
RegisterMethod: "authkey",
|
||||
}
|
||||
app.state.PutNodeInStoreForTest(orphan)
|
||||
|
||||
pakB, err := app.state.CreatePreAuthKey(userB.TypedID(), true, false, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
newNodeKey := key.NewNode()
|
||||
regReq := tailcfg.RegisterRequest{
|
||||
Auth: &tailcfg.RegisterResponseAuth{
|
||||
AuthKey: pakB.Key,
|
||||
},
|
||||
NodeKey: newNodeKey.Public(),
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
Hostname: "preauth-orphan-newuser",
|
||||
},
|
||||
Expiry: time.Now().Add(24 * time.Hour),
|
||||
}
|
||||
|
||||
resp, err := app.handleRegisterWithAuthKey(regReq, machineKey.Public())
|
||||
require.NoError(t, err, "registration must not panic when old user is nil")
|
||||
require.NotNil(t, resp)
|
||||
require.True(t, resp.MachineAuthorized)
|
||||
|
||||
var registered types.NodeView
|
||||
|
||||
require.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
var found bool
|
||||
|
||||
registered, found = app.state.GetNodeByNodeKey(newNodeKey.Public())
|
||||
assert.True(c, found, "new node should be available in NodeStore")
|
||||
}, 1*time.Second, 50*time.Millisecond, "waiting for new node")
|
||||
|
||||
assert.NotEqual(t, types.NodeID(99001), registered.ID(), "new node, not orphan")
|
||||
assert.Equal(t, userB.ID, registered.UserID().Get(), "new node belongs to userB")
|
||||
}
|
||||
|
||||
// TestHandleNodeFromAuthPath_OldUserNil_NoPanic is the parallel guard
|
||||
// for the gRPC/OIDC entry point. Same orphan shape as
|
||||
// TestHandleNodeFromPreAuthKey_OldUserNil_NoPanic; HandleNodeFromAuthPath
|
||||
// has its own oldUser.Name() log line in the existingNodeOwnedByOtherUser
|
||||
// branch and panics independently of the noise registration path.
|
||||
func TestHandleNodeFromAuthPath_OldUserNil_NoPanic(t *testing.T) {
|
||||
app := createTestApp(t)
|
||||
|
||||
userA := app.state.CreateUserForTest("authpath-orphan-old")
|
||||
userB := app.state.CreateUserForTest("authpath-orphan-new")
|
||||
|
||||
machineKey := key.NewMachine()
|
||||
orphanNodeKey := key.NewNode()
|
||||
|
||||
userIDA := userA.ID
|
||||
orphan := types.Node{
|
||||
ID: 99002,
|
||||
MachineKey: machineKey.Public(),
|
||||
NodeKey: orphanNodeKey.Public(),
|
||||
Hostname: "authpath-orphan",
|
||||
GivenName: "authpath-orphan",
|
||||
UserID: &userIDA,
|
||||
User: nil,
|
||||
RegisterMethod: "oidc",
|
||||
}
|
||||
app.state.PutNodeInStoreForTest(orphan)
|
||||
|
||||
newNodeKey := key.NewNode()
|
||||
authID := types.MustAuthID()
|
||||
regEntry := types.NewRegisterAuthRequest(&types.RegistrationData{
|
||||
MachineKey: machineKey.Public(),
|
||||
NodeKey: newNodeKey.Public(),
|
||||
Hostname: "authpath-orphan-newuser",
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
Hostname: "authpath-orphan-newuser",
|
||||
},
|
||||
})
|
||||
app.state.SetAuthCacheEntry(authID, regEntry)
|
||||
|
||||
node, _, err := app.state.HandleNodeFromAuthPath(
|
||||
authID,
|
||||
types.UserID(userB.ID),
|
||||
nil,
|
||||
"oidc",
|
||||
)
|
||||
require.NoError(t, err, "auth-path registration must not panic on nil old user")
|
||||
require.True(t, node.Valid())
|
||||
assert.NotEqual(t, types.NodeID(99002), node.ID(), "new node, not orphan")
|
||||
assert.Equal(t, userB.ID, node.UserID().Get(), "new node belongs to userB")
|
||||
}
|
||||
|
||||
@@ -379,7 +379,7 @@ func TestEphemeralGarbageCollectorConcurrentScheduleAndClose(t *testing.T) {
|
||||
stopScheduling := make(chan struct{})
|
||||
|
||||
// Track how many nodes have been scheduled
|
||||
var scheduledCount atomic.Int64
|
||||
var scheduledCount int64
|
||||
|
||||
// Launch goroutines that continuously schedule nodes
|
||||
for schedulerIndex := range numSchedulers {
|
||||
@@ -396,7 +396,7 @@ func TestEphemeralGarbageCollectorConcurrentScheduleAndClose(t *testing.T) {
|
||||
default:
|
||||
nodeID := types.NodeID(baseNodeID + j + 1) //nolint:gosec // safe conversion in test
|
||||
gc.Schedule(nodeID, 1*time.Hour) // Long expiry to ensure it doesn't trigger during test
|
||||
scheduledCount.Add(1)
|
||||
atomic.AddInt64(&scheduledCount, 1)
|
||||
|
||||
// Yield to other goroutines to introduce variability
|
||||
runtime.Gosched()
|
||||
@@ -410,7 +410,7 @@ func TestEphemeralGarbageCollectorConcurrentScheduleAndClose(t *testing.T) {
|
||||
defer wg.Done()
|
||||
|
||||
// Wait until enough nodes have been scheduled
|
||||
for scheduledCount.Load() < int64(numSchedulers*closeAfterNodes) {
|
||||
for atomic.LoadInt64(&scheduledCount) < int64(numSchedulers*closeAfterNodes) {
|
||||
runtime.Gosched()
|
||||
}
|
||||
|
||||
|
||||
+87
-6
@@ -5,9 +5,11 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -19,7 +21,6 @@ import (
|
||||
"gorm.io/gorm"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/types/key"
|
||||
"tailscale.com/util/dnsname"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -33,6 +34,8 @@ const (
|
||||
// ErrNodeNameNotUnique is returned when a node name is not unique.
|
||||
var ErrNodeNameNotUnique = errors.New("node name is not unique")
|
||||
|
||||
var invalidDNSRegex = regexp.MustCompile("[^a-z0-9-.]+")
|
||||
|
||||
var (
|
||||
ErrNodeNotFound = errors.New("node not found")
|
||||
ErrNodeRouteIsNotAvailable = errors.New("route is not available on node")
|
||||
@@ -288,7 +291,7 @@ func SetLastSeen(tx *gorm.DB, nodeID types.NodeID, lastSeen time.Time) error {
|
||||
func RenameNode(tx *gorm.DB,
|
||||
nodeID types.NodeID, newName string,
|
||||
) error {
|
||||
err := dnsname.ValidLabel(newName)
|
||||
err := util.ValidateHostname(newName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("renaming node: %w", err)
|
||||
}
|
||||
@@ -296,7 +299,8 @@ func RenameNode(tx *gorm.DB,
|
||||
// Check if the new name is unique
|
||||
var count int64
|
||||
|
||||
if err := tx.Model(&types.Node{}).Where("given_name = ? AND id != ?", newName, nodeID).Count(&count).Error; err != nil { //nolint:noinlineerr
|
||||
err = tx.Model(&types.Node{}).Where("given_name = ? AND id != ?", newName, nodeID).Count(&count).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking name uniqueness: %w", err)
|
||||
}
|
||||
|
||||
@@ -423,11 +427,22 @@ func RegisterNodeForTest(tx *gorm.DB, node types.Node, ipv4 *netip.Addr, ipv6 *n
|
||||
node.IPv4 = ipv4
|
||||
node.IPv6 = ipv6
|
||||
|
||||
var err error
|
||||
|
||||
node.Hostname, err = util.NormaliseHostname(node.Hostname)
|
||||
if err != nil {
|
||||
newHostname := util.InvalidString()
|
||||
log.Info().Err(err).Str(zf.InvalidHostname, node.Hostname).Str(zf.NewHostname, newHostname).Msgf("invalid hostname, replacing")
|
||||
node.Hostname = newHostname
|
||||
}
|
||||
|
||||
if node.GivenName == "" {
|
||||
node.GivenName = dnsname.SanitizeHostname(node.Hostname)
|
||||
if node.GivenName == "" {
|
||||
node.GivenName = "node"
|
||||
givenName, err := EnsureUniqueGivenName(tx, node.Hostname)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ensuring unique given name: %w", err)
|
||||
}
|
||||
|
||||
node.GivenName = givenName
|
||||
}
|
||||
|
||||
if err := tx.Save(&node).Error; err != nil { //nolint:noinlineerr
|
||||
@@ -469,6 +484,72 @@ func NodeSetMachineKey(
|
||||
}).Error
|
||||
}
|
||||
|
||||
func generateGivenName(suppliedName string, randomSuffix bool) (string, error) {
|
||||
// Strip invalid DNS characters for givenName
|
||||
suppliedName = strings.ToLower(suppliedName)
|
||||
suppliedName = invalidDNSRegex.ReplaceAllString(suppliedName, "")
|
||||
|
||||
if len(suppliedName) > util.LabelHostnameLength {
|
||||
return "", types.ErrHostnameTooLong
|
||||
}
|
||||
|
||||
if randomSuffix {
|
||||
// Trim if a hostname will be longer than 63 chars after adding the hash.
|
||||
trimmedHostnameLength := util.LabelHostnameLength - NodeGivenNameHashLength - NodeGivenNameTrimSize
|
||||
if len(suppliedName) > trimmedHostnameLength {
|
||||
suppliedName = suppliedName[:trimmedHostnameLength]
|
||||
}
|
||||
|
||||
suffix, err := util.GenerateRandomStringDNSSafe(NodeGivenNameHashLength)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
suppliedName += "-" + suffix
|
||||
}
|
||||
|
||||
return suppliedName, nil
|
||||
}
|
||||
|
||||
func isUniqueName(tx *gorm.DB, name string) (bool, error) {
|
||||
nodes := types.Nodes{}
|
||||
|
||||
err := tx.
|
||||
Where("given_name = ?", name).Find(&nodes).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return len(nodes) == 0, nil
|
||||
}
|
||||
|
||||
// EnsureUniqueGivenName generates a unique given name for a node based on its hostname.
|
||||
func EnsureUniqueGivenName(
|
||||
tx *gorm.DB,
|
||||
name string,
|
||||
) (string, error) {
|
||||
givenName, err := generateGivenName(name, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
unique, err := isUniqueName(tx, givenName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if !unique {
|
||||
postfixedName, err := generateGivenName(name, true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
givenName = postfixedName
|
||||
}
|
||||
|
||||
return givenName, nil
|
||||
}
|
||||
|
||||
// EphemeralGarbageCollector is a garbage collector that will delete nodes after
|
||||
// a certain amount of time.
|
||||
// It is used to delete ephemeral nodes that have disconnected and should be
|
||||
|
||||
+395
-3
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/netip"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -226,6 +227,122 @@ func TestSetTags(t *testing.T) {
|
||||
assert.Equal(t, []string{"tag:bar", "tag:test", "tag:unknown"}, node.Tags)
|
||||
}
|
||||
|
||||
func TestHeadscale_generateGivenName(t *testing.T) {
|
||||
type args struct {
|
||||
suppliedName string
|
||||
randomSuffix bool
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want *regexp.Regexp
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "simple node name generation",
|
||||
args: args{
|
||||
suppliedName: "testnode",
|
||||
randomSuffix: false,
|
||||
},
|
||||
want: regexp.MustCompile("^testnode$"),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "UPPERCASE node name generation",
|
||||
args: args{
|
||||
suppliedName: "TestNode",
|
||||
randomSuffix: false,
|
||||
},
|
||||
want: regexp.MustCompile("^testnode$"),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "node name with 53 chars",
|
||||
args: args{
|
||||
suppliedName: "testmaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaachine",
|
||||
randomSuffix: false,
|
||||
},
|
||||
want: regexp.MustCompile("^testmaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaachine$"),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "node name with 63 chars",
|
||||
args: args{
|
||||
suppliedName: "nodeeeeeee12345678901234567890123456789012345678901234567890123",
|
||||
randomSuffix: false,
|
||||
},
|
||||
want: regexp.MustCompile("^nodeeeeeee12345678901234567890123456789012345678901234567890123$"),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "node name with 64 chars",
|
||||
args: args{
|
||||
suppliedName: "nodeeeeeee123456789012345678901234567890123456789012345678901234",
|
||||
randomSuffix: false,
|
||||
},
|
||||
want: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "node name with 73 chars",
|
||||
args: args{
|
||||
suppliedName: "nodeeeeeee123456789012345678901234567890123456789012345678901234567890123",
|
||||
randomSuffix: false,
|
||||
},
|
||||
want: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "node name with random suffix",
|
||||
args: args{
|
||||
suppliedName: "test",
|
||||
randomSuffix: true,
|
||||
},
|
||||
want: regexp.MustCompile(fmt.Sprintf("^test-[a-z0-9]{%d}$", NodeGivenNameHashLength)),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "node name with 63 chars with random suffix",
|
||||
args: args{
|
||||
suppliedName: "nodeeee12345678901234567890123456789012345678901234567890123",
|
||||
randomSuffix: true,
|
||||
},
|
||||
want: regexp.MustCompile(fmt.Sprintf("^nodeeee1234567890123456789012345678901234567890123456-[a-z0-9]{%d}$", NodeGivenNameHashLength)),
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := generateGivenName(tt.args.suppliedName, tt.args.randomSuffix)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf(
|
||||
"Headscale.GenerateGivenName() error = %v, wantErr %v",
|
||||
err,
|
||||
tt.wantErr,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if tt.want != nil && !tt.want.MatchString(got) {
|
||||
t.Errorf(
|
||||
"Headscale.GenerateGivenName() = %v, does not match %v",
|
||||
tt.want,
|
||||
got,
|
||||
)
|
||||
}
|
||||
|
||||
if len(got) > util.LabelHostnameLength {
|
||||
t.Errorf(
|
||||
"Headscale.GenerateGivenName() = %v is larger than allowed DNS segment %d",
|
||||
got,
|
||||
util.LabelHostnameLength,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoApproveRoutes(t *testing.T) {
|
||||
tests := []struct {
|
||||
@@ -516,7 +633,7 @@ func TestEphemeralGarbageCollectorLoads(t *testing.T) {
|
||||
|
||||
want := 1000
|
||||
|
||||
var deletedCount atomic.Int64
|
||||
var deletedCount int64
|
||||
|
||||
e := NewEphemeralGarbageCollector(func(ni types.NodeID) {
|
||||
mu.Lock()
|
||||
@@ -527,7 +644,7 @@ func TestEphemeralGarbageCollectorLoads(t *testing.T) {
|
||||
|
||||
got = append(got, ni)
|
||||
|
||||
deletedCount.Add(1)
|
||||
atomic.AddInt64(&deletedCount, 1)
|
||||
})
|
||||
go e.Start()
|
||||
|
||||
@@ -538,7 +655,7 @@ func TestEphemeralGarbageCollectorLoads(t *testing.T) {
|
||||
|
||||
// Wait for all deletions to complete
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
count := deletedCount.Load()
|
||||
count := atomic.LoadInt64(&deletedCount)
|
||||
assert.Equal(c, int64(want), count, "all nodes should be deleted")
|
||||
}, 10*time.Second, 50*time.Millisecond, "waiting for all deletions")
|
||||
|
||||
@@ -625,6 +742,281 @@ func TestListEphemeralNodes(t *testing.T) {
|
||||
assert.Equal(t, nodeEph.Hostname, ephemeralNodes[0].Hostname)
|
||||
}
|
||||
|
||||
func TestNodeNaming(t *testing.T) {
|
||||
db, err := newSQLiteTestDB()
|
||||
if err != nil {
|
||||
t.Fatalf("creating db: %s", err)
|
||||
}
|
||||
|
||||
user, err := db.CreateUser(types.User{Name: "test"})
|
||||
require.NoError(t, err)
|
||||
|
||||
user2, err := db.CreateUser(types.User{Name: "user2"})
|
||||
require.NoError(t, err)
|
||||
|
||||
node := types.Node{
|
||||
ID: 0,
|
||||
MachineKey: key.NewMachine().Public(),
|
||||
NodeKey: key.NewNode().Public(),
|
||||
Hostname: "test",
|
||||
UserID: &user.ID,
|
||||
RegisterMethod: util.RegisterMethodAuthKey,
|
||||
Hostinfo: &tailcfg.Hostinfo{},
|
||||
}
|
||||
|
||||
node2 := types.Node{
|
||||
ID: 0,
|
||||
MachineKey: key.NewMachine().Public(),
|
||||
NodeKey: key.NewNode().Public(),
|
||||
Hostname: "test",
|
||||
UserID: &user2.ID,
|
||||
RegisterMethod: util.RegisterMethodAuthKey,
|
||||
Hostinfo: &tailcfg.Hostinfo{},
|
||||
}
|
||||
|
||||
// Using non-ASCII characters in the hostname can
|
||||
// break your network, so they should be replaced when registering
|
||||
// a node.
|
||||
// https://github.com/juanfont/headscale/issues/2343
|
||||
nodeInvalidHostname := types.Node{
|
||||
MachineKey: key.NewMachine().Public(),
|
||||
NodeKey: key.NewNode().Public(),
|
||||
Hostname: "我的电脑", //nolint:gosmopolitan // intentional i18n test data
|
||||
UserID: &user2.ID,
|
||||
RegisterMethod: util.RegisterMethodAuthKey,
|
||||
}
|
||||
|
||||
nodeShortHostname := types.Node{
|
||||
MachineKey: key.NewMachine().Public(),
|
||||
NodeKey: key.NewNode().Public(),
|
||||
Hostname: "a",
|
||||
UserID: &user2.ID,
|
||||
RegisterMethod: util.RegisterMethodAuthKey,
|
||||
}
|
||||
|
||||
err = db.DB.Save(&node).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
err = db.DB.Save(&node2).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
err = db.DB.Transaction(func(tx *gorm.DB) error {
|
||||
_, err := RegisterNodeForTest(tx, node, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = RegisterNodeForTest(tx, node2, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, _ = RegisterNodeForTest(tx, nodeInvalidHostname, new(mpp("100.64.0.66/32").Addr()), nil)
|
||||
_, err = RegisterNodeForTest(tx, nodeShortHostname, new(mpp("100.64.0.67/32").Addr()), nil)
|
||||
|
||||
return err
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
nodes, err := db.ListNodes()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Len(t, nodes, 4)
|
||||
|
||||
t.Logf("node1 %s %s", nodes[0].Hostname, nodes[0].GivenName)
|
||||
t.Logf("node2 %s %s", nodes[1].Hostname, nodes[1].GivenName)
|
||||
t.Logf("node3 %s %s", nodes[2].Hostname, nodes[2].GivenName)
|
||||
t.Logf("node4 %s %s", nodes[3].Hostname, nodes[3].GivenName)
|
||||
|
||||
assert.Equal(t, nodes[0].Hostname, nodes[0].GivenName)
|
||||
assert.NotEqual(t, nodes[1].Hostname, nodes[1].GivenName)
|
||||
assert.Equal(t, nodes[0].Hostname, nodes[1].Hostname)
|
||||
assert.NotEqual(t, nodes[0].Hostname, nodes[1].GivenName)
|
||||
assert.Contains(t, nodes[1].GivenName, nodes[0].Hostname)
|
||||
assert.Equal(t, nodes[0].GivenName, nodes[1].Hostname)
|
||||
assert.Len(t, nodes[0].Hostname, 4)
|
||||
assert.Len(t, nodes[1].Hostname, 4)
|
||||
assert.Len(t, nodes[0].GivenName, 4)
|
||||
assert.Len(t, nodes[1].GivenName, 13)
|
||||
assert.Contains(t, nodes[2].Hostname, "invalid-") // invalid chars
|
||||
assert.Contains(t, nodes[2].GivenName, "invalid-")
|
||||
assert.Contains(t, nodes[3].Hostname, "invalid-") // too short
|
||||
assert.Contains(t, nodes[3].GivenName, "invalid-")
|
||||
|
||||
// Nodes can be renamed to a unique name
|
||||
err = db.Write(func(tx *gorm.DB) error {
|
||||
return RenameNode(tx, nodes[0].ID, "newname")
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
nodes, err = db.ListNodes()
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, nodes, 4)
|
||||
assert.Equal(t, "test", nodes[0].Hostname)
|
||||
assert.Equal(t, "newname", nodes[0].GivenName)
|
||||
|
||||
// Nodes can reuse name that is no longer used
|
||||
err = db.Write(func(tx *gorm.DB) error {
|
||||
return RenameNode(tx, nodes[1].ID, "test")
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
nodes, err = db.ListNodes()
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, nodes, 4)
|
||||
assert.Equal(t, "test", nodes[0].Hostname)
|
||||
assert.Equal(t, "newname", nodes[0].GivenName)
|
||||
assert.Equal(t, "test", nodes[1].GivenName)
|
||||
|
||||
// Nodes cannot be renamed to used names
|
||||
err = db.Write(func(tx *gorm.DB) error {
|
||||
return RenameNode(tx, nodes[0].ID, "test")
|
||||
})
|
||||
require.ErrorContains(t, err, "name is not unique")
|
||||
|
||||
// Rename invalid chars
|
||||
err = db.Write(func(tx *gorm.DB) error {
|
||||
return RenameNode(tx, nodes[2].ID, "我的电脑") //nolint:gosmopolitan // intentional i18n test data
|
||||
})
|
||||
require.ErrorContains(t, err, "invalid characters")
|
||||
|
||||
// Rename too short
|
||||
err = db.Write(func(tx *gorm.DB) error {
|
||||
return RenameNode(tx, nodes[3].ID, "a")
|
||||
})
|
||||
require.ErrorContains(t, err, "at least 2 characters")
|
||||
|
||||
// Rename with emoji
|
||||
err = db.Write(func(tx *gorm.DB) error {
|
||||
return RenameNode(tx, nodes[0].ID, "hostname-with-💩")
|
||||
})
|
||||
require.ErrorContains(t, err, "invalid characters")
|
||||
|
||||
// Rename with only emoji
|
||||
err = db.Write(func(tx *gorm.DB) error {
|
||||
return RenameNode(tx, nodes[0].ID, "🚀")
|
||||
})
|
||||
assert.ErrorContains(t, err, "invalid characters")
|
||||
}
|
||||
|
||||
func TestRenameNodeComprehensive(t *testing.T) {
|
||||
db, err := newSQLiteTestDB()
|
||||
if err != nil {
|
||||
t.Fatalf("creating db: %s", err)
|
||||
}
|
||||
|
||||
user, err := db.CreateUser(types.User{Name: "test"})
|
||||
require.NoError(t, err)
|
||||
|
||||
node := types.Node{
|
||||
ID: 0,
|
||||
MachineKey: key.NewMachine().Public(),
|
||||
NodeKey: key.NewNode().Public(),
|
||||
Hostname: "testnode",
|
||||
UserID: &user.ID,
|
||||
RegisterMethod: util.RegisterMethodAuthKey,
|
||||
Hostinfo: &tailcfg.Hostinfo{},
|
||||
}
|
||||
|
||||
err = db.DB.Save(&node).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
err = db.DB.Transaction(func(tx *gorm.DB) error {
|
||||
_, err := RegisterNodeForTest(tx, node, nil, nil)
|
||||
return err
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
nodes, err := db.ListNodes()
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, nodes, 1)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
newName string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "uppercase_rejected",
|
||||
newName: "User2-Host",
|
||||
wantErr: "must be lowercase",
|
||||
},
|
||||
{
|
||||
name: "underscore_rejected",
|
||||
newName: "test_node",
|
||||
wantErr: "invalid characters",
|
||||
},
|
||||
{
|
||||
name: "at_sign_uppercase_rejected",
|
||||
newName: "Test@Host",
|
||||
wantErr: "must be lowercase",
|
||||
},
|
||||
{
|
||||
name: "at_sign_rejected",
|
||||
newName: "test@host",
|
||||
wantErr: "invalid characters",
|
||||
},
|
||||
{
|
||||
name: "chinese_chars_with_dash_rejected",
|
||||
newName: "server-北京-01", //nolint:gosmopolitan // intentional i18n test data
|
||||
wantErr: "invalid characters",
|
||||
},
|
||||
{
|
||||
name: "chinese_only_rejected",
|
||||
newName: "我的电脑", //nolint:gosmopolitan // intentional i18n test data
|
||||
wantErr: "invalid characters",
|
||||
},
|
||||
{
|
||||
name: "emoji_with_text_rejected",
|
||||
newName: "laptop-🚀",
|
||||
wantErr: "invalid characters",
|
||||
},
|
||||
{
|
||||
name: "mixed_chinese_emoji_rejected",
|
||||
newName: "测试💻机器", //nolint:gosmopolitan // intentional i18n test data
|
||||
wantErr: "invalid characters",
|
||||
},
|
||||
{
|
||||
name: "only_emojis_rejected",
|
||||
newName: "🎉🎊",
|
||||
wantErr: "invalid characters",
|
||||
},
|
||||
{
|
||||
name: "only_at_signs_rejected",
|
||||
newName: "@@@",
|
||||
wantErr: "invalid characters",
|
||||
},
|
||||
{
|
||||
name: "starts_with_dash_rejected",
|
||||
newName: "-test",
|
||||
wantErr: "cannot start or end with a hyphen",
|
||||
},
|
||||
{
|
||||
name: "ends_with_dash_rejected",
|
||||
newName: "test-",
|
||||
wantErr: "cannot start or end with a hyphen",
|
||||
},
|
||||
{
|
||||
name: "too_long_hostname_rejected",
|
||||
newName: "this-is-a-very-long-hostname-that-exceeds-sixty-three-characters-limit",
|
||||
wantErr: "must not exceed 63 characters",
|
||||
},
|
||||
{
|
||||
name: "too_short_hostname_rejected",
|
||||
newName: "a",
|
||||
wantErr: "at least 2 characters",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := db.Write(func(tx *gorm.DB) error {
|
||||
return RenameNode(tx, nodes[0].ID, tt.newName)
|
||||
})
|
||||
assert.ErrorContains(t, err, tt.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPeers(t *testing.T) {
|
||||
// Setup test database
|
||||
|
||||
@@ -28,7 +28,7 @@ func (hsdb *HSDatabase) CreateUser(user types.User) (*types.User, error) {
|
||||
// CreateUser creates a new User. Returns error if could not be created
|
||||
// or another user already exists.
|
||||
func CreateUser(tx *gorm.DB, user types.User) (*types.User, error) {
|
||||
err := util.ValidateUsername(user.Name)
|
||||
err := util.ValidateHostname(user.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -102,7 +102,7 @@ func RenameUser(tx *gorm.DB, uid types.UserID, newName string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = util.ValidateUsername(newName); err != nil { //nolint:noinlineerr
|
||||
if err = util.ValidateHostname(newName); err != nil { //nolint:noinlineerr
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ func TestDestroyUserErrors(t *testing.T) {
|
||||
user, err := db.CreateUser(types.User{Name: "test"})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a tagged node with no user_id (the rule for tagged nodes).
|
||||
// Create a tagged node with no user_id (the invariant).
|
||||
node := types.Node{
|
||||
ID: 0,
|
||||
Hostname: "tagged-node",
|
||||
|
||||
+4
-138
@@ -1,21 +1,16 @@
|
||||
package hscontrol
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/arl/statsviz"
|
||||
"github.com/juanfont/headscale/hscontrol/templates"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/types/change"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tsweb"
|
||||
)
|
||||
|
||||
@@ -329,41 +324,6 @@ func (h *Headscale) debugHTTPServer() *http.Server {
|
||||
}
|
||||
}))
|
||||
|
||||
// Ping endpoint: sends a PingRequest to a node and waits for it to respond.
|
||||
// Supports POST (form submit) and GET with ?node= (clickable quick-ping links).
|
||||
debug.Handle("ping", "Ping a node to check connectivity", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
query string
|
||||
result *templates.PingResult
|
||||
)
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 4096) //nolint:mnd
|
||||
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
http.Error(w, "bad form data", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
query = r.FormValue("node")
|
||||
result = h.doPing(r.Context(), query)
|
||||
case http.MethodGet:
|
||||
// Support ?node= for auto-ping links from other debug pages.
|
||||
if q := r.URL.Query().Get("node"); q != "" {
|
||||
query = q
|
||||
result = h.doPing(r.Context(), query)
|
||||
}
|
||||
}
|
||||
|
||||
nodes := h.connectedNodesList()
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(templates.PingPage(query, result, nodes).Render()))
|
||||
}))
|
||||
|
||||
// statsviz.Register would mount handlers directly on the raw mux,
|
||||
// bypassing the access gate. Build the server by hand and wrap
|
||||
// each handler with protectedDebugHandler.
|
||||
@@ -379,7 +339,7 @@ func (h *Headscale) debugHTTPServer() *http.Server {
|
||||
|
||||
debugHTTPServer := &http.Server{
|
||||
Addr: h.cfg.MetricsAddr,
|
||||
Handler: securityHeaders(debugMux),
|
||||
Handler: debugMux,
|
||||
ReadTimeout: types.HTTPTimeout,
|
||||
WriteTimeout: 0,
|
||||
}
|
||||
@@ -435,13 +395,13 @@ func (h *Headscale) debugBatcher() string {
|
||||
}
|
||||
|
||||
if node.activeConnections > 0 {
|
||||
fmt.Fprintf(&sb, "Node %d:\t%s (%d connections)\n", node.id, status, node.activeConnections)
|
||||
sb.WriteString(fmt.Sprintf("Node %d:\t%s (%d connections)\n", node.id, status, node.activeConnections))
|
||||
} else {
|
||||
fmt.Fprintf(&sb, "Node %d:\t%s\n", node.id, status)
|
||||
sb.WriteString(fmt.Sprintf("Node %d:\t%s\n", node.id, status))
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(&sb, "\nSummary: %d connected, %d total\n", connectedCount, totalNodes)
|
||||
sb.WriteString(fmt.Sprintf("\nSummary: %d connected, %d total\n", connectedCount, totalNodes))
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
@@ -476,97 +436,3 @@ func (h *Headscale) debugBatcherJSON() DebugBatcherInfo {
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
// connectedNodesList returns a list of connected nodes for the ping page.
|
||||
func (h *Headscale) connectedNodesList() []templates.ConnectedNode {
|
||||
debugInfo := h.mapBatcher.Debug()
|
||||
|
||||
var nodes []templates.ConnectedNode
|
||||
|
||||
for nodeID, info := range debugInfo {
|
||||
if !info.Connected {
|
||||
continue
|
||||
}
|
||||
|
||||
nv, ok := h.state.GetNodeByID(nodeID)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
cn := templates.ConnectedNode{
|
||||
ID: nodeID,
|
||||
Hostname: nv.Hostname(),
|
||||
}
|
||||
|
||||
for _, ip := range nv.IPs() {
|
||||
cn.IPs = append(cn.IPs, ip.String())
|
||||
}
|
||||
|
||||
nodes = append(nodes, cn)
|
||||
}
|
||||
|
||||
return nodes
|
||||
}
|
||||
|
||||
const pingTimeout = 30 * time.Second
|
||||
|
||||
// doPing sends a PingRequest to the node identified by query and waits for a response.
|
||||
func (h *Headscale) doPing(ctx context.Context, query string) *templates.PingResult {
|
||||
if query == "" {
|
||||
return &templates.PingResult{
|
||||
Status: "error",
|
||||
Message: "No node specified.",
|
||||
}
|
||||
}
|
||||
|
||||
node, ok := h.state.ResolveNode(query)
|
||||
if !ok {
|
||||
return &templates.PingResult{
|
||||
Status: "error",
|
||||
Message: fmt.Sprintf("Node %q not found.", query),
|
||||
}
|
||||
}
|
||||
|
||||
nodeID := node.ID()
|
||||
|
||||
if !h.mapBatcher.IsConnected(nodeID) {
|
||||
return &templates.PingResult{
|
||||
Status: "error",
|
||||
NodeID: nodeID,
|
||||
Message: fmt.Sprintf("Node %d is not connected.", nodeID),
|
||||
}
|
||||
}
|
||||
|
||||
pingID, responseCh := h.state.RegisterPing(nodeID)
|
||||
defer h.state.CancelPing(pingID)
|
||||
|
||||
// The callback hits /machine/ping-response on the main TLS router,
|
||||
// not the noise chain, so URLIsNoise stays false. Operators running
|
||||
// server_url over plain HTTP are on their own — don't do that.
|
||||
callbackURL := h.cfg.ServerURL + "/machine/ping-response?id=" + pingID
|
||||
h.Change(change.PingNode(nodeID, &tailcfg.PingRequest{
|
||||
URL: callbackURL,
|
||||
Log: true,
|
||||
}))
|
||||
|
||||
select {
|
||||
case latency := <-responseCh:
|
||||
return &templates.PingResult{
|
||||
Status: "ok",
|
||||
Latency: latency,
|
||||
NodeID: nodeID,
|
||||
}
|
||||
case <-time.After(pingTimeout):
|
||||
return &templates.PingResult{
|
||||
Status: "timeout",
|
||||
NodeID: nodeID,
|
||||
Message: fmt.Sprintf("No response after %s.", pingTimeout),
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return &templates.PingResult{
|
||||
Status: "error",
|
||||
NodeID: nodeID,
|
||||
Message: "Request cancelled.",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ import (
|
||||
"tailscale.com/types/views"
|
||||
|
||||
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
|
||||
policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2"
|
||||
"github.com/juanfont/headscale/hscontrol/state"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/util"
|
||||
@@ -782,35 +781,6 @@ func (api headscaleV1APIServer) SetPolicy(
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// CheckPolicy validates the given policy against the server's live users
|
||||
// and nodes, running its `tests` block as a sandbox. Nothing is persisted
|
||||
// and the live PolicyManager is not touched. Works regardless of
|
||||
// policy.mode so operators can validate a policy file before storing it.
|
||||
func (api headscaleV1APIServer) CheckPolicy(
|
||||
_ context.Context,
|
||||
request *v1.CheckPolicyRequest,
|
||||
) (*v1.CheckPolicyResponse, error) {
|
||||
polB := []byte(request.GetPolicy())
|
||||
|
||||
users, err := api.h.state.ListAllUsers()
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "loading users: %s", err)
|
||||
}
|
||||
|
||||
nodes := api.h.state.ListNodes()
|
||||
|
||||
pm, err := policyv2.NewPolicyManager(polB, users, nodes)
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||
}
|
||||
|
||||
if _, err := pm.SetPolicy(polB); err != nil {
|
||||
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||
}
|
||||
|
||||
return &v1.CheckPolicyResponse{}, nil
|
||||
}
|
||||
|
||||
// The following service calls are for testing and debugging
|
||||
func (api headscaleV1APIServer) DebugCreateNode(
|
||||
ctx context.Context,
|
||||
|
||||
@@ -596,7 +596,7 @@ func TestDeleteUser_TaggedNodeSurvives(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.True(t, resp.MachineAuthorized)
|
||||
|
||||
// Verify the registered node has nil UserID (enforced at registration).
|
||||
// Verify the registered node has nil UserID (enforced invariant).
|
||||
node, found := app.state.GetNodeByNodeKey(nodeKey.Public())
|
||||
require.True(t, found)
|
||||
require.True(t, node.IsTagged())
|
||||
|
||||
@@ -44,54 +44,6 @@ func httpError(w http.ResponseWriter, err error) {
|
||||
}
|
||||
}
|
||||
|
||||
// httpUserError logs an error and sends a styled HTML error page.
|
||||
// Use this for browser-facing error paths (OIDC, registration confirm)
|
||||
// where the user should see a branded page instead of plain text.
|
||||
// Technical details go to the server log; the HTML page only shows
|
||||
// an actionable message derived from the HTTP status code.
|
||||
func httpUserError(w http.ResponseWriter, err error) {
|
||||
code := http.StatusInternalServerError
|
||||
|
||||
if herr, ok := errors.AsType[HTTPError](err); ok {
|
||||
if herr.Code != 0 {
|
||||
code = herr.Code
|
||||
}
|
||||
|
||||
log.Error().Err(herr.Err).Int("code", code).Msgf("user msg: %s", herr.Msg)
|
||||
} else {
|
||||
log.Error().Err(err).Int("code", code).Msg("http internal server error")
|
||||
}
|
||||
|
||||
userMsg := userMessageForStatusCode(code)
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(code)
|
||||
|
||||
page := templates.AuthError(templates.AuthErrorResult{
|
||||
Title: "Headscale - Error",
|
||||
Heading: http.StatusText(code),
|
||||
Message: userMsg,
|
||||
})
|
||||
|
||||
_, werr := w.Write([]byte(page.Render()))
|
||||
if werr != nil {
|
||||
log.Error().Err(werr).Msg("failed to write HTML error response")
|
||||
}
|
||||
}
|
||||
|
||||
func userMessageForStatusCode(code int) string {
|
||||
switch {
|
||||
case code == http.StatusUnauthorized || code == http.StatusForbidden:
|
||||
return "You are not authorized. Please contact your administrator."
|
||||
case code == http.StatusGone:
|
||||
return "Your session has expired. Please try again."
|
||||
case code >= 400 && code < 500:
|
||||
return "The request could not be processed. Please try again."
|
||||
default:
|
||||
return "Something went wrong. Please try again later."
|
||||
}
|
||||
}
|
||||
|
||||
// HTTPError represents an error that is surfaced to the user via web.
|
||||
type HTTPError struct {
|
||||
Code int // HTTP response code to send to client; 0 means 500
|
||||
|
||||
@@ -12,8 +12,6 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
var errTestUnexpected = errors.New("unexpected failure")
|
||||
|
||||
// TestHandleVerifyRequest_OversizedBodyRejected verifies that the
|
||||
// /verify handler refuses POST bodies larger than verifyBodyLimit.
|
||||
// The MaxBytesReader is applied in VerifyHandler, so we simulate
|
||||
@@ -57,73 +55,3 @@ func errorAsHTTPError(err error) (HTTPError, bool) {
|
||||
|
||||
return HTTPError{}, false
|
||||
}
|
||||
|
||||
func TestHttpUserError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
wantCode int
|
||||
wantContains string
|
||||
wantNotContain string
|
||||
}{
|
||||
{
|
||||
name: "forbidden_renders_authorization_message",
|
||||
err: NewHTTPError(http.StatusForbidden, "csrf token mismatch", nil),
|
||||
wantCode: http.StatusForbidden,
|
||||
wantContains: "You are not authorized. Please contact your administrator.",
|
||||
wantNotContain: "csrf token mismatch",
|
||||
},
|
||||
{
|
||||
name: "unauthorized_renders_authorization_message",
|
||||
err: NewHTTPError(http.StatusUnauthorized, "unauthorised domain", nil),
|
||||
wantCode: http.StatusUnauthorized,
|
||||
wantContains: "You are not authorized. Please contact your administrator.",
|
||||
wantNotContain: "unauthorised domain",
|
||||
},
|
||||
{
|
||||
name: "gone_renders_session_expired",
|
||||
err: NewHTTPError(http.StatusGone, "login session expired, try again", nil),
|
||||
wantCode: http.StatusGone,
|
||||
wantContains: "Your session has expired. Please try again.",
|
||||
wantNotContain: "login session expired",
|
||||
},
|
||||
{
|
||||
name: "bad_request_renders_generic_retry",
|
||||
err: NewHTTPError(http.StatusBadRequest, "state not found", nil),
|
||||
wantCode: http.StatusBadRequest,
|
||||
wantContains: "The request could not be processed. Please try again.",
|
||||
wantNotContain: "state not found",
|
||||
},
|
||||
{
|
||||
name: "plain_error_renders_500",
|
||||
err: errTestUnexpected,
|
||||
wantCode: http.StatusInternalServerError,
|
||||
wantContains: "Something went wrong. Please try again later.",
|
||||
},
|
||||
{
|
||||
name: "html_structure_present",
|
||||
err: NewHTTPError(http.StatusGone, "session expired", nil),
|
||||
wantCode: http.StatusGone,
|
||||
wantContains: "<!DOCTYPE html>",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
httpUserError(rec, tt.err)
|
||||
|
||||
assert.Equal(t, tt.wantCode, rec.Code)
|
||||
assert.Contains(t, rec.Header().Get("Content-Type"), "text/html")
|
||||
assert.Contains(t, rec.Body.String(), tt.wantContains)
|
||||
|
||||
if tt.wantNotContain != "" {
|
||||
assert.NotContains(t, rec.Body.String(), tt.wantNotContain)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -782,7 +782,6 @@ func TestBug3_CleanupOfflineNodes_TOCTOU(t *testing.T) {
|
||||
entry.lastUsed.Store(time.Now().Unix())
|
||||
mc.addConnection(entry)
|
||||
mc.markConnected()
|
||||
|
||||
lb.channels[targetNode] = newCh
|
||||
|
||||
// Now run cleanup. Node 3 is in the candidates list (old disconnect
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/juanfont/headscale/hscontrol/state"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/types/change"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"tailscale.com/tailcfg"
|
||||
@@ -140,9 +141,9 @@ type node struct {
|
||||
ch chan *tailcfg.MapResponse
|
||||
|
||||
// Update tracking (all accessed atomically for thread safety)
|
||||
updateCount atomic.Int64
|
||||
patchCount atomic.Int64
|
||||
fullCount atomic.Int64
|
||||
updateCount int64
|
||||
patchCount int64
|
||||
fullCount int64
|
||||
maxPeersCount atomic.Int64
|
||||
lastPeerCount atomic.Int64
|
||||
stop chan struct{}
|
||||
@@ -403,14 +404,14 @@ func (n *node) start() {
|
||||
for {
|
||||
select {
|
||||
case data := <-n.ch:
|
||||
n.updateCount.Add(1)
|
||||
atomic.AddInt64(&n.updateCount, 1)
|
||||
|
||||
// Parse update and track detailed stats
|
||||
info := parseUpdateAndAnalyze(data)
|
||||
{
|
||||
// Track update types
|
||||
if info.IsFull {
|
||||
n.fullCount.Add(1)
|
||||
atomic.AddInt64(&n.fullCount, 1)
|
||||
n.lastPeerCount.Store(int64(info.PeerCount))
|
||||
// Update max peers seen using compare-and-swap for thread safety
|
||||
for {
|
||||
@@ -426,7 +427,7 @@ func (n *node) start() {
|
||||
}
|
||||
|
||||
if info.IsPatch {
|
||||
n.patchCount.Add(1)
|
||||
atomic.AddInt64(&n.patchCount, 1)
|
||||
// For patches, we track how many patch items using compare-and-swap
|
||||
for {
|
||||
current := n.maxPeersCount.Load()
|
||||
@@ -465,9 +466,9 @@ func (n *node) cleanup() NodeStats {
|
||||
}
|
||||
|
||||
return NodeStats{
|
||||
TotalUpdates: n.updateCount.Load(),
|
||||
PatchUpdates: n.patchCount.Load(),
|
||||
FullUpdates: n.fullCount.Load(),
|
||||
TotalUpdates: atomic.LoadInt64(&n.updateCount),
|
||||
PatchUpdates: atomic.LoadInt64(&n.patchCount),
|
||||
FullUpdates: atomic.LoadInt64(&n.fullCount),
|
||||
MaxPeersSeen: int(n.maxPeersCount.Load()),
|
||||
LastPeerCount: int(n.lastPeerCount.Load()),
|
||||
}
|
||||
@@ -508,7 +509,7 @@ func TestEnhancedNodeTracking(t *testing.T) {
|
||||
|
||||
// Wait for tracking goroutine to process the update
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
assert.GreaterOrEqual(c, testNode.updateCount.Load(), int64(1), "should have processed the update")
|
||||
assert.GreaterOrEqual(c, atomic.LoadInt64(&testNode.updateCount), int64(1), "should have processed the update")
|
||||
}, time.Second, 10*time.Millisecond, "waiting for update to be processed")
|
||||
|
||||
// Check stats
|
||||
@@ -552,7 +553,7 @@ func TestEnhancedTrackingWithBatcher(t *testing.T) {
|
||||
|
||||
// Wait for updates to be processed (at least 1 update received)
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
assert.GreaterOrEqual(c, testNode.updateCount.Load(), int64(1), "should have received updates")
|
||||
assert.GreaterOrEqual(c, atomic.LoadInt64(&testNode.updateCount), int64(1), "should have received updates")
|
||||
}, time.Second, 10*time.Millisecond, "waiting for updates to be processed")
|
||||
|
||||
// Check stats
|
||||
@@ -573,6 +574,12 @@ func TestEnhancedTrackingWithBatcher(t *testing.T) {
|
||||
// and ensure all nodes can see all other nodes. This is a critical test for mesh network
|
||||
// functionality where every node must be able to communicate with every other node.
|
||||
func TestBatcherScalabilityAllToAll(t *testing.T) {
|
||||
// Reduce verbose application logging for cleaner test output
|
||||
originalLevel := zerolog.GlobalLevel()
|
||||
defer zerolog.SetGlobalLevel(originalLevel)
|
||||
|
||||
zerolog.SetGlobalLevel(zerolog.ErrorLevel)
|
||||
|
||||
// Test cases: different node counts to stress test the all-to-all connectivity
|
||||
testCases := []struct {
|
||||
name string
|
||||
@@ -2134,8 +2141,8 @@ func TestNodeDeletedWhileChangesPending(t *testing.T) {
|
||||
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
// Node 1 and 2 should receive updates
|
||||
stats1 := NodeStats{TotalUpdates: node1.updateCount.Load()}
|
||||
stats2 := NodeStats{TotalUpdates: node2.updateCount.Load()}
|
||||
stats1 := NodeStats{TotalUpdates: atomic.LoadInt64(&node1.updateCount)}
|
||||
stats2 := NodeStats{TotalUpdates: atomic.LoadInt64(&node2.updateCount)}
|
||||
assert.Positive(c, stats1.TotalUpdates, "node1 should have received updates")
|
||||
assert.Positive(c, stats2.TotalUpdates, "node2 should have received updates")
|
||||
}, 5*time.Second, 100*time.Millisecond, "waiting for remaining nodes to receive updates")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -300,12 +277,6 @@ func (b *MapResponseBuilder) buildTailPeers(peers views.Slice[types.NodeView]) (
|
||||
return tailPeers, nil
|
||||
}
|
||||
|
||||
// WithPingRequest adds a PingRequest to the response.
|
||||
func (b *MapResponseBuilder) WithPingRequest(pr *tailcfg.PingRequest) *MapResponseBuilder {
|
||||
b.resp.PingRequest = pr
|
||||
return b
|
||||
}
|
||||
|
||||
// WithPeerChangedPatch adds peer change patches.
|
||||
func (b *MapResponseBuilder) WithPeerChangedPatch(changes []*tailcfg.PeerChange) *MapResponseBuilder {
|
||||
b.resp.PeersChangedPatch = changes
|
||||
|
||||
+18
-139
@@ -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)
|
||||
}
|
||||
|
||||
@@ -425,10 +308,6 @@ func (m *mapper) buildFromChange(
|
||||
builder.WithPeerChangedPatch(resp.PeerPatches)
|
||||
}
|
||||
|
||||
if resp.PingRequest != nil {
|
||||
builder.WithPingRequest(resp.PingRequest)
|
||||
}
|
||||
|
||||
return builder.Build()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+27
-234
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
"github.com/juanfont/headscale/hscontrol/routes"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/tailcfg"
|
||||
@@ -16,8 +17,6 @@ import (
|
||||
)
|
||||
|
||||
func TestTailNode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mustNK := func(str string) key.NodePublic {
|
||||
var k key.NodePublic
|
||||
|
||||
@@ -53,6 +52,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 +75,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 +166,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 +193,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,34 +209,27 @@ func TestTailNode(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
primary := routes.New()
|
||||
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},
|
||||
}
|
||||
_ = primary.SetRoutes(tt.node.ID, tt.node.SubnetRoutes()...)
|
||||
|
||||
// Stub primary-route lookup: tt.node owns its SubnetRoutes,
|
||||
// node ID 2 owns 192.168.0.0/24 (a hack carried over from
|
||||
// the original routes-package-driven version of this test —
|
||||
// avoids spinning up a second node just to validate that
|
||||
// other nodes' primaries don't leak into tt.node's TailNode
|
||||
// output).
|
||||
primaries := map[types.NodeID][]netip.Prefix{
|
||||
tt.node.ID: tt.node.SubnetRoutes(),
|
||||
2: {netip.MustParsePrefix("192.168.0.0/24")},
|
||||
}
|
||||
// This is a hack to avoid having a second node to test the primary route.
|
||||
// This should be baked into the test case proper if it is extended in the future.
|
||||
_ = primary.SetRoutes(2, netip.MustParsePrefix("192.168.0.0/24"))
|
||||
nv := tt.node.View()
|
||||
got, err := nv.TailNode(
|
||||
0,
|
||||
func(id types.NodeID) []netip.Prefix {
|
||||
// Route function returns primaries + exit routes
|
||||
// (matching the real caller contract).
|
||||
return slices.Concat(primaries[id], nv.ExitRoutes())
|
||||
return slices.Concat(primary.PrimaryRoutes(id), nv.ExitRoutes())
|
||||
},
|
||||
cfg,
|
||||
nil,
|
||||
)
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
@@ -249,208 +245,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 +286,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)
|
||||
|
||||
+4
-28
@@ -169,7 +169,7 @@ func (h *Headscale) NoiseUpgradeHandler(
|
||||
r.Post("/map", ns.PollNetMapHandler)
|
||||
|
||||
// SSH Check mode endpoint, consulted to validate if a given SSH connection should be accepted or rejected.
|
||||
r.Get("/ssh/action/{src_node_id}/to/{dst_node_id}", ns.SSHActionHandler)
|
||||
r.Get("/ssh/action/from/{src_node_id}/to/{dst_node_id}", ns.SSHActionHandler)
|
||||
|
||||
// Not implemented yet
|
||||
//
|
||||
@@ -295,31 +295,6 @@ func (ns *noiseServer) NotImplementedHandler(writer http.ResponseWriter, req *ht
|
||||
http.Error(writer, "Not implemented yet", http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
// PingResponseHandler handles HEAD requests from clients responding to a
|
||||
// PingRequest. The client calls this endpoint to prove connectivity.
|
||||
// The unguessable ping ID serves as authentication.
|
||||
func (h *Headscale) PingResponseHandler(
|
||||
writer http.ResponseWriter,
|
||||
req *http.Request,
|
||||
) {
|
||||
if req.Method != http.MethodHead {
|
||||
http.Error(writer, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
pingID := req.URL.Query().Get("id")
|
||||
if pingID == "" {
|
||||
http.Error(writer, "missing ping ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if h.state.CompletePing(pingID) {
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
} else {
|
||||
http.Error(writer, "unknown or expired ping", http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func urlParam[T any](req *http.Request, key string) (T, error) {
|
||||
var zero T
|
||||
|
||||
@@ -419,6 +394,7 @@ func (ns *noiseServer) SSHActionHandler(
|
||||
reqLog := log.With().
|
||||
Uint64("src_node_id", srcNodeID.Uint64()).
|
||||
Uint64("dst_node_id", dstNodeID.Uint64()).
|
||||
Str("ssh_user", req.URL.Query().Get("ssh_user")).
|
||||
Str("local_user", req.URL.Query().Get("local_user")).
|
||||
Logger()
|
||||
|
||||
@@ -519,8 +495,8 @@ func (ns *noiseServer) sshActionHoldAndDelegate(
|
||||
) (*tailcfg.SSHAction, error) {
|
||||
holdURL, err := url.Parse(
|
||||
ns.headscale.cfg.ServerURL +
|
||||
"/machine/ssh/action/$SRC_NODE_ID/to/$DST_NODE_ID" +
|
||||
"?local_user=$LOCAL_USER",
|
||||
"/machine/ssh/action/from/$SRC_NODE_ID/to/$DST_NODE_ID" +
|
||||
"?ssh_user=$SSH_USER&local_user=$LOCAL_USER",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, NewHTTPError(
|
||||
|
||||
+2
-39
@@ -198,50 +198,13 @@ func TestRegistrationHandler_OversizedBody(t *testing.T) {
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
}
|
||||
|
||||
// TestSSHActionRoute_OldPathReturns404 pins the wire-format shape of the
|
||||
// SSH check-action endpoint. Pre-alignment headscale served
|
||||
// /machine/ssh/action/from/{src}/to/{dst}?ssh_user=...; the current
|
||||
// endpoint is /machine/ssh/action/{src}/to/{dst}?local_user=.... If
|
||||
// someone re-adds the old route shape, this fails.
|
||||
func TestSSHActionRoute_OldPathReturns404(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Route("/machine", func(r chi.Router) {
|
||||
r.Get("/ssh/action/{src_node_id}/to/{dst_node_id}", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
})
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
path string
|
||||
want int
|
||||
}{
|
||||
{"new", "/machine/ssh/action/1/to/2", http.StatusOK},
|
||||
{"old-with-from", "/machine/ssh/action/from/1/to/2", http.StatusNotFound},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, tc.path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, tc.want, rec.Code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// newSSHActionRequest builds an httptest request with the chi URL params
|
||||
// SSHActionHandler reads (src_node_id and dst_node_id), so the handler
|
||||
// can be exercised directly without going through the chi router.
|
||||
func newSSHActionRequest(t *testing.T, src, dst types.NodeID) *http.Request {
|
||||
t.Helper()
|
||||
|
||||
url := fmt.Sprintf("/machine/ssh/action/%d/to/%d", src.Uint64(), dst.Uint64())
|
||||
url := fmt.Sprintf("/machine/ssh/action/from/%d/to/%d", src.Uint64(), dst.Uint64())
|
||||
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
||||
|
||||
rctx := chi.NewRouteContext()
|
||||
@@ -352,7 +315,7 @@ func TestSSHActionFollowUp_RejectsBindingMismatch(t *testing.T) {
|
||||
}
|
||||
|
||||
url := fmt.Sprintf(
|
||||
"/machine/ssh/action/%d/to/%d?auth_id=%s",
|
||||
"/machine/ssh/action/from/%d/to/%d?auth_id=%s",
|
||||
srcOther.ID.Uint64(), dstOther.ID.Uint64(), authID.String(),
|
||||
)
|
||||
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
||||
|
||||
+48
-39
@@ -155,21 +155,21 @@ func (a *AuthProviderOIDC) authHandler(
|
||||
) {
|
||||
authID, err := authIDFromRequest(req)
|
||||
if err != nil {
|
||||
httpUserError(writer, err)
|
||||
httpError(writer, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Set the state and nonce cookies to protect against CSRF attacks
|
||||
state, err := setCSRFCookie(writer, req, "state")
|
||||
if err != nil {
|
||||
httpUserError(writer, err)
|
||||
httpError(writer, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Set the state and nonce cookies to protect against CSRF attacks
|
||||
nonce, err := setCSRFCookie(writer, req, "nonce")
|
||||
if err != nil {
|
||||
httpUserError(writer, err)
|
||||
httpError(writer, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -222,7 +222,7 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
|
||||
) {
|
||||
code, state, err := extractCodeAndStateParamFromRequest(req)
|
||||
if err != nil {
|
||||
httpUserError(writer, err)
|
||||
httpError(writer, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -230,29 +230,29 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
|
||||
|
||||
cookieState, err := req.Cookie(stateCookieName)
|
||||
if err != nil {
|
||||
httpUserError(writer, NewHTTPError(http.StatusBadRequest, "state not found", err))
|
||||
httpError(writer, NewHTTPError(http.StatusBadRequest, "state not found", err))
|
||||
return
|
||||
}
|
||||
|
||||
if state != cookieState.Value {
|
||||
httpUserError(writer, NewHTTPError(http.StatusForbidden, "state did not match", nil))
|
||||
httpError(writer, NewHTTPError(http.StatusForbidden, "state did not match", nil))
|
||||
return
|
||||
}
|
||||
|
||||
oauth2Token, err := a.getOauth2Token(req.Context(), code, state)
|
||||
if err != nil {
|
||||
httpUserError(writer, err)
|
||||
httpError(writer, err)
|
||||
return
|
||||
}
|
||||
|
||||
idToken, err := a.extractIDToken(req.Context(), oauth2Token)
|
||||
if err != nil {
|
||||
httpUserError(writer, err)
|
||||
httpError(writer, err)
|
||||
return
|
||||
}
|
||||
|
||||
if idToken.Nonce == "" {
|
||||
httpUserError(writer, NewHTTPError(http.StatusBadRequest, "nonce not found in IDToken", err))
|
||||
httpError(writer, NewHTTPError(http.StatusBadRequest, "nonce not found in IDToken", err))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -260,12 +260,12 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
|
||||
|
||||
nonce, err := req.Cookie(nonceCookieName)
|
||||
if err != nil {
|
||||
httpUserError(writer, NewHTTPError(http.StatusBadRequest, "nonce not found", err))
|
||||
httpError(writer, NewHTTPError(http.StatusBadRequest, "nonce not found", err))
|
||||
return
|
||||
}
|
||||
|
||||
if idToken.Nonce != nonce.Value {
|
||||
httpUserError(writer, NewHTTPError(http.StatusForbidden, "nonce did not match", nil))
|
||||
httpError(writer, NewHTTPError(http.StatusForbidden, "nonce did not match", nil))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -273,7 +273,7 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
|
||||
|
||||
var claims types.OIDCClaims
|
||||
if err := idToken.Claims(&claims); err != nil { //nolint:noinlineerr
|
||||
httpUserError(writer, fmt.Errorf("decoding ID token claims: %w", err))
|
||||
httpError(writer, fmt.Errorf("decoding ID token claims: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -310,17 +310,26 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
|
||||
// against allowed emails, email domains, and groups.
|
||||
err = doOIDCAuthorization(a.cfg, &claims)
|
||||
if err != nil {
|
||||
httpUserError(writer, err)
|
||||
httpError(writer, err)
|
||||
return
|
||||
}
|
||||
|
||||
user, _, err := a.createOrUpdateUserFromClaim(&claims)
|
||||
if err != nil {
|
||||
httpUserError(writer, NewHTTPError(
|
||||
http.StatusInternalServerError,
|
||||
"could not create or update user",
|
||||
err,
|
||||
))
|
||||
log.Error().
|
||||
Err(err).
|
||||
Caller().
|
||||
Msgf("could not create or update user")
|
||||
writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
writer.WriteHeader(http.StatusInternalServerError)
|
||||
|
||||
_, werr := writer.Write([]byte("Could not create or update user"))
|
||||
if werr != nil {
|
||||
log.Error().
|
||||
Caller().
|
||||
Err(werr).
|
||||
Msg("Failed to write HTTP response")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
@@ -332,7 +341,7 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
|
||||
authInfo := a.getAuthInfoFromState(state)
|
||||
if authInfo == nil {
|
||||
log.Debug().Caller().Str("state", state).Msg("state not found in cache, login session may have expired")
|
||||
httpUserError(writer, NewHTTPError(http.StatusGone, "login session expired, try again", nil))
|
||||
httpError(writer, NewHTTPError(http.StatusGone, "login session expired, try again", nil))
|
||||
|
||||
return
|
||||
}
|
||||
@@ -358,7 +367,7 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
|
||||
authReq, ok := a.h.state.GetAuthCacheEntry(authInfo.AuthID)
|
||||
if !ok {
|
||||
log.Debug().Caller().Str("auth_id", authInfo.AuthID.String()).Msg("auth session expired before authorization completed")
|
||||
httpUserError(writer, NewHTTPError(http.StatusGone, "login session expired, try again", nil))
|
||||
httpError(writer, NewHTTPError(http.StatusGone, "login session expired, try again", nil))
|
||||
|
||||
return
|
||||
}
|
||||
@@ -367,7 +376,7 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
|
||||
log.Warn().Caller().
|
||||
Str("auth_id", authInfo.AuthID.String()).
|
||||
Msg("OIDC callback hit non-registration path with auth request that is not an SSH check binding")
|
||||
httpUserError(writer, NewHTTPError(http.StatusBadRequest, "auth session is not for SSH check", nil))
|
||||
httpError(writer, NewHTTPError(http.StatusBadRequest, "auth session is not for SSH check", nil))
|
||||
|
||||
return
|
||||
}
|
||||
@@ -380,7 +389,7 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
|
||||
Str("auth_id", authInfo.AuthID.String()).
|
||||
Uint64("src_node_id", binding.SrcNodeID.Uint64()).
|
||||
Msg("SSH check src node no longer exists")
|
||||
httpUserError(writer, NewHTTPError(http.StatusGone, "src node no longer exists", nil))
|
||||
httpError(writer, NewHTTPError(http.StatusGone, "src node no longer exists", nil))
|
||||
|
||||
return
|
||||
}
|
||||
@@ -395,7 +404,7 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
|
||||
Bool("src_is_tagged", srcNode.IsTagged()).
|
||||
Str("oidc_user", user.Username()).
|
||||
Msg("SSH check rejected: src node has no user owner")
|
||||
httpUserError(writer, NewHTTPError(http.StatusForbidden, "src node has no user owner", nil))
|
||||
httpError(writer, NewHTTPError(http.StatusForbidden, "src node has no user owner", nil))
|
||||
|
||||
return
|
||||
}
|
||||
@@ -408,7 +417,7 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
|
||||
Uint("oidc_user_id", user.ID).
|
||||
Str("oidc_user", user.Username()).
|
||||
Msg("SSH check rejected: OIDC user is not the owner of src node")
|
||||
httpUserError(writer, NewHTTPError(http.StatusForbidden, "OIDC user is not the owner of the SSH source node", nil))
|
||||
httpError(writer, NewHTTPError(http.StatusForbidden, "OIDC user is not the owner of the SSH source node", nil))
|
||||
|
||||
return
|
||||
}
|
||||
@@ -671,7 +680,7 @@ func (a *AuthProviderOIDC) renderRegistrationConfirmInterstitial(
|
||||
authReq, ok := a.h.state.GetAuthCacheEntry(authID)
|
||||
if !ok {
|
||||
log.Debug().Caller().Str("auth_id", authID.String()).Msg("registration session expired before authorization completed")
|
||||
httpUserError(writer, NewHTTPError(http.StatusGone, "login session expired, try again", nil))
|
||||
httpError(writer, NewHTTPError(http.StatusGone, "login session expired, try again", nil))
|
||||
|
||||
return
|
||||
}
|
||||
@@ -680,14 +689,14 @@ func (a *AuthProviderOIDC) renderRegistrationConfirmInterstitial(
|
||||
log.Warn().Caller().
|
||||
Str("auth_id", authID.String()).
|
||||
Msg("OIDC callback hit registration path with auth request that is not a node registration")
|
||||
httpUserError(writer, NewHTTPError(http.StatusBadRequest, "auth session is not for node registration", nil))
|
||||
httpError(writer, NewHTTPError(http.StatusBadRequest, "auth session is not for node registration", nil))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
csrf, err := util.GenerateRandomStringURLSafe(32)
|
||||
if err != nil {
|
||||
httpUserError(writer, fmt.Errorf("generating csrf token: %w", err))
|
||||
httpError(writer, fmt.Errorf("generating csrf token: %w", err))
|
||||
|
||||
return
|
||||
}
|
||||
@@ -739,14 +748,14 @@ func (a *AuthProviderOIDC) RegisterConfirmHandler(
|
||||
req *http.Request,
|
||||
) {
|
||||
if req.Method != http.MethodPost {
|
||||
httpUserError(writer, errMethodNotAllowed)
|
||||
httpError(writer, errMethodNotAllowed)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
authID, err := authIDFromRequest(req)
|
||||
if err != nil {
|
||||
httpUserError(writer, err)
|
||||
httpError(writer, err)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -757,54 +766,54 @@ func (a *AuthProviderOIDC) RegisterConfirmHandler(
|
||||
req.Body = http.MaxBytesReader(writer, req.Body, 4*1024)
|
||||
|
||||
if err := req.ParseForm(); err != nil { //nolint:noinlineerr,gosec // body is bounded above
|
||||
httpUserError(writer, NewHTTPError(http.StatusBadRequest, "invalid form", err))
|
||||
httpError(writer, NewHTTPError(http.StatusBadRequest, "invalid form", err))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
formCSRF := req.PostFormValue(registerConfirmCSRFCookie) //nolint:gosec // body is bounded above
|
||||
if formCSRF == "" {
|
||||
httpUserError(writer, NewHTTPError(http.StatusBadRequest, "missing csrf token", nil))
|
||||
httpError(writer, NewHTTPError(http.StatusBadRequest, "missing csrf token", nil))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
cookie, err := req.Cookie(registerConfirmCSRFCookie)
|
||||
if err != nil {
|
||||
httpUserError(writer, NewHTTPError(http.StatusForbidden, "missing csrf cookie", err))
|
||||
httpError(writer, NewHTTPError(http.StatusForbidden, "missing csrf cookie", err))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if cookie.Value != formCSRF {
|
||||
httpUserError(writer, NewHTTPError(http.StatusForbidden, "csrf token mismatch", nil))
|
||||
httpError(writer, NewHTTPError(http.StatusForbidden, "csrf token mismatch", nil))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
authReq, ok := a.h.state.GetAuthCacheEntry(authID)
|
||||
if !ok {
|
||||
httpUserError(writer, NewHTTPError(http.StatusGone, "registration session expired", nil))
|
||||
httpError(writer, NewHTTPError(http.StatusGone, "registration session expired", nil))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
pending := authReq.PendingConfirmation()
|
||||
if pending == nil {
|
||||
httpUserError(writer, NewHTTPError(http.StatusForbidden, "registration not OIDC-authorized", nil))
|
||||
httpError(writer, NewHTTPError(http.StatusForbidden, "registration not OIDC-authorized", nil))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if pending.CSRF != cookie.Value {
|
||||
httpUserError(writer, NewHTTPError(http.StatusForbidden, "csrf token does not match cached registration", nil))
|
||||
httpError(writer, NewHTTPError(http.StatusForbidden, "csrf token does not match cached registration", nil))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
user, err := a.h.state.GetUserByID(types.UserID(pending.UserID))
|
||||
if err != nil {
|
||||
httpUserError(writer, fmt.Errorf("looking up user: %w", err))
|
||||
httpError(writer, fmt.Errorf("looking up user: %w", err))
|
||||
|
||||
return
|
||||
}
|
||||
@@ -812,12 +821,12 @@ func (a *AuthProviderOIDC) RegisterConfirmHandler(
|
||||
newNode, err := a.handleRegistration(user, authID, pending.NodeExpiry)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNodeNotFoundRegistrationCache) {
|
||||
httpUserError(writer, NewHTTPError(http.StatusGone, "registration session expired", err))
|
||||
httpError(writer, NewHTTPError(http.StatusGone, "registration session expired", err))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
httpUserError(writer, err)
|
||||
httpError(writer, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -7,71 +7,6 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAuthErrorTemplate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
result templates.AuthErrorResult
|
||||
}{
|
||||
{
|
||||
name: "bad_request",
|
||||
result: templates.AuthErrorResult{
|
||||
Title: "Headscale - Error",
|
||||
Heading: "Bad Request",
|
||||
Message: "The request could not be processed. Please try again.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "forbidden",
|
||||
result: templates.AuthErrorResult{
|
||||
Title: "Headscale - Error",
|
||||
Heading: "Forbidden",
|
||||
Message: "You are not authorized. Please contact your administrator.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gone_expired",
|
||||
result: templates.AuthErrorResult{
|
||||
Title: "Headscale - Error",
|
||||
Heading: "Gone",
|
||||
Message: "Your session has expired. Please try again.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "internal_server_error",
|
||||
result: templates.AuthErrorResult{
|
||||
Title: "Headscale - Error",
|
||||
Heading: "Internal Server Error",
|
||||
Message: "Something went wrong. Please try again later.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
html := templates.AuthError(tt.result).Render()
|
||||
|
||||
// Verify the HTML contains expected structural elements
|
||||
assert.Contains(t, html, "<!DOCTYPE html>")
|
||||
assert.Contains(t, html, "<title>"+tt.result.Title+"</title>")
|
||||
assert.Contains(t, html, tt.result.Heading)
|
||||
assert.Contains(t, html, tt.result.Message)
|
||||
|
||||
// Verify Material for MkDocs design system CSS is present
|
||||
assert.Contains(t, html, "Material for MkDocs")
|
||||
assert.Contains(t, html, "Roboto")
|
||||
assert.Contains(t, html, ".md-typeset")
|
||||
|
||||
// Verify SVG elements are present
|
||||
assert.Contains(t, html, "<svg")
|
||||
assert.Contains(t, html, "class=\"headscale-logo\"")
|
||||
assert.Contains(t, html, "id=\"error-icon\"")
|
||||
|
||||
// Verify no success checkbox icon
|
||||
assert.NotContains(t, html, "id=\"checkbox\"")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthSuccessTemplate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -44,47 +44,15 @@ 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
|
||||
// strings. Unparseable entries are silently dropped (fail-open): the
|
||||
// resulting Match is narrower than the input described, but never
|
||||
// wider. Callers that need strict validation should pre-validate
|
||||
// their inputs via util.ParseIPSet.
|
||||
func MatchFromStrings(sources, destinations []string) Match {
|
||||
srcs := new(netipx.IPSetBuilder)
|
||||
dests := new(netipx.IPSetBuilder)
|
||||
@@ -128,20 +96,18 @@ func (m *Match) DestsOverlapsPrefixes(prefixes ...netip.Prefix) bool {
|
||||
return slices.ContainsFunc(prefixes, m.dests.OverlapsPrefix)
|
||||
}
|
||||
|
||||
// DestsIsTheInternet reports whether the destination covers "the
|
||||
// internet" — the set represented by autogroup:internet, special-cased
|
||||
// for exit nodes. Returns true if either family's /0 is contained
|
||||
// (0.0.0.0/0 or ::/0), or if dests is a superset of TheInternet(). A
|
||||
// single-family /0 counts because operators may write it directly and
|
||||
// it still denotes the whole internet for that family.
|
||||
// DestsIsTheInternet reports if the destination contains "the internet"
|
||||
// which is a IPSet that represents "autogroup:internet" and is special
|
||||
// cased for exit nodes.
|
||||
// This checks if dests is a superset of TheInternet(), which handles
|
||||
// merged filter rules where TheInternet is combined with other destinations.
|
||||
func (m *Match) DestsIsTheInternet() bool {
|
||||
if m.dests.ContainsPrefix(tsaddr.AllIPv4()) ||
|
||||
m.dests.ContainsPrefix(tsaddr.AllIPv6()) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Superset-of-TheInternet check handles merged filter rules
|
||||
// where the internet prefixes are combined with other dests.
|
||||
// Check if dests contains all prefixes of TheInternet (superset check)
|
||||
theInternet := util.TheInternet()
|
||||
for _, prefix := range theInternet.Prefixes() {
|
||||
if !m.dests.ContainsPrefix(prefix) {
|
||||
|
||||
@@ -2,7 +2,6 @@ package matcher
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -180,78 +179,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 {
|
||||
@@ -501,44 +428,4 @@ func TestDebugString(t *testing.T) {
|
||||
assert.Contains(t, s, "Destinations:")
|
||||
assert.Contains(t, s, "10.0.0.0/8")
|
||||
assert.Contains(t, s, "192.168.1.0/24")
|
||||
|
||||
// Sources appear before Destinations in the output.
|
||||
assert.Less(
|
||||
t,
|
||||
strings.Index(s, "Sources:"),
|
||||
strings.Index(s, "Destinations:"),
|
||||
"Sources section must precede Destinations",
|
||||
)
|
||||
}
|
||||
|
||||
func TestDebugString_Empty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m := MatchFromStrings(nil, nil)
|
||||
|
||||
s := m.DebugString()
|
||||
assert.Contains(t, s, "Match:")
|
||||
assert.Contains(t, s, "Sources:")
|
||||
assert.Contains(t, s, "Destinations:")
|
||||
assert.NotContains(t, s, "/")
|
||||
}
|
||||
|
||||
// TestMatchFromStrings_MalformedFailsOpen asserts that unparseable
|
||||
// entries are silently dropped and do not crash or widen the Match.
|
||||
func TestMatchFromStrings_MalformedFailsOpen(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m := MatchFromStrings(
|
||||
[]string{"not-a-cidr", "10.0.0.0/8"},
|
||||
[]string{"also-bogus", "192.168.1.0/24"},
|
||||
)
|
||||
|
||||
assert.True(t, m.SrcsContainsIPs(netip.MustParseAddr("10.1.2.3")),
|
||||
"valid src entry must still match")
|
||||
assert.False(t, m.SrcsContainsIPs(netip.MustParseAddr("1.1.1.1")),
|
||||
"malformed src entry must not widen the set")
|
||||
assert.True(t, m.DestsContainsIP(netip.MustParseAddr("192.168.1.10")),
|
||||
"valid dst entry must still match")
|
||||
assert.False(t, m.DestsContainsIP(netip.MustParseAddr("8.8.8.8")),
|
||||
"malformed dst entry must not widen the set")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/juanfont/headscale/hscontrol/policy/matcher"
|
||||
@@ -951,13 +952,11 @@ func TestReduceNodesFromPolicy(t *testing.T) {
|
||||
]
|
||||
}`,
|
||||
node: n(1, "100.64.0.1", "mobile", "mobile"),
|
||||
// autogroup:internet emits no client packet filter, but it
|
||||
// must still produce a matcher: Node.CanAccess uses
|
||||
// matcher.DestsIsTheInternet() + IsExitNode() to surface
|
||||
// exit-node peers (juanfont/headscale#3212).
|
||||
// autogroup:internet does not generate packet filters - it's handled
|
||||
// by exit node routing via AllowedIPs, not by packet filtering.
|
||||
// Only server is visible through the mobile -> server:80 rule.
|
||||
want: types.Nodes{
|
||||
n(2, "100.64.0.2", "server", "server"),
|
||||
n(3, "100.64.0.3", "exit", "server", "0.0.0.0/0", "::/0"),
|
||||
},
|
||||
wantMatchers: 1,
|
||||
},
|
||||
@@ -1265,11 +1264,11 @@ func TestSSHPolicyRules(t *testing.T) {
|
||||
},
|
||||
Action: &tailcfg.SSHAction{
|
||||
Accept: false,
|
||||
SessionDuration: 0,
|
||||
HoldAndDelegate: "unused-url/machine/ssh/action/$SRC_NODE_ID/to/$DST_NODE_ID?local_user=$LOCAL_USER",
|
||||
AllowAgentForwarding: false,
|
||||
AllowLocalPortForwarding: false,
|
||||
AllowRemotePortForwarding: false,
|
||||
SessionDuration: 24 * time.Hour,
|
||||
HoldAndDelegate: "unused-url/machine/ssh/action/from/$SRC_NODE_ID/to/$DST_NODE_ID?ssh_user=$SSH_USER&local_user=$LOCAL_USER",
|
||||
AllowAgentForwarding: true,
|
||||
AllowLocalPortForwarding: true,
|
||||
AllowRemotePortForwarding: true,
|
||||
},
|
||||
},
|
||||
}},
|
||||
@@ -1317,7 +1316,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 +1340,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 +1362,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,9 +0,0 @@
|
||||
// Package policyutil contains pure functions that transform compiled
|
||||
// policy rules for a specific node. The headline function is
|
||||
// ReduceFilterRules, which filters global rules down to those relevant
|
||||
// to one node.
|
||||
//
|
||||
// A node's SubnetRoutes (approved, non-exit) participate in rule
|
||||
// matching so subnet routers receive filter rules for destinations
|
||||
// their subnets cover — the fix for issue #3169.
|
||||
package policyutil
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/util"
|
||||
"go4.org/netipx"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
@@ -18,8 +17,6 @@ import (
|
||||
// to this function. Use PolicyManager.FilterForNode() instead, which handles both cases.
|
||||
func ReduceFilterRules(node types.NodeView, rules []tailcfg.FilterRule) []tailcfg.FilterRule {
|
||||
ret := []tailcfg.FilterRule{}
|
||||
subnetRoutes := node.SubnetRoutes()
|
||||
hasExitRoutes := node.IsExitNode()
|
||||
|
||||
for _, rule := range rules {
|
||||
// Handle CapGrant rules separately — they use CapGrant[].Dsts
|
||||
@@ -36,16 +33,18 @@ func ReduceFilterRules(node types.NodeView, rules []tailcfg.FilterRule) []tailcf
|
||||
// record if the rule is actually relevant for the given node.
|
||||
var dests []tailcfg.NetPortRange
|
||||
|
||||
DEST_LOOP:
|
||||
for _, dest := range rule.DstPorts {
|
||||
expanded, err := util.ParseIPSet(dest.IP, nil)
|
||||
// Fail closed: unparseable dests are dropped.
|
||||
// Fail closed, if we can't parse it, then we should not allow
|
||||
// access.
|
||||
if err != nil {
|
||||
continue
|
||||
continue DEST_LOOP
|
||||
}
|
||||
|
||||
if node.InIPSet(expanded) {
|
||||
dests = append(dests, dest)
|
||||
continue
|
||||
continue DEST_LOOP
|
||||
}
|
||||
|
||||
// If the node has approved subnet routes, preserve
|
||||
@@ -56,45 +55,26 @@ func ReduceFilterRules(node types.NodeView, rules []tailcfg.FilterRule) []tailcf
|
||||
// Exit routes (0.0.0.0/0, ::/0) are excluded by
|
||||
// SubnetRoutes() and handled separately via
|
||||
// AllowedIPs/routing.
|
||||
if slices.ContainsFunc(subnetRoutes, expanded.OverlapsPrefix) {
|
||||
dests = append(dests, dest)
|
||||
continue
|
||||
}
|
||||
|
||||
// Exit-route advertisers need rules targeting the
|
||||
// public internet so the kernel filter accepts
|
||||
// traffic forwarded by autogroup:internet sources.
|
||||
if hasExitRoutes && ipSetSubsetOf(expanded, util.TheInternet()) {
|
||||
dests = append(dests, dest)
|
||||
for _, subnetRoute := range node.SubnetRoutes() {
|
||||
if expanded.OverlapsPrefix(subnetRoute) {
|
||||
dests = append(dests, dest)
|
||||
continue DEST_LOOP
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(dests) > 0 {
|
||||
// Struct-copy preserves any unknown future FilterRule
|
||||
// fields.
|
||||
out := rule
|
||||
out.DstPorts = dests
|
||||
ret = append(ret, out)
|
||||
ret = append(ret, tailcfg.FilterRule{
|
||||
SrcIPs: rule.SrcIPs,
|
||||
DstPorts: dests,
|
||||
IPProto: rule.IPProto,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
func ipSetSubsetOf(candidate, container *netipx.IPSet) bool {
|
||||
if candidate == nil || container == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, pref := range candidate.Prefixes() {
|
||||
if !container.ContainsPrefix(pref) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// reduceCapGrantRule filters a CapGrant rule to only include CapGrant
|
||||
// entries whose Dsts match the given node's IPs. When a broad prefix
|
||||
// (e.g. 100.64.0.0/10 from dst:*) contains a node's IP, it is
|
||||
@@ -107,7 +87,6 @@ func reduceCapGrantRule(
|
||||
var capGrants []tailcfg.CapGrant
|
||||
|
||||
nodeIPs := node.IPs()
|
||||
subnetRoutes := node.SubnetRoutes()
|
||||
|
||||
for _, cg := range rule.CapGrant {
|
||||
// Collect the node's IPs that fall within any of this
|
||||
@@ -132,15 +111,13 @@ func reduceCapGrantRule(
|
||||
}
|
||||
}
|
||||
|
||||
// Asymmetric on purpose: the IP-match loop above narrows broad
|
||||
// prefixes to node-specific /32 or /128 so peers receive only
|
||||
// the minimum routing surface. The route-match loop below
|
||||
// preserves the original prefix so the subnet-serving node
|
||||
// receives the full CapGrant scope. SubnetRoutes() excludes
|
||||
// both unapproved and exit routes, matching Tailscale SaaS
|
||||
// behavior.
|
||||
// Also check approved subnet routes — nodes serving
|
||||
// approved routes should receive CapGrant rules for
|
||||
// destinations that overlap those routes. SubnetRoutes()
|
||||
// excludes both unapproved and exit routes, matching
|
||||
// Tailscale SaaS behavior.
|
||||
for _, dst := range cg.Dsts {
|
||||
for _, subnetRoute := range subnetRoutes {
|
||||
for _, subnetRoute := range node.SubnetRoutes() {
|
||||
if dst.Overlaps(subnetRoute) {
|
||||
// For route overlaps, keep the original prefix.
|
||||
matchingDsts = append(matchingDsts, dst)
|
||||
@@ -149,13 +126,6 @@ func reduceCapGrantRule(
|
||||
}
|
||||
|
||||
if len(matchingDsts) > 0 {
|
||||
// A Dst can be appended twice when a broad prefix both
|
||||
// contains a node IP and overlaps one of its approved
|
||||
// subnet routes. Sort + Compact dedups; netip.Prefix is
|
||||
// comparable so Compact works with ==.
|
||||
slices.SortFunc(matchingDsts, netip.Prefix.Compare)
|
||||
matchingDsts = slices.Compact(matchingDsts)
|
||||
|
||||
capGrants = append(capGrants, tailcfg.CapGrant{
|
||||
Dsts: matchingDsts,
|
||||
CapMap: cg.CapMap,
|
||||
|
||||
@@ -1203,55 +1203,6 @@ func TestReduceFilterRulesCapGrant(t *testing.T) {
|
||||
},
|
||||
want: []tailcfg.FilterRule{},
|
||||
},
|
||||
{
|
||||
// A broad prefix that both contains the node IP (/32 narrow)
|
||||
// and overlaps one of its approved subnet routes (route
|
||||
// preserved) would otherwise be emitted twice.
|
||||
name: "capgrant-ip-and-route-overlap-dedup",
|
||||
node: &types.Node{
|
||||
IPv4: ap("100.64.0.1"),
|
||||
IPv6: ap("fd7a:115c:a1e0::1"),
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
RoutableIPs: []netip.Prefix{
|
||||
netip.MustParsePrefix("100.64.0.0/24"),
|
||||
},
|
||||
},
|
||||
ApprovedRoutes: []netip.Prefix{
|
||||
netip.MustParsePrefix("100.64.0.0/24"),
|
||||
},
|
||||
},
|
||||
rules: []tailcfg.FilterRule{
|
||||
{
|
||||
SrcIPs: []string{"*"},
|
||||
CapGrant: []tailcfg.CapGrant{
|
||||
{
|
||||
Dsts: []netip.Prefix{
|
||||
netip.MustParsePrefix("100.64.0.0/24"),
|
||||
},
|
||||
CapMap: tailcfg.PeerCapMap{
|
||||
"tailscale.com/cap/relay-target": nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: []tailcfg.FilterRule{
|
||||
{
|
||||
SrcIPs: []string{"*"},
|
||||
CapGrant: []tailcfg.CapGrant{
|
||||
{
|
||||
Dsts: []netip.Prefix{
|
||||
netip.MustParsePrefix("100.64.0.1/32"),
|
||||
netip.MustParsePrefix("100.64.0.0/24"),
|
||||
},
|
||||
CapMap: tailcfg.PeerCapMap{
|
||||
"tailscale.com/cap/relay-target": nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
+13
-115
@@ -1,12 +1,10 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"slices"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/util"
|
||||
"github.com/rs/zerolog/log"
|
||||
"go4.org/netipx"
|
||||
"tailscale.com/tailcfg"
|
||||
@@ -74,10 +72,9 @@ type viaGrantData struct {
|
||||
srcIPStrings []string
|
||||
}
|
||||
|
||||
// userNodeIndex maps user IDs to their untagged nodes. Built once per
|
||||
// policy or node-set change and read from many goroutines under
|
||||
// PolicyManager.mu; readers must hold the lock (or the snapshot
|
||||
// returned to them).
|
||||
// userNodeIndex maps user IDs to their untagged nodes. Precomputed
|
||||
// once per node-set change, used by compileAutogroupSelf to avoid
|
||||
// O(N) scans per self-grant per node.
|
||||
type userNodeIndex map[uint][]types.NodeView
|
||||
|
||||
func buildUserNodeIndex(
|
||||
@@ -95,92 +92,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
|
||||
@@ -189,7 +100,7 @@ func (pol *Policy) compileGrants(
|
||||
users types.Users,
|
||||
nodes views.Slice[types.NodeView],
|
||||
) []compiledGrant {
|
||||
if pol == nil || (pol.ACLs == nil && pol.Grants == nil) {
|
||||
if pol == nil || (pol.ACLs == nil && len(pol.Grants) == 0) {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -358,9 +269,7 @@ func (pol *Policy) compileOneViaGrant(
|
||||
|
||||
// resolveSources resolves grant sources per-alias, returning the
|
||||
// resolved addresses and a separate slice of non-wildcard sources.
|
||||
// This is the canonical source-resolution path. Its output lands in
|
||||
// compiledGrant.srcIPStrings (among other places) and callers on the
|
||||
// hot path should prefer reading that over calling Resolve again.
|
||||
// This is the shared source resolution used by all grant categories.
|
||||
func resolveSources(
|
||||
pol *Policy,
|
||||
sources Aliases,
|
||||
@@ -548,9 +457,8 @@ func hasPerNodeGrants(grants []compiledGrant) bool {
|
||||
}
|
||||
|
||||
// globalFilterRules extracts global filter rules from compiled
|
||||
// grants. Via grants produce no global rules (they are per-node
|
||||
// only); regular grants contribute their full pre-compiled ruleset;
|
||||
// self grants contribute their non-self portion.
|
||||
// grants. Only includes pre-compiled rules from non-via grants.
|
||||
// Via grants produce no global rules (they are per-node only).
|
||||
func globalFilterRules(grants []compiledGrant) []tailcfg.FilterRule {
|
||||
var rules []tailcfg.FilterRule
|
||||
|
||||
@@ -759,10 +667,11 @@ func compileViaForNode(
|
||||
return nil
|
||||
}
|
||||
|
||||
// Find matching destination prefixes. SubnetRoutes() excludes exit
|
||||
// routes, so the *Prefix check below sees only subnet advertisements;
|
||||
// the *AutoGroup AutoGroupInternet branch checks IsExitNode() instead.
|
||||
// Find matching destination prefixes.
|
||||
nodeSubnetRoutes := node.SubnetRoutes()
|
||||
if len(nodeSubnetRoutes) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var viaDstPrefixes []netip.Prefix
|
||||
|
||||
@@ -776,19 +685,8 @@ func compileViaForNode(
|
||||
)
|
||||
}
|
||||
case *AutoGroup:
|
||||
// autogroup:internet on a via-tagged exit advertiser
|
||||
// becomes a rule whose DstPorts enumerate
|
||||
// util.TheInternet(). The matchers derived from this
|
||||
// rule let Node.CanAccess surface the exit node to the
|
||||
// grant source via DestsIsTheInternet. ReduceFilterRules
|
||||
// strips the rule from the wire format on non-exit
|
||||
// advertisers, preserving SaaS PacketFilter encoding.
|
||||
if d.Is(AutoGroupInternet) && node.IsExitNode() {
|
||||
viaDstPrefixes = append(
|
||||
viaDstPrefixes,
|
||||
util.TheInternet().Prefixes()...,
|
||||
)
|
||||
}
|
||||
// autogroup:internet via grants do not produce
|
||||
// PacketFilter rules on exit nodes.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
@@ -63,7 +62,7 @@ func companionCapGrantRules(
|
||||
}
|
||||
|
||||
slices.SortFunc(pairs, func(a, b pair) int {
|
||||
return cmp.Compare(a.original, b.original)
|
||||
return strings.Compare(string(a.original), string(b.original))
|
||||
})
|
||||
|
||||
companions := make([]tailcfg.FilterRule, 0, len(pairs))
|
||||
@@ -138,7 +137,7 @@ func (pol *Policy) compileFilterRules(
|
||||
users types.Users,
|
||||
nodes views.Slice[types.NodeView],
|
||||
) ([]tailcfg.FilterRule, error) {
|
||||
if pol == nil || (pol.ACLs == nil && pol.Grants == nil) {
|
||||
if pol == nil || (pol.ACLs == nil && len(pol.Grants) == 0) {
|
||||
return tailcfg.FilterAllowAll, nil
|
||||
}
|
||||
|
||||
@@ -166,6 +165,12 @@ func (pol *Policy) destinationsToNetPortRange(
|
||||
continue
|
||||
}
|
||||
|
||||
// autogroup:internet does not generate packet filters - it's handled
|
||||
// by exit node routing via AllowedIPs, not by packet filtering.
|
||||
if ag, isAutoGroup := dest.(*AutoGroup); isAutoGroup && ag.Is(AutoGroupInternet) {
|
||||
continue
|
||||
}
|
||||
|
||||
ips, err := dest.Resolve(pol, users, nodes)
|
||||
if err != nil {
|
||||
log.Trace().Caller().Err(err).Msgf("resolving destination ips")
|
||||
@@ -224,8 +229,6 @@ var sshAccept = tailcfg.SSHAction{
|
||||
// checkPeriodFromRule extracts the check period duration from an SSH rule.
|
||||
// Returns SSHCheckPeriodDefault if no checkPeriod is configured,
|
||||
// 0 if checkPeriod is "always", or the configured duration otherwise.
|
||||
// This is used server-side by SSHCheckParams to resolve the real period
|
||||
// when the client calls back; the wire format always sends 0.
|
||||
func checkPeriodFromRule(rule SSH) time.Duration {
|
||||
switch {
|
||||
case rule.CheckPeriod == nil:
|
||||
@@ -237,13 +240,13 @@ func checkPeriodFromRule(rule SSH) time.Duration {
|
||||
}
|
||||
}
|
||||
|
||||
func sshCheck(baseURL string, _ time.Duration) tailcfg.SSHAction {
|
||||
holdURL := baseURL + "/machine/ssh/action/$SRC_NODE_ID/to/$DST_NODE_ID?local_user=$LOCAL_USER"
|
||||
func sshCheck(baseURL string, duration time.Duration) tailcfg.SSHAction {
|
||||
holdURL := baseURL + "/machine/ssh/action/from/$SRC_NODE_ID/to/$DST_NODE_ID?ssh_user=$SSH_USER&local_user=$LOCAL_USER"
|
||||
|
||||
return tailcfg.SSHAction{
|
||||
Reject: false,
|
||||
Accept: false,
|
||||
SessionDuration: 0,
|
||||
SessionDuration: duration,
|
||||
// Replaced in the client:
|
||||
// * $SRC_NODE_IP (URL escaped)
|
||||
// * $SRC_NODE_ID (Node.ID as int64 string)
|
||||
@@ -252,9 +255,9 @@ func sshCheck(baseURL string, _ time.Duration) tailcfg.SSHAction {
|
||||
// * $SSH_USER (URL escaped, ssh user requested)
|
||||
// * $LOCAL_USER (URL escaped, local user mapped)
|
||||
HoldAndDelegate: holdURL,
|
||||
AllowAgentForwarding: false,
|
||||
AllowLocalPortForwarding: false,
|
||||
AllowRemotePortForwarding: false,
|
||||
AllowAgentForwarding: true,
|
||||
AllowLocalPortForwarding: true,
|
||||
AllowRemotePortForwarding: true,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1071,7 +1071,7 @@ func TestCompileSSHPolicy_CheckAction(t *testing.T) {
|
||||
assert.False(t, rule.Action.Reject)
|
||||
assert.NotEmpty(t, rule.Action.HoldAndDelegate)
|
||||
assert.Contains(t, rule.Action.HoldAndDelegate, "/machine/ssh/action/")
|
||||
assert.Equal(t, time.Duration(0), rule.Action.SessionDuration)
|
||||
assert.Equal(t, 24*time.Hour, rule.Action.SessionDuration)
|
||||
|
||||
// Verify check params are NOT encoded in the URL (looked up server-side).
|
||||
assert.NotContains(t, rule.Action.HoldAndDelegate, "check_explicit")
|
||||
@@ -2632,24 +2632,25 @@ func TestCompileSSHPolicy_CheckPeriodVariants(t *testing.T) {
|
||||
|
||||
nodes := types.Nodes{&node}
|
||||
|
||||
// SaaS always sends SessionDuration=0 in the wire format
|
||||
// regardless of checkPeriod. The check period is resolved
|
||||
// server-side, not embedded in the SSHAction.
|
||||
tests := []struct {
|
||||
name string
|
||||
checkPeriod *SSHCheckPeriod
|
||||
name string
|
||||
checkPeriod *SSHCheckPeriod
|
||||
wantDuration time.Duration
|
||||
}{
|
||||
{
|
||||
name: "nil period",
|
||||
checkPeriod: nil,
|
||||
name: "nil period defaults to 12h",
|
||||
checkPeriod: nil,
|
||||
wantDuration: SSHCheckPeriodDefault,
|
||||
},
|
||||
{
|
||||
name: "always period",
|
||||
checkPeriod: &SSHCheckPeriod{Always: true},
|
||||
name: "always period uses 0",
|
||||
checkPeriod: &SSHCheckPeriod{Always: true},
|
||||
wantDuration: 0,
|
||||
},
|
||||
{
|
||||
name: "explicit 2h",
|
||||
checkPeriod: &SSHCheckPeriod{Duration: 2 * time.Hour},
|
||||
name: "explicit 2h",
|
||||
checkPeriod: &SSHCheckPeriod{Duration: 2 * time.Hour},
|
||||
wantDuration: 2 * time.Hour,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2681,7 +2682,7 @@ func TestCompileSSHPolicy_CheckPeriodVariants(t *testing.T) {
|
||||
require.Len(t, sshPolicy.Rules, 1)
|
||||
|
||||
rule := sshPolicy.Rules[0]
|
||||
assert.Equal(t, time.Duration(0), rule.Action.SessionDuration)
|
||||
assert.Equal(t, tt.wantDuration, rule.Action.SessionDuration)
|
||||
// Check params must NOT be in the URL; they are
|
||||
// resolved server-side via SSHCheckParams.
|
||||
assert.NotContains(t, rule.Action.HoldAndDelegate, "check_explicit")
|
||||
@@ -3537,26 +3538,11 @@ func TestFilterAllowAllFix(t *testing.T) {
|
||||
wantFilterAllow: true,
|
||||
},
|
||||
{
|
||||
name: "nil ACLs and empty grants denies all",
|
||||
name: "nil ACLs and empty grants returns FilterAllowAll",
|
||||
pol: &Policy{
|
||||
Grants: []Grant{},
|
||||
},
|
||||
wantFilterAllow: false,
|
||||
},
|
||||
{
|
||||
name: "empty ACLs and nil grants denies all",
|
||||
pol: &Policy{
|
||||
ACLs: []ACL{},
|
||||
},
|
||||
wantFilterAllow: false,
|
||||
},
|
||||
{
|
||||
name: "empty ACLs and empty grants denies all",
|
||||
pol: &Policy{
|
||||
ACLs: []ACL{},
|
||||
Grants: []Grant{},
|
||||
},
|
||||
wantFilterAllow: false,
|
||||
wantFilterAllow: true,
|
||||
},
|
||||
{
|
||||
name: "both ACLs and grants should not return FilterAllowAll",
|
||||
@@ -3668,27 +3654,6 @@ func TestCompileViaGrant(t *testing.T) {
|
||||
Hostinfo: &tailcfg.Hostinfo{},
|
||||
}
|
||||
|
||||
// Expected rule for autogroup:internet on a via-tagged exit
|
||||
// advertiser: SrcIPs scoped to the grant source, DstPorts
|
||||
// enumerating util.TheInternet() prefixes.
|
||||
internetDstPorts := make(
|
||||
[]tailcfg.NetPortRange, 0, len(util.TheInternet().Prefixes()),
|
||||
)
|
||||
|
||||
for _, p := range util.TheInternet().Prefixes() {
|
||||
internetDstPorts = append(internetDstPorts, tailcfg.NetPortRange{
|
||||
IP: p.String(),
|
||||
Ports: tailcfg.PortRangeAny,
|
||||
})
|
||||
}
|
||||
|
||||
internetWant := []tailcfg.FilterRule{
|
||||
{
|
||||
SrcIPs: []string{"100.64.0.10"},
|
||||
DstPorts: internetDstPorts,
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
grant Grant
|
||||
@@ -3745,12 +3710,11 @@ func TestCompileViaGrant(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
// autogroup:internet on a via-tagged exit advertiser
|
||||
// produces a rule with DstPorts enumerating
|
||||
// util.TheInternet(). The matchers derived from this
|
||||
// rule let Node.CanAccess surface the exit node to
|
||||
// grant sources via DestsIsTheInternet.
|
||||
name: "autogroup:internet with exit routes produces TheInternet rule",
|
||||
// autogroup:internet via grants do NOT produce PacketFilter rules
|
||||
// on exit nodes. Tailscale SaaS handles exit traffic forwarding
|
||||
// through the client's exit node mechanism, not PacketFilter.
|
||||
// Verified by golden captures GRANT-V14 through GRANT-V36.
|
||||
name: "autogroup:internet with exit routes produces no rules",
|
||||
grant: Grant{
|
||||
Sources: Aliases{up("testuser@")},
|
||||
Destinations: Aliases{agp(string(AutoGroupInternet))},
|
||||
@@ -3760,7 +3724,7 @@ func TestCompileViaGrant(t *testing.T) {
|
||||
node: exitNode,
|
||||
nodes: types.Nodes{exitNode, srcNode},
|
||||
pol: &Policy{},
|
||||
want: internetWant,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "autogroup:internet without exit routes returns nil",
|
||||
@@ -4123,14 +4087,6 @@ func TestDestinationsToNetPortRange_AutogroupInternet(t *testing.T) {
|
||||
pol := &Policy{}
|
||||
ports := []tailcfg.PortRange{tailcfg.PortRangeAny}
|
||||
|
||||
// autogroup:internet must surface as DstPorts (not be skipped at
|
||||
// compile time). The matcher derived from these FilterRules is
|
||||
// what makes Node.CanAccess return true for exit-node peers via
|
||||
// DestsIsTheInternet (#3212). The wire format is currently the
|
||||
// canonical CIDR breakdown of util.TheInternet(); aligning it to
|
||||
// the SaaS range form is tracked separately.
|
||||
internetPrefixCount := len(util.TheInternet().Prefixes())
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
dests Aliases
|
||||
@@ -4138,9 +4094,9 @@ func TestDestinationsToNetPortRange_AutogroupInternet(t *testing.T) {
|
||||
wantStar bool
|
||||
}{
|
||||
{
|
||||
name: "autogroup:internet produces TheInternet DstPorts",
|
||||
name: "autogroup:internet produces no DstPorts",
|
||||
dests: Aliases{agp(string(AutoGroupInternet))},
|
||||
wantLen: internetPrefixCount,
|
||||
wantLen: 0,
|
||||
},
|
||||
{
|
||||
name: "wildcard produces DstPorts with star",
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
// Tests pinned against 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
|
||||
// access to autogroup:internet:* combined with autoApprovers.exitNode
|
||||
// approving exit routes on tagged exit nodes. SaaS surfaces those exit
|
||||
// nodes as peers in the ACL source's netmap with 0.0.0.0/0 and ::/0 in
|
||||
// AllowedIPs. Headscale must do the same — that is the user-visible UX
|
||||
// driving `tailscale exit-node list`.
|
||||
//
|
||||
// Captures live under testdata/issue_3212/ rather than testdata/
|
||||
// routes_results/ so the broader TestRoutesCompat / *PeerAllowedIPs /
|
||||
// *ReduceRoutes machinery does not pull them in. Those tests assume a
|
||||
// PacketFilterRules wire format (CIDR prefix per dest entry) that
|
||||
// differs from what SaaS emits for autogroup:internet (range form per
|
||||
// IPSet range — e.g. "0.0.0.0-9.255.255.255"). Aligning that wire
|
||||
// format is tracked separately; the #3212 fix is about peer
|
||||
// visibility, not packet-filter encoding.
|
||||
|
||||
package v2
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/types/testcapture"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"tailscale.com/net/tsaddr"
|
||||
)
|
||||
|
||||
// TestIssue3212AutogroupInternetExitVisibility loads the b17/b18
|
||||
// SaaS captures and asserts headscale's BuildPeerMap surfaces every
|
||||
// exit-route advertiser to every ACL-source node — matching the peer
|
||||
// list in the captured netmap.
|
||||
//
|
||||
// The bug fixed by this PR (#3212) was that headscale skipped
|
||||
// autogroup:internet during FilterRule compilation, which silently
|
||||
// dropped the matchers that Node.CanAccess reads via DestsIsTheInternet.
|
||||
// The captures pin the SaaS-equivalent expectation as a regression
|
||||
// guard so the same skip cannot sneak back in unnoticed.
|
||||
func TestIssue3212AutogroupInternetExitVisibility(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
files := []string{
|
||||
"routes-b17-autogroup-internet-with-exit-autoapprover",
|
||||
"routes-b18-autogroup-internet-wildcard-src-with-exit-autoapprover",
|
||||
}
|
||||
|
||||
for _, testID := range files {
|
||||
path := filepath.Join(
|
||||
"testdata", "issue_3212", testID+".hujson",
|
||||
)
|
||||
tf := loadRoutesTestFile(t, path)
|
||||
|
||||
t.Run(tf.TestID, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
users, nodes := buildRoutesUsersAndNodes(t, tf.Topology)
|
||||
policyJSON := convertPolicyUserEmails(tf.Input.FullPolicy)
|
||||
|
||||
pm, err := NewPolicyManager(
|
||||
policyJSON, users, nodes.ViewSlice(),
|
||||
)
|
||||
require.NoErrorf(t, err,
|
||||
"%s: failed to create PolicyManager", tf.TestID,
|
||||
)
|
||||
|
||||
peerMap := pm.BuildPeerMap(nodes.ViewSlice())
|
||||
|
||||
expected := expectedExitPeerVisibility(t, tf, nodes)
|
||||
require.NotEmptyf(t, expected,
|
||||
"%s: capture exposes no source→exit relationships — "+
|
||||
"the test is meaningless if SaaS itself never "+
|
||||
"surfaced an exit node to a source",
|
||||
tf.TestID,
|
||||
)
|
||||
|
||||
for srcName, exitNames := range expected {
|
||||
srcNode := findNodeByGivenName(nodes, srcName)
|
||||
require.NotNilf(t, srcNode,
|
||||
"%s: src node %q missing from topology",
|
||||
tf.TestID, srcName,
|
||||
)
|
||||
|
||||
peerIDs := make(
|
||||
map[types.NodeID]struct{},
|
||||
len(peerMap[srcNode.ID]),
|
||||
)
|
||||
for _, p := range peerMap[srcNode.ID] {
|
||||
peerIDs[p.ID()] = struct{}{}
|
||||
}
|
||||
|
||||
for _, exitName := range exitNames {
|
||||
exitNode := findNodeByGivenName(nodes, exitName)
|
||||
require.NotNilf(t, exitNode,
|
||||
"%s: exit node %q missing from topology",
|
||||
tf.TestID, exitName,
|
||||
)
|
||||
|
||||
_, found := peerIDs[exitNode.ID]
|
||||
assert.Truef(t, found,
|
||||
"%s: source %q must see exit node %q "+
|
||||
"as a peer via the autogroup:internet "+
|
||||
"ACL — Tailscale SaaS does (#3212)",
|
||||
tf.TestID, srcName, exitName,
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// expectedExitPeerVisibility extracts (source-node, exit-node) pairs
|
||||
// the capture's netmaps witness. A node is treated as an exit-route
|
||||
// advertiser when its ApprovedRoutes contain 0.0.0.0/0 or ::/0;
|
||||
// a (source, exit) pair is recorded when the source's captured netmap
|
||||
// lists the advertiser as a peer with 0.0.0.0/0 or ::/0 in AllowedIPs.
|
||||
func expectedExitPeerVisibility(
|
||||
t *testing.T,
|
||||
tf *testcapture.Capture,
|
||||
nodes types.Nodes,
|
||||
) map[string][]string {
|
||||
t.Helper()
|
||||
|
||||
v4Exit := tsaddr.AllIPv4()
|
||||
v6Exit := tsaddr.AllIPv6()
|
||||
|
||||
exitAdvertisers := make(map[string]bool)
|
||||
|
||||
for _, n := range nodes {
|
||||
if slices.Contains(n.ApprovedRoutes, v4Exit) ||
|
||||
slices.Contains(n.ApprovedRoutes, v6Exit) {
|
||||
exitAdvertisers[n.GivenName] = true
|
||||
}
|
||||
}
|
||||
|
||||
expected := make(map[string][]string)
|
||||
|
||||
for srcName, capture := range tf.Captures {
|
||||
if capture.Netmap == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var seen []string
|
||||
|
||||
for _, peer := range capture.Netmap.Peers {
|
||||
peerName := strings.Split(peer.Name(), ".")[0]
|
||||
|
||||
if !exitAdvertisers[peerName] {
|
||||
continue
|
||||
}
|
||||
|
||||
peerAllowed := peer.AllowedIPs().AsSlice()
|
||||
if !slices.Contains(peerAllowed, v4Exit) &&
|
||||
!slices.Contains(peerAllowed, v6Exit) {
|
||||
continue
|
||||
}
|
||||
|
||||
seen = append(seen, peerName)
|
||||
}
|
||||
|
||||
if len(seen) > 0 {
|
||||
expected[srcName] = seen
|
||||
}
|
||||
}
|
||||
|
||||
return expected
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
// A via grant scoping autogroup:internet to a tag must surface only
|
||||
// the matching exit node to the source — not strip every exit node
|
||||
// from the source's view.
|
||||
//
|
||||
// Spec: https://tailscale.com/docs/features/access-control/grants/grants-via#route-users-through-exit-nodes-based-on-location
|
||||
package v2
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
// TestIssue3233ViaInternetExitVisibility loads a policy where alice's
|
||||
// only access to autogroup:internet is via tag:exit1. Alice sees her
|
||||
// tag:exit1 exit node as a peer with 0.0.0.0/0 + ::/0 in AllowedIPs,
|
||||
// and does not see bob's tag:exit2 exit node.
|
||||
func TestIssue3233ViaInternetExitVisibility(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
users := types.Users{
|
||||
{Model: gorm.Model{ID: 1}, Name: "alice", Email: "alice@headscale.net"},
|
||||
{Model: gorm.Model{ID: 2}, Name: "bob", Email: "bob@headscale.net"},
|
||||
}
|
||||
|
||||
exitRoutes := []netip.Prefix{tsaddr.AllIPv4(), tsaddr.AllIPv6()}
|
||||
|
||||
aliceLaptop := node("alice-laptop", "100.64.0.10", "fd7a:115c:a1e0::a", users[0])
|
||||
aliceLaptop.ID = 1
|
||||
|
||||
aliceExit := node("alice-exit", "100.64.0.11", "fd7a:115c:a1e0::b", users[0])
|
||||
aliceExit.ID = 2
|
||||
aliceExit.Tags = []string{"tag:exit1"}
|
||||
aliceExit.Hostinfo = &tailcfg.Hostinfo{RoutableIPs: exitRoutes}
|
||||
aliceExit.ApprovedRoutes = exitRoutes
|
||||
|
||||
bobExit := node("bob-exit", "100.64.0.21", "fd7a:115c:a1e0::15", users[1])
|
||||
bobExit.ID = 3
|
||||
bobExit.Tags = []string{"tag:exit2"}
|
||||
bobExit.Hostinfo = &tailcfg.Hostinfo{RoutableIPs: exitRoutes}
|
||||
bobExit.ApprovedRoutes = exitRoutes
|
||||
|
||||
nodes := types.Nodes{aliceLaptop, aliceExit, bobExit}
|
||||
|
||||
policy := `{
|
||||
"tagOwners": {
|
||||
"tag:exit1": ["alice@headscale.net"],
|
||||
"tag:exit2": ["bob@headscale.net"]
|
||||
},
|
||||
"grants": [
|
||||
{
|
||||
"src": ["alice@headscale.net"],
|
||||
"dst": ["autogroup:internet"],
|
||||
"via": ["tag:exit1"],
|
||||
"ip": ["*"]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
pm, err := NewPolicyManager([]byte(policy), users, nodes.ViewSlice())
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("BuildPeerMap_includes_via_tagged_exit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
peerMap := pm.BuildPeerMap(nodes.ViewSlice())
|
||||
|
||||
require.True(t,
|
||||
slices.ContainsFunc(peerMap[aliceLaptop.ID], func(n types.NodeView) bool {
|
||||
return n.ID() == aliceExit.ID
|
||||
}),
|
||||
"alice must see her tag:exit1 exit node as a peer")
|
||||
|
||||
require.False(t,
|
||||
slices.ContainsFunc(peerMap[aliceLaptop.ID], func(n types.NodeView) bool {
|
||||
return n.ID() == bobExit.ID
|
||||
}),
|
||||
"alice must not see bob's tag:exit2 exit node — via grant scopes to tag:exit1")
|
||||
})
|
||||
|
||||
t.Run("ViaRoutesForPeer_includes_exit_for_matching_tag", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := pm.ViaRoutesForPeer(aliceLaptop.View(), aliceExit.View())
|
||||
require.Contains(t, result.Include, tsaddr.AllIPv4(),
|
||||
"alice viewing tag:exit1 exit must Include 0.0.0.0/0 — drives AllowedIPs in state.RoutesForPeer")
|
||||
require.Contains(t, result.Include, tsaddr.AllIPv6(),
|
||||
"alice viewing tag:exit1 exit must Include ::/0 — drives AllowedIPs in state.RoutesForPeer")
|
||||
})
|
||||
|
||||
t.Run("ViaRoutesForPeer_excludes_exit_for_other_tag", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := pm.ViaRoutesForPeer(aliceLaptop.View(), bobExit.View())
|
||||
require.Contains(t, result.Exclude, tsaddr.AllIPv4(),
|
||||
"alice viewing tag:exit2 exit must Exclude 0.0.0.0/0 — strips it from AllowedIPs")
|
||||
require.Contains(t, result.Exclude, tsaddr.AllIPv6(),
|
||||
"alice viewing tag:exit2 exit must Exclude ::/0 — strips it from AllowedIPs")
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
+52
-469
@@ -5,7 +5,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -21,7 +20,6 @@ import (
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/views"
|
||||
"tailscale.com/util/deephash"
|
||||
"tailscale.com/util/multierr"
|
||||
)
|
||||
|
||||
// ErrInvalidTagOwner is returned when a tag owner is not an Alias type.
|
||||
@@ -57,24 +55,9 @@ type PolicyManager struct {
|
||||
// Lazy map of per-node filter rules (reduced, for packet filters)
|
||||
filterRulesMap map[types.NodeID][]tailcfg.FilterRule
|
||||
|
||||
// Lazy map of per-node matchers derived from UNREDUCED filter
|
||||
// rules. Only populated on the slow path when needsPerNodeFilter
|
||||
// is true; the fast path returns pm.matchers directly.
|
||||
matchersForNodeMap map[types.NodeID][]matcher.Match
|
||||
|
||||
// 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.
|
||||
@@ -85,91 +68,6 @@ type filterAndPolicy struct {
|
||||
Policy *Policy
|
||||
}
|
||||
|
||||
// validateUserReferences surfaces ambiguous user@ tokens at policy load so
|
||||
// duplicate DB rows fail loudly instead of silently dropping rules (#3160).
|
||||
// Missing-user tokens stay tolerant (#2863). Empty users → no-op for
|
||||
// syntax-only checks.
|
||||
func validateUserReferences(pol *Policy, users types.Users) error {
|
||||
if pol == nil || len(users) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var errs []error
|
||||
|
||||
check := func(u *Username) {
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
|
||||
_, err := u.resolveUser(users)
|
||||
if err != nil && errors.Is(err, ErrMultipleUsersFound) {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
checkAlias := func(a Alias) {
|
||||
if u, ok := a.(*Username); ok {
|
||||
check(u)
|
||||
}
|
||||
}
|
||||
|
||||
checkOwner := func(o Owner) {
|
||||
if u, ok := o.(*Username); ok {
|
||||
check(u)
|
||||
}
|
||||
}
|
||||
|
||||
checkAutoApprover := func(aa AutoApprover) {
|
||||
if u, ok := aa.(*Username); ok {
|
||||
check(u)
|
||||
}
|
||||
}
|
||||
|
||||
for _, usernames := range pol.Groups {
|
||||
for i := range usernames {
|
||||
check(&usernames[i])
|
||||
}
|
||||
}
|
||||
|
||||
for _, owners := range pol.TagOwners {
|
||||
for _, o := range owners {
|
||||
checkOwner(o)
|
||||
}
|
||||
}
|
||||
|
||||
for _, approvers := range pol.AutoApprovers.Routes {
|
||||
for _, aa := range approvers {
|
||||
checkAutoApprover(aa)
|
||||
}
|
||||
}
|
||||
|
||||
for _, aa := range pol.AutoApprovers.ExitNode {
|
||||
checkAutoApprover(aa)
|
||||
}
|
||||
|
||||
for _, acl := range pol.ACLs {
|
||||
for _, src := range acl.Sources {
|
||||
checkAlias(src)
|
||||
}
|
||||
|
||||
for _, dst := range acl.Destinations {
|
||||
checkAlias(dst.Alias)
|
||||
}
|
||||
}
|
||||
|
||||
for _, ssh := range pol.SSHs {
|
||||
for _, src := range ssh.Sources {
|
||||
checkAlias(src)
|
||||
}
|
||||
|
||||
for _, dst := range ssh.Destinations {
|
||||
checkAlias(dst)
|
||||
}
|
||||
}
|
||||
|
||||
return multierr.New(errs...)
|
||||
}
|
||||
|
||||
// NewPolicyManager creates a new PolicyManager from a policy file and a list of users and nodes.
|
||||
// It returns an error if the policy file is invalid.
|
||||
// The policy manager will update the filter rules based on the users and nodes.
|
||||
@@ -179,18 +77,12 @@ func NewPolicyManager(b []byte, users []types.User, nodes views.Slice[types.Node
|
||||
return nil, fmt.Errorf("parsing policy: %w", err)
|
||||
}
|
||||
|
||||
err = validateUserReferences(policy, users)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("validating policy user references: %w", err)
|
||||
}
|
||||
|
||||
pm := PolicyManager{
|
||||
pol: policy,
|
||||
users: users,
|
||||
nodes: nodes,
|
||||
sshPolicyMap: make(map[types.NodeID]*tailcfg.SSHPolicy, nodes.Len()),
|
||||
filterRulesMap: make(map[types.NodeID][]tailcfg.FilterRule, nodes.Len()),
|
||||
matchersForNodeMap: make(map[types.NodeID][]matcher.Match, nodes.Len()),
|
||||
pol: policy,
|
||||
users: users,
|
||||
nodes: nodes,
|
||||
sshPolicyMap: make(map[types.NodeID]*tailcfg.SSHPolicy, nodes.Len()),
|
||||
filterRulesMap: make(map[types.NodeID][]tailcfg.FilterRule, nodes.Len()),
|
||||
}
|
||||
|
||||
_, err = pm.updateLocked()
|
||||
@@ -198,20 +90,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
|
||||
}
|
||||
|
||||
@@ -225,7 +103,7 @@ func (pm *PolicyManager) updateLocked() (bool, error) {
|
||||
pm.needsPerNodeFilter = hasPerNodeGrants(pm.compiledGrants)
|
||||
|
||||
var filter []tailcfg.FilterRule
|
||||
if pm.pol == nil || (pm.pol.ACLs == nil && pm.pol.Grants == nil) {
|
||||
if pm.pol == nil || (pm.pol.ACLs == nil && len(pm.pol.Grants) == 0) {
|
||||
filter = tailcfg.FilterAllowAll
|
||||
} else {
|
||||
filter = globalFilterRules(pm.compiledGrants)
|
||||
@@ -313,16 +191,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
|
||||
@@ -339,7 +207,6 @@ func (pm *PolicyManager) updateLocked() (bool, error) {
|
||||
// that nodes has been added or removed.
|
||||
clear(pm.sshPolicyMap)
|
||||
clear(pm.filterRulesMap)
|
||||
clear(pm.matchersForNodeMap)
|
||||
}
|
||||
|
||||
// If nothing changed, no need to update nodes
|
||||
@@ -360,11 +227,6 @@ func (pm *PolicyManager) updateLocked() (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// SSHPolicy returns the tailcfg.SSHPolicy for node, compiling and
|
||||
// caching on first access. Rules use SessionDuration = 0 (no
|
||||
// auto-approval) and emit check URLs of the form
|
||||
// /machine/ssh/action/{src}/to/{dst}?local_user={local_user} per the
|
||||
// SaaS wire format. Cache is invalidated on policy reload.
|
||||
func (pm *PolicyManager) SSHPolicy(baseURL string, node types.NodeView) (*tailcfg.SSHPolicy, error) {
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
@@ -385,12 +247,9 @@ func (pm *PolicyManager) SSHPolicy(baseURL string, node types.NodeView) (*tailcf
|
||||
|
||||
// SSHCheckParams resolves the SSH check period for a source-destination
|
||||
// node pair by looking up the current policy. This avoids trusting URL
|
||||
// parameters that a client could tamper with. First-match wins across
|
||||
// the policy's SSH rules.
|
||||
//
|
||||
// Returns (duration, true) when a matching rule is found and
|
||||
// (0, false) when none is. A (0, true) return means the matched rule
|
||||
// uses a zero check period (re-check every session).
|
||||
// parameters that a client could tamper with.
|
||||
// It returns the check period duration and whether a matching check
|
||||
// rule was found.
|
||||
func (pm *PolicyManager) SSHCheckParams(
|
||||
srcNodeID, dstNodeID types.NodeID,
|
||||
) (time.Duration, bool) {
|
||||
@@ -477,27 +336,6 @@ func (pm *PolicyManager) SetPolicy(polB []byte) (bool, error) {
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
|
||||
err = validateUserReferences(pol, pm.users)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("validating policy user references: %w", err)
|
||||
}
|
||||
|
||||
// SetPolicy is the user-write boundary. Tests evaluate against a
|
||||
// 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
|
||||
}
|
||||
|
||||
// Log policy metadata for debugging
|
||||
log.Debug().
|
||||
Int("policy.bytes", len(polB)).
|
||||
@@ -505,9 +343,7 @@ 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")
|
||||
|
||||
pm.pol = pol
|
||||
@@ -666,9 +502,6 @@ func (pm *PolicyManager) filterForNodeLocked(
|
||||
// to only include rules relevant to that node.
|
||||
// If the policy uses autogroup:self, this returns node-specific compiled rules.
|
||||
// Otherwise, it returns the global filter reduced for this node.
|
||||
//
|
||||
// Cache is invalidated by updateLocked on policy reload, node-set
|
||||
// change, or tag-state change.
|
||||
func (pm *PolicyManager) FilterForNode(node types.NodeView) ([]tailcfg.FilterRule, error) {
|
||||
if pm == nil {
|
||||
return nil, nil
|
||||
@@ -686,10 +519,6 @@ func (pm *PolicyManager) FilterForNode(node types.NodeView) ([]tailcfg.FilterRul
|
||||
//
|
||||
// For global policies: returns the global matchers (same for all nodes)
|
||||
// For autogroup:self: returns node-specific matchers from unreduced compiled rules.
|
||||
//
|
||||
// Per-node results are cached and invalidated on policy/node updates
|
||||
// so BuildPeerMap's O(N²) slow path avoids recomputing matchers for
|
||||
// every pair.
|
||||
func (pm *PolicyManager) MatchersForNode(node types.NodeView) ([]matcher.Match, error) {
|
||||
if pm == nil {
|
||||
return nil, nil
|
||||
@@ -705,17 +534,11 @@ func (pm *PolicyManager) MatchersForNode(node types.NodeView) ([]matcher.Match,
|
||||
return pm.matchers, nil
|
||||
}
|
||||
|
||||
if cached, ok := pm.matchersForNodeMap[node.ID()]; ok {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
// For autogroup:self or via grants, derive matchers from
|
||||
// the stored compiled grants for this specific node.
|
||||
unreduced := pm.filterRulesForNodeLocked(node)
|
||||
matchers := matcher.MatchesFromFilterRules(unreduced)
|
||||
pm.matchersForNodeMap[node.ID()] = matchers
|
||||
|
||||
return matchers, nil
|
||||
return matcher.MatchesFromFilterRules(unreduced), nil
|
||||
}
|
||||
|
||||
// SetUsers updates the users in the policy manager and updates the filter rules.
|
||||
@@ -787,7 +610,6 @@ func (pm *PolicyManager) SetNodes(nodes views.Slice[types.NodeView]) (bool, erro
|
||||
// This ensures fresh filter rules are generated for all nodes
|
||||
clear(pm.sshPolicyMap)
|
||||
clear(pm.filterRulesMap)
|
||||
clear(pm.matchersForNodeMap)
|
||||
}
|
||||
// Always return true when nodes changed, even if filter hash didn't change
|
||||
// (can happen with autogroup:self or when nodes are added but don't affect rules)
|
||||
@@ -936,9 +758,6 @@ func (pm *PolicyManager) NodeCanApproveRoute(node types.NodeView, route netip.Pr
|
||||
return false
|
||||
}
|
||||
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
|
||||
// If the route to-be-approved is an exit route, then we need to check
|
||||
// if the node is in allowed to approve it. This is treated differently
|
||||
// than the auto-approvers, as the auto-approvers are not allowed to
|
||||
@@ -950,9 +769,16 @@ func (pm *PolicyManager) NodeCanApproveRoute(node types.NodeView, route netip.Pr
|
||||
return false
|
||||
}
|
||||
|
||||
return slices.ContainsFunc(node.IPs(), pm.exitSet.Contains)
|
||||
if slices.ContainsFunc(node.IPs(), pm.exitSet.Contains) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
|
||||
// The fast path is that a node requests to approve a prefix
|
||||
// where there is an exact entry, e.g. 10.0.0.0/8, then
|
||||
// check and return quickly
|
||||
@@ -985,10 +811,6 @@ func (pm *PolicyManager) NodeCanApproveRoute(node types.NodeView, route netip.Pr
|
||||
// For each via grant where the viewer matches the source, it checks whether the
|
||||
// peer advertises any of the grant's destination prefixes. If the peer has the
|
||||
// via tag, those prefixes go into Include; otherwise into Exclude.
|
||||
//
|
||||
// Performance note: this holds pm.mu for its full duration. Hot
|
||||
// callers should memoise by (policy-hash, viewer-id) rather than
|
||||
// invoking this per-pair.
|
||||
func (pm *PolicyManager) ViaRoutesForPeer(viewer, peer types.NodeView) types.ViaRouteResult {
|
||||
var result types.ViaRouteResult
|
||||
|
||||
@@ -1009,33 +831,28 @@ func (pm *PolicyManager) ViaRoutesForPeer(viewer, peer types.NodeView) types.Via
|
||||
grants = append(grants, aclToGrants(acl)...)
|
||||
}
|
||||
|
||||
// Resolve each grant's sources against the viewer once. The three
|
||||
// passes below reuse this result instead of calling src.Resolve
|
||||
// per grant per pass.
|
||||
viewerIPs := viewer.IPs()
|
||||
viewerMatchesGrant := make([]bool, len(grants))
|
||||
for _, grant := range grants {
|
||||
if len(grant.Via) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if viewer matches any grant source.
|
||||
viewerMatches := false
|
||||
|
||||
for i, grant := range grants {
|
||||
for _, src := range grant.Sources {
|
||||
ips, err := src.Resolve(pm.pol, pm.users, pm.nodes)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if ips != nil && slices.ContainsFunc(viewerIPs, ips.Contains) {
|
||||
viewerMatchesGrant[i] = true
|
||||
if ips != nil && slices.ContainsFunc(viewer.IPs(), ips.Contains) {
|
||||
viewerMatches = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for i, grant := range grants {
|
||||
if len(grant.Via) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if !viewerMatchesGrant[i] {
|
||||
if !viewerMatches {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1052,16 +869,11 @@ func (pm *PolicyManager) ViaRoutesForPeer(viewer, peer types.NodeView) types.Via
|
||||
matchedPrefixes = append(matchedPrefixes, dstPrefix)
|
||||
}
|
||||
case *AutoGroup:
|
||||
// Per-viewer steering for autogroup:internet: a peer
|
||||
// advertising approved exit routes is the via-tagged
|
||||
// node's analogue of "advertises the destination".
|
||||
// The downstream Include/Exclude split below restricts
|
||||
// alice to exit nodes carrying the via tag.
|
||||
if d.Is(AutoGroupInternet) && peer.IsExitNode() {
|
||||
matchedPrefixes = append(
|
||||
matchedPrefixes, peer.ExitRoutes()...,
|
||||
)
|
||||
}
|
||||
// autogroup:internet via grants do NOT affect AllowedIPs or
|
||||
// route steering for exit nodes. Tailscale SaaS handles exit
|
||||
// traffic forwarding through the client's exit node selection
|
||||
// mechanism, not through AllowedIPs. Verified by golden
|
||||
// captures GRANT-V14 through GRANT-V36.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1087,113 +899,6 @@ func (pm *PolicyManager) ViaRoutesForPeer(viewer, peer types.NodeView) types.Via
|
||||
}
|
||||
}
|
||||
|
||||
// Detect prefixes that should fall back to HA primary election
|
||||
// rather than per-viewer via steering. Two conditions trigger this:
|
||||
//
|
||||
// 1. Multi-router via: a via grant's tag matches multiple peers
|
||||
// advertising the same prefix.
|
||||
// 2. Regular grant overlap: a non-via grant also covers the same
|
||||
// prefix for this viewer.
|
||||
//
|
||||
// When neither condition is met, per-viewer via steering applies.
|
||||
if len(result.Include) > 0 || len(result.Exclude) > 0 {
|
||||
// Multi-router via election: when a via grant's tag matches
|
||||
// multiple peers advertising the same prefix, only the
|
||||
// lowest-ID peer (the via-group primary) keeps the prefix in
|
||||
// Include. The others move to Exclude. This mirrors HA
|
||||
// primary election scoped to the via tag group.
|
||||
//
|
||||
// Unlike the global PrimaryRoutes election (routes/primary.go),
|
||||
// which picks one primary across ALL advertisers of a prefix,
|
||||
// this election is scoped to the via tag. Two via grants with
|
||||
// different tags (e.g., tag:ha-a vs tag:ha-b) each elect their
|
||||
// own winner independently.
|
||||
//
|
||||
// Only process via grants where the viewer matches the source,
|
||||
// otherwise grants for other viewer groups would incorrectly
|
||||
// demote the peer.
|
||||
for i, grant := range grants {
|
||||
if len(grant.Via) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if !viewerMatchesGrant[i] {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, dst := range grant.Destinations {
|
||||
d, ok := dst.(*Prefix)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
dstPrefix := netip.Prefix(*d)
|
||||
if !slices.Contains(result.Include, dstPrefix) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Find the lowest-ID peer with this via tag that
|
||||
// advertises this prefix — the via-group primary.
|
||||
var viaPrimaryID types.NodeID
|
||||
|
||||
for _, viaTag := range grant.Via {
|
||||
for _, node := range pm.nodes.All() {
|
||||
if node.HasTag(string(viaTag)) &&
|
||||
slices.Contains(node.SubnetRoutes(), dstPrefix) {
|
||||
if viaPrimaryID == 0 || node.ID() < viaPrimaryID {
|
||||
viaPrimaryID = node.ID()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If the current peer is not the via-group primary,
|
||||
// demote the prefix from Include to Exclude.
|
||||
if viaPrimaryID != 0 && peer.ID() != viaPrimaryID {
|
||||
result.Include = slices.DeleteFunc(result.Include, func(p netip.Prefix) bool {
|
||||
return p == dstPrefix
|
||||
})
|
||||
if !slices.Contains(result.Exclude, dstPrefix) {
|
||||
result.Exclude = append(result.Exclude, dstPrefix)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for regular (non-via) grants covering the same prefix.
|
||||
// When a regular grant also covers a prefix that a via grant
|
||||
// included, defer to global HA primary election (UsePrimary).
|
||||
// When a regular grant covers a prefix that a via grant excluded
|
||||
// (peer lacks via tag), remove the exclusion so RoutesForPeer
|
||||
// can apply normal ReduceRoutes + primary logic.
|
||||
for i, grant := range grants {
|
||||
if len(grant.Via) > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if !viewerMatchesGrant[i] {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, dst := range grant.Destinations {
|
||||
if d, ok := dst.(*Prefix); ok {
|
||||
dstPrefix := netip.Prefix(*d)
|
||||
if slices.Contains(result.Include, dstPrefix) &&
|
||||
!slices.Contains(result.UsePrimary, dstPrefix) {
|
||||
result.UsePrimary = append(result.UsePrimary, dstPrefix)
|
||||
}
|
||||
|
||||
// A regular grant overrides a via exclusion: the
|
||||
// peer doesn't need the via tag if the viewer has
|
||||
// direct (non-via) access to the prefix.
|
||||
result.Exclude = slices.DeleteFunc(result.Exclude, func(p netip.Prefix) bool {
|
||||
return p == dstPrefix
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -1406,12 +1111,10 @@ func (pm *PolicyManager) invalidateAutogroupSelfCache(oldNodes, newNodes views.S
|
||||
if found {
|
||||
if _, affected := affectedUsers[nodeUserID]; affected {
|
||||
delete(pm.filterRulesMap, nodeID)
|
||||
delete(pm.matchersForNodeMap, nodeID)
|
||||
}
|
||||
} else {
|
||||
// Node not found in either old or new list, clear it
|
||||
delete(pm.filterRulesMap, nodeID)
|
||||
delete(pm.matchersForNodeMap, nodeID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1460,7 +1163,6 @@ func (pm *PolicyManager) invalidateGlobalPolicyCache(newNodes views.Slice[types.
|
||||
|
||||
if newNode.HasNetworkChanges(oldNode) {
|
||||
delete(pm.filterRulesMap, nodeID)
|
||||
delete(pm.matchersForNodeMap, nodeID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1470,26 +1172,29 @@ func (pm *PolicyManager) invalidateGlobalPolicyCache(newNodes views.Slice[types.
|
||||
delete(pm.filterRulesMap, nodeID)
|
||||
}
|
||||
}
|
||||
|
||||
for nodeID := range pm.matchersForNodeMap {
|
||||
if _, exists := newNodeMap[nodeID]; !exists {
|
||||
delete(pm.matchersForNodeMap, nodeID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 +1300,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
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
@@ -45,20 +44,6 @@ func TestPolicyManager(t *testing.T) {
|
||||
wantFilter: tailcfg.FilterAllowAll,
|
||||
wantMatchers: matcher.MatchesFromFilterRules(tailcfg.FilterAllowAll),
|
||||
},
|
||||
{
|
||||
name: "empty-acls-denies-all",
|
||||
pol: `{"acls": []}`,
|
||||
nodes: types.Nodes{},
|
||||
wantFilter: nil,
|
||||
wantMatchers: matcher.MatchesFromFilterRules(nil),
|
||||
},
|
||||
{
|
||||
name: "empty-grants-denies-all",
|
||||
pol: `{"grants": []}`,
|
||||
nodes: types.Nodes{},
|
||||
wantFilter: nil,
|
||||
wantMatchers: matcher.MatchesFromFilterRules(nil),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -1640,14 +1625,10 @@ func TestViaRoutesForPeer(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
result := pm.ViaRoutesForPeer(nodes[0].View(), nodes[1].View())
|
||||
// Include contains the subnet route plus the peer's approved
|
||||
// exit routes — the peer holds tag:router and advertises exit
|
||||
// routes, so autogroup:internet steering applies alongside the
|
||||
// explicit prefix.
|
||||
// Include should have only the subnet route.
|
||||
// autogroup:internet does not produce via route effects.
|
||||
require.Contains(t, result.Include, mp("10.0.0.0/24"))
|
||||
require.Contains(t, result.Include, mp("0.0.0.0/0"))
|
||||
require.Contains(t, result.Include, mp("::/0"))
|
||||
require.Len(t, result.Include, 3)
|
||||
require.Len(t, result.Include, 1)
|
||||
require.Empty(t, result.Exclude)
|
||||
})
|
||||
|
||||
@@ -1717,20 +1698,17 @@ func TestViaRoutesForPeer(t *testing.T) {
|
||||
pm, err := NewPolicyManager([]byte(pol), users, nodes.ViewSlice())
|
||||
require.NoError(t, err)
|
||||
|
||||
// autogroup:internet via grants surface the peer's approved
|
||||
// exit routes when the peer carries the via tag, and exclude
|
||||
// them when it does not — restricting which exit nodes the
|
||||
// viewer may use, per Tailscale's grants-via spec for
|
||||
// autogroup:internet.
|
||||
// autogroup:internet via grants do NOT affect AllowedIPs or
|
||||
// route steering. Tailscale SaaS handles exit traffic through
|
||||
// the client's exit node mechanism, not ViaRoutesForPeer.
|
||||
// Verified by golden captures GRANT-V14 through GRANT-V36.
|
||||
resultExit := pm.ViaRoutesForPeer(nodes[0].View(), nodes[1].View())
|
||||
require.Contains(t, resultExit.Include, mp("0.0.0.0/0"))
|
||||
require.Contains(t, resultExit.Include, mp("::/0"))
|
||||
require.Empty(t, resultExit.Include)
|
||||
require.Empty(t, resultExit.Exclude)
|
||||
|
||||
resultOther := pm.ViaRoutesForPeer(nodes[0].View(), nodes[2].View())
|
||||
require.Empty(t, resultOther.Include)
|
||||
require.Contains(t, resultOther.Exclude, mp("0.0.0.0/0"))
|
||||
require.Contains(t, resultOther.Exclude, mp("::/0"))
|
||||
require.Empty(t, resultOther.Exclude)
|
||||
})
|
||||
|
||||
t.Run("via_routes_survive_reduce_routes", func(t *testing.T) {
|
||||
@@ -1814,389 +1792,3 @@ func TestViaRoutesForPeer(t *testing.T) {
|
||||
"state.RoutesForPeer adds via routes after ReduceRoutes to fix this")
|
||||
})
|
||||
}
|
||||
|
||||
// TestBuildPeerMap_AutogroupInternetMakesExitNodeVisible reproduces
|
||||
// juanfont/headscale#3212. An ACL that grants access only via
|
||||
// `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.
|
||||
func TestBuildPeerMap_AutogroupInternetMakesExitNodeVisible(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
users := types.Users{
|
||||
{Model: gorm.Model{ID: 1}, Name: "alice", Email: "alice@headscale.net"},
|
||||
}
|
||||
|
||||
aliceNode := node("alice-laptop", "100.64.0.10", "fd7a:115c:a1e0::a", users[0])
|
||||
aliceNode.ID = 1
|
||||
|
||||
exitRoutes := []netip.Prefix{tsaddr.AllIPv4(), tsaddr.AllIPv6()}
|
||||
exitNode := node("alice-exit", "100.64.0.1", "fd7a:115c:a1e0::1", users[0])
|
||||
exitNode.ID = 2
|
||||
exitNode.Hostinfo = &tailcfg.Hostinfo{RoutableIPs: exitRoutes}
|
||||
exitNode.ApprovedRoutes = exitRoutes
|
||||
|
||||
nodes := types.Nodes{aliceNode, exitNode}
|
||||
|
||||
policy := `{
|
||||
"acls": [
|
||||
{"action": "accept", "src": ["alice@headscale.net"], "dst": ["autogroup:internet:*"]}
|
||||
]
|
||||
}`
|
||||
|
||||
pm, err := NewPolicyManager([]byte(policy), users, nodes.ViewSlice())
|
||||
require.NoError(t, err)
|
||||
|
||||
peerMap := pm.BuildPeerMap(nodes.ViewSlice())
|
||||
|
||||
require.True(t,
|
||||
slices.ContainsFunc(peerMap[aliceNode.ID], func(n types.NodeView) bool {
|
||||
return n.ID() == exitNode.ID
|
||||
}),
|
||||
"alice should see the exit node as a peer when an ACL grants autogroup:internet (#3212)")
|
||||
|
||||
_, matchers := pm.Filter()
|
||||
require.True(t, aliceNode.View().CanAccess(matchers, exitNode.View()),
|
||||
"alice.CanAccess(exit) should be true via DestsIsTheInternet()+IsExitNode() (#3212)")
|
||||
}
|
||||
|
||||
// Reproduction for #3160: ambiguous user@ used to silently drop rules.
|
||||
func TestNewPolicyManager_DuplicateUsername(t *testing.T) {
|
||||
users := types.Users{
|
||||
{Model: gorm.Model{ID: 2}, Name: "yala"},
|
||||
{Model: gorm.Model{ID: 7}, Name: "yala", Email: "yala@yala.yala"},
|
||||
}
|
||||
|
||||
polB := []byte(`{
|
||||
"groups": {"group:admins": ["yala@"]},
|
||||
"tagOwners": {"tag:ssh": ["group:admins"]},
|
||||
"acls": [{"action":"accept","src":["*"],"dst":["*:*"]}],
|
||||
"ssh": [
|
||||
{"action":"accept","src":["group:admins"],"dst":["tag:ssh"],"users":["root"]}
|
||||
]
|
||||
}`)
|
||||
|
||||
_, err := NewPolicyManager(polB, users, types.Nodes{}.ViewSlice())
|
||||
require.Error(t, err, "NewPolicyManager must reject policy with ambiguous username")
|
||||
require.ErrorIs(t, err, ErrMultipleUsersFound)
|
||||
require.Contains(t, err.Error(), "yala@",
|
||||
"error must name the offending token")
|
||||
}
|
||||
|
||||
// Missing-user tokens stay tolerant per #2863; only multi-match blocks load.
|
||||
func TestNewPolicyManager_UnknownUsernameTolerant(t *testing.T) {
|
||||
users := types.Users{
|
||||
{Model: gorm.Model{ID: 1}, Name: "alice"},
|
||||
}
|
||||
|
||||
polB := []byte(`{
|
||||
"acls": [{"action":"accept","src":["ghost@"],"dst":["*:*"]}]
|
||||
}`)
|
||||
|
||||
_, err := NewPolicyManager(polB, users, types.Nodes{}.ViewSlice())
|
||||
require.NoError(t, err, "missing-user references must not block policy load (#2863)")
|
||||
}
|
||||
|
||||
// Rejected SetPolicy must keep the previous policy intact.
|
||||
func TestSetPolicy_DuplicateUsername(t *testing.T) {
|
||||
users := types.Users{
|
||||
{Model: gorm.Model{ID: 2}, Name: "yala"},
|
||||
{Model: gorm.Model{ID: 7}, Name: "yala", Email: "yala@yala.yala"},
|
||||
}
|
||||
|
||||
good := []byte(`{
|
||||
"acls": [{"action":"accept","src":["*"],"dst":["*:*"]}]
|
||||
}`)
|
||||
|
||||
pm, err := NewPolicyManager(good, users, types.Nodes{}.ViewSlice())
|
||||
require.NoError(t, err)
|
||||
|
||||
bad := []byte(`{
|
||||
"groups": {"group:admins": ["yala@"]},
|
||||
"acls": [{"action":"accept","src":["group:admins"],"dst":["*:*"]}]
|
||||
}`)
|
||||
|
||||
_, err = pm.SetPolicy(bad)
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, ErrMultipleUsersFound)
|
||||
|
||||
filter, _ := pm.Filter()
|
||||
require.NotNil(t, filter, "filter must remain populated after rejected SetPolicy")
|
||||
}
|
||||
|
||||
// Empty users → syntax-only check, used by `headscale policy check`.
|
||||
func TestValidateUserReferences_EmptyUsersTolerant(t *testing.T) {
|
||||
polB := []byte(`{
|
||||
"groups": {"group:admins": ["yala@"]},
|
||||
"tagOwners": {"tag:ssh": ["group:admins"]},
|
||||
"acls": [{"action":"accept","src":["yala@"],"dst":["*:*"]}],
|
||||
"ssh": [
|
||||
{"action":"accept","src":["yala@"],"dst":["tag:ssh"],"users":["root"]}
|
||||
]
|
||||
}`)
|
||||
|
||||
_, err := NewPolicyManager(polB, nil, types.Nodes{}.ViewSlice())
|
||||
require.NoError(t, err, "nil users must skip user-reference validation")
|
||||
|
||||
_, err = NewPolicyManager(polB, types.Users{}, types.Nodes{}.ViewSlice())
|
||||
require.NoError(t, err, "empty users must skip user-reference validation")
|
||||
}
|
||||
|
||||
// One case per AST site so a dropped walk fails the matching subtest.
|
||||
func TestValidateUserReferences_AllSites(t *testing.T) {
|
||||
users := types.Users{
|
||||
{Model: gorm.Model{ID: 1}, Name: "alice"},
|
||||
{Model: gorm.Model{ID: 2}, Name: "dup"},
|
||||
{Model: gorm.Model{ID: 3}, Name: "dup"},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
pol string
|
||||
}{
|
||||
{
|
||||
name: "groups",
|
||||
pol: `{
|
||||
"groups": {"group:admins": ["dup@"]},
|
||||
"acls": [{"action":"accept","src":["group:admins"],"dst":["*:*"]}]
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "tagOwners",
|
||||
pol: `{
|
||||
"tagOwners": {"tag:ssh": ["dup@"]},
|
||||
"acls": [{"action":"accept","src":["*"],"dst":["*:*"]}]
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "autoApprovers.routes",
|
||||
pol: `{
|
||||
"autoApprovers": {"routes": {"10.0.0.0/8": ["dup@"]}},
|
||||
"acls": [{"action":"accept","src":["*"],"dst":["*:*"]}]
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "autoApprovers.exitNode",
|
||||
pol: `{
|
||||
"autoApprovers": {"exitNode": ["dup@"]},
|
||||
"acls": [{"action":"accept","src":["*"],"dst":["*:*"]}]
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "acls.src",
|
||||
pol: `{
|
||||
"acls": [{"action":"accept","src":["dup@"],"dst":["*:*"]}]
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "acls.dst",
|
||||
pol: `{
|
||||
"acls": [{"action":"accept","src":["alice@"],"dst":["dup@:*"]}]
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "ssh.src",
|
||||
pol: `{
|
||||
"tagOwners": {"tag:ssh": ["alice@"]},
|
||||
"acls": [{"action":"accept","src":["*"],"dst":["*:*"]}],
|
||||
"ssh": [{"action":"accept","src":["dup@"],"dst":["tag:ssh"],"users":["root"]}]
|
||||
}`,
|
||||
},
|
||||
{
|
||||
// ErrSSHUserDestRequiresSameUser forces src==dst when dst is a user.
|
||||
name: "ssh.dst",
|
||||
pol: `{
|
||||
"acls": [{"action":"accept","src":["*"],"dst":["*:*"]}],
|
||||
"ssh": [{"action":"accept","src":["dup@"],"dst":["dup@"],"users":["root"]}]
|
||||
}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := NewPolicyManager([]byte(tt.pol), users, types.Nodes{}.ViewSlice())
|
||||
require.Error(t, err, "site %q must surface duplicate-user errors", tt.name)
|
||||
require.ErrorIs(t, err, ErrMultipleUsersFound)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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,170 +0,0 @@
|
||||
// Compatibility tests for the policy `tests` block, replaying captures
|
||||
// recorded against a real Tailscale SaaS tailnet. The runner mirrors the
|
||||
// pattern in tailscale_grants_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 the SaaS, the
|
||||
// captured Message is the byte-exact 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 — the SaaS accepted the policy (its `tests`
|
||||
// block passed); headscale's RunTests must also pass.
|
||||
//
|
||||
// Captures live in testdata/policytest_results/*.hujson. Scenarios in
|
||||
// knownPolicyTesterDivergences are skipped with their tracking note —
|
||||
// these are real Tailscale ↔ headscale divergences uncovered by the
|
||||
// captures 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"
|
||||
"github.com/juanfont/headscale/hscontrol/types/testcapture"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
// knownPolicyTesterDivergences lists scenarios where headscale's evaluator
|
||||
// disagrees with Tailscale SaaS on whether the policy should be accepted.
|
||||
// Each entry is a real bug to fix in a follow-up; documenting them here
|
||||
// keeps the compat suite green and the divergence list visible.
|
||||
var knownPolicyTesterDivergences = map[string]string{} //nolint:gosec // strings here are human-readable notes, not credentials
|
||||
|
||||
// policyTesterCompatUsers / policyTesterCompatNodes mirror the small
|
||||
// shared topology used to record the captures. When more captures land
|
||||
// we'll also exercise an autogroup-heavy second topology — for now this
|
||||
// minimal one is enough to make the runner go.
|
||||
func policyTesterCompatUsers() 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"},
|
||||
}
|
||||
}
|
||||
|
||||
func policyTesterCompatNodes(users types.Users) types.Nodes {
|
||||
return types.Nodes{
|
||||
{
|
||||
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{},
|
||||
},
|
||||
{
|
||||
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{},
|
||||
},
|
||||
{
|
||||
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{},
|
||||
},
|
||||
{
|
||||
ID: 4,
|
||||
GivenName: "beedrill",
|
||||
IPv4: ptrAddr("100.108.74.26"),
|
||||
IPv6: ptrAddr("fd7a:115c:a1e0::b901:4a87"),
|
||||
Tags: []string{"tag:server"},
|
||||
Hostinfo: &tailcfg.Hostinfo{},
|
||||
},
|
||||
{
|
||||
ID: 5,
|
||||
GivenName: "kakuna",
|
||||
IPv4: ptrAddr("100.103.8.15"),
|
||||
IPv6: ptrAddr("fd7a:115c:a1e0::5b37:80f"),
|
||||
Tags: []string{"tag:client"},
|
||||
Hostinfo: &tailcfg.Hostinfo{},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestPolicyTesterCompat replays every capture under
|
||||
// testdata/policytest_results/ against the engine. With no captures the
|
||||
// test is a no-op — committed early so the layout/wiring lands before
|
||||
// the bulk import.
|
||||
func TestPolicyTesterCompat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
files, err := filepath.Glob(filepath.Join("testdata", "policytest_results", "*.hujson"))
|
||||
require.NoError(t, err, "failed to glob test files")
|
||||
|
||||
if len(files) == 0 {
|
||||
t.Skip("no policytest captures yet")
|
||||
}
|
||||
|
||||
users := policyTesterCompatUsers()
|
||||
nodes := policyTesterCompatNodes(users)
|
||||
|
||||
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 := knownPolicyTesterDivergences[c.TestID]; skip {
|
||||
t.Skip(reason)
|
||||
}
|
||||
|
||||
policyJSON := []byte(c.Input.FullPolicy)
|
||||
|
||||
pm, parseErr := NewPolicyManager(policyJSON, users, nodes.ViewSlice())
|
||||
|
||||
// Tailscale validates and runs tests as one POST step:
|
||||
// either failure mode produces the same 400. Headscale
|
||||
// splits structural validation (parse) from test
|
||||
// evaluation (SetPolicy). For the compat assertion, the
|
||||
// two are equivalent — whichever surfaces first carries
|
||||
// the captured body.
|
||||
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 tests should 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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user