Compare commits

..

23 Commits

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

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

Adds two captures for the new shapes (bare IPv4 / IPv6) and parse-time
rows for the same in types_test. The IPv6 capture lands a SaaS-side
engine asymmetry (parse-accept, engine-reject "test(s) failed" while
the IPv4 mirror engine-passes) so it is parked in
knownSSHTesterDivergences for a follow-up.
2026-05-13 12:28:37 +00:00
Kristoffer Dalby 2865926028 policy/v2: use Alias and SSHUser types in SSHPolicyTest 2026-05-13 12:28:37 +00:00
Kristoffer Dalby b94936e129 CHANGELOG: rewrite sshTests entry to match policy tests style 2026-05-13 12:28:37 +00:00
Kristoffer Dalby c0a087461e policy/v2: drop useless comment on checkPeriod parse 2026-05-13 12:28:37 +00:00
Kristoffer Dalby 2b61b26772 policy/v2: match SaaS wording for group nesting rejection
SaaS rejects any group-in-group reference (chain, cycle, or
self-reference) with `groups["X"]: "Y": group members cannot be
recursive`. headscale rejected the same input but the message
surfaced as a generic JSON parse error from the unmarshal layer.
Groups.UnmarshalJSON now scans the raw map in descending
alphabetical order and reports the first group whose member is
itself a group, mirroring the (X, Y) pair SaaS picks (deepest
non-leaf parent first). Drop the now-unused ErrNestedGroups
sentinel and update the existing group-in-group test row plus add
chain, cycle, and self-cycle rows.
2026-05-13 12:28:37 +00:00
Kristoffer Dalby 079dca8924 policy/v2: reject non-ASCII tag names
SaaS rejects tag names whose first character after `tag:` is not an
ASCII letter (`[a-zA-Z]`) with `tagOwners["tag:X"]: tag names must
start with a letter, after 'tag:'`. headscale was accepting any
UTF-8. Tighten Tag.Validate to enforce the first-character rule and
reshape the surfaced error in unmarshalPolicy so the tagOwners key
appears in the SaaS-style prefix. Subsequent characters remain
unconstrained — only the leading byte is checked.
2026-05-13 12:28:37 +00:00
Kristoffer Dalby a79fb20372 policy/v2: reject hosts-table aliases as SSH dst
SaaS rejects any hosts-table alias on an SSH rule dst with
`invalid dst "alias"`, irrespective of whether the alias resolves
to a single IP or a CIDR. headscale was resolving the alias through
the same path as ACL dsts and accepting the policy. Validate at the
per-SSH-rule pass so the error body matches SaaS. The existing
host-alias-as-dst sshtest_test row tested the now-rejected shape
and is dropped along with its unused commonHosts fixture.
2026-05-13 12:28:36 +00:00
Kristoffer Dalby aea64b34de policy/v2: tolerate tag-owner cycles by resolving to empty
SaaS accepts tagOwners with circular references (tag:a -> tag:b ->
tag:a, tag:a -> tag:a) by dropping the cycle edge instead of failing
the policy. Each tag whose only path back is via the cycle resolves
to the empty owner set; a sibling non-tag owner survives. headscale
previously rejected the policy with ErrCircularReference at parse
time. Drop the sentinel, change flattenTags to return an empty owner
list on revisit, and update flattenTagOwners tests to capture the
new behaviour including the sibling-survives edge case.
2026-05-13 12:28:36 +00:00
Kristoffer Dalby 9f362d5be9 policy/v2: trim whitespace in SSH src and dst aliases
SaaS trims surrounding whitespace from src/dst entries before
dispatching to the alias lookup, so `"tag:server "` resolves to the
same tag and `" odin@example.com"` resolves to the same user.
headscale was treating the untrimmed strings as literals, which
either failed the tag/user lookup or dropped the affected node from
every rule referencing it. Trim inside AliasEnc.UnmarshalJSON so
ACL src/dst and SSH src/dst all benefit.
2026-05-13 12:28:36 +00:00
Kristoffer Dalby 76ba2de85a policy/v2: add ssh-edges captures from Tailscale SaaS
30 new captures recorded against SaaS covering multi-rule SSH
policies, group nesting, tag-owner cycles, empty references, unicode,
hosts table aliases, multi-src/dst, whitespace in src/dst, null and
missing fields, and sshTests edges. 20 captures already match
headscale behaviour. The remaining 10 land in sshSkipReasons and
sshRejectSkipReasons tracking 5 engine divergences (whitespace trim
in src/dst, tag-owner cycle tolerance, hosts-alias rejection on SSH
dst, non-ASCII tag-name rejection, group-nesting error wording);
follow-up commits close each gap and drain the corresponding skip
entries.
2026-05-13 12:28:36 +00:00
Kristoffer Dalby f8aa6c46ef policy/v2: trim whitespace and reject negative checkPeriod
SaaS trims leading whitespace in action and per-user entries before
matching, so headscale does too. Reject negative checkPeriod with
"must be a positive duration" matching SaaS body. The 168h upper
bound is inclusive.
2026-05-13 12:28:36 +00:00
Kristoffer Dalby 2180380fc1 policy/v2: align SSH rule validation with Tailscale SaaS
Drop autogroup/localpart strictness on SSH users. Drop checkPeriod
minimum. Reject empty/wildcard users and empty acceptEnv at parse.
Match SaaS wording for action and checkPeriod errors. 28 captures
under testdata/ssh_results/ssh-malformed-*.hujson lock the surface.
2026-05-13 12:28:36 +00:00
Kristoffer Dalby 9026f810fe policy/v2: branch tailscale_ssh_data_compat_test on APIResponseCode
Rejection captures (APIResponseCode != 200) now route through
NewPolicyManager + SetPolicy mirroring sshtester_compat_test.go; the
SaaS Message must be a substring of headscale's error. The accepted
path (200) keeps the existing per-node SSHRules comparison. Adds 28
ssh-malformed-* captures and a parallel sshRejectSkipReasons map for
4xx scenarios where headscale and SaaS legitimately disagree.
2026-05-13 12:28:13 +00:00
Kristoffer Dalby 4c4cebdc29 cmd/headscale/cli: mention sshTests in policy check help 2026-05-13 12:27:32 +00:00
Kristoffer Dalby 3eff2d5d0f policy/v2: address review on sshTests engine
Empty-dst-nodes fails loudly. Compat runner uses per-capture topology
so capture IPs match. Test rows tightened; stale godoc and goto fixed.
2026-05-13 12:27:32 +00:00
Kristoffer Dalby 48900f6c1a CHANGELOG: document sshTests evaluation (beta) 2026-05-13 12:27:32 +00:00
Kristoffer Dalby 1d477f4b8b integration: regenerate workflow for sshTests integration test 2026-05-13 12:27:32 +00:00
Kristoffer Dalby 4b79b03858 integration: reject failing sshTests at headscale policy set 2026-05-13 12:27:32 +00:00
Kristoffer Dalby 9e15565056 policy/v2: add sshtester captures from Tailscale SaaS
32 captures. knownSSHTesterDivergences tracks sshtest-user-wildcard
where SaaS rejects users:["*"] at parse and headscale does not.
2026-05-13 12:27:32 +00:00
Kristoffer Dalby 6f93e3b010 policy/v2: add sshtester compat runner
Replays captures under testdata/sshtest_results. 200 path requires
parse and SetPolicy to succeed; non-200 requires an error whose body
substring-matches the captured SaaS message.
2026-05-13 12:27:32 +00:00
Kristoffer Dalby 59755d496d policy/v2: evaluate sshTests at write boundary
accept and check count as reachable; check requires a check-action rule
specifically. SetPolicy rejects failing tests without mutating live
state; boot path warns.
2026-05-13 12:27:32 +00:00
Kristoffer Dalby 9205b02044 policy/v2: validate sshTests at parse
Reject empty src/dst, port-suffix dst, CIDR dst, autogroup:internet dst,
unknown-tag dst. Each error wraps errSSHPolicyTestsFailed for errors.Is.
2026-05-13 12:27:32 +00:00
206 changed files with 3589 additions and 64983 deletions
@@ -14,7 +14,7 @@ import (
// Key is the test function name, value is a list of subtest prefixes.
// Each prefix becomes a separate CI job as "TestName/prefix".
//
// Example: [TestAutoApproveMultiNetwork] has subtests like:
// Example: TestAutoApproveMultiNetwork has subtests like:
// - TestAutoApproveMultiNetwork/authkey-tag-advertiseduringup-false-pol-database
// - TestAutoApproveMultiNetwork/webauth-user-advertiseduringup-true-pol-file
//
+15 -24
View File
@@ -51,11 +51,6 @@ jobs:
with:
name: tailscale-head-image
path: /tmp/artifacts
- name: Download tailscale released images
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: tailscale-released-images
path: /tmp/artifacts
- name: Download hi binary
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
@@ -72,26 +67,28 @@ jobs:
with:
name: postgres-image
path: /tmp/artifacts
- name: Force overlay2 storage driver
- name: Pin Docker to v28 (avoid v29 breaking changes)
run: |
sudo mkdir -p /etc/docker
echo '{"storage-driver":"overlay2"}' | sudo tee /etc/docker/daemon.json
# Docker 29 breaks docker build via Go client libraries and
# docker load/save with certain tarball formats.
# Pin to Docker 28.x until our tooling is updated.
# https://github.com/actions/runner-images/issues/13474
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
| sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
| sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update -qq
VERSION=$(apt-cache madison docker-ce | grep '28\.5' | head -1 | awk '{print $3}')
sudo apt-get install -y --allow-downgrades \
"docker-ce=${VERSION}" "docker-ce-cli=${VERSION}"
sudo systemctl restart docker
docker version
- name: Login to Docker Hub
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_CI_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_CI_TOKEN }}
if: env.DOCKERHUB_USERNAME != ''
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
username: ${{ env.DOCKERHUB_USERNAME }}
password: ${{ env.DOCKERHUB_TOKEN }}
- name: Load Docker images, Go cache, and prepare binary
run: |
gunzip -c /tmp/artifacts/headscale-image.tar.gz | docker load
gunzip -c /tmp/artifacts/tailscale-head-image.tar.gz | docker load
gunzip -c /tmp/artifacts/tailscale-released-images.tar.gz | docker load
if [ -f /tmp/artifacts/postgres-image.tar.gz ]; then
gunzip -c /tmp/artifacts/postgres-image.tar.gz | docker load
fi
@@ -108,12 +105,6 @@ jobs:
HEADSCALE_INTEGRATION_POSTGRES_IMAGE: ${{ inputs.postgres_flag == '--postgres=1' && format('postgres:{0}', github.sha) || '' }}
HEADSCALE_INTEGRATION_GO_CACHE: /tmp/go-cache/go
HEADSCALE_INTEGRATION_GO_BUILD_CACHE: /tmp/go-cache/.cache/go-build
# Mirror the docker/login-action secrets into env so the
# dockertestutil.Credentials resolver picks them up directly
# (otherwise it falls back to parsing ~/.docker/config.json,
# which works but is one step further from the source).
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_CI_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_CI_TOKEN }}
run: /tmp/artifacts/hi run --stats --ts-memory-limit=300 --hs-memory-limit=1500 "^${{ inputs.test }}$" \
--timeout=120m \
${{ inputs.postgres_flag }}
+31 -94
View File
@@ -9,9 +9,6 @@ concurrency:
jobs:
# build: Builds binaries and Docker images once, uploads as artifacts for reuse.
# build-postgres: Pulls postgres image separately to avoid Docker Hub rate limits.
# build-tailscale-released: Pre-pulls released Tailscale images from ghcr.io
# so fork PRs (no DOCKERHUB_USERNAME secret) don't hit Docker Hub rate
# limits at test time.
# sqlite: Runs all integration tests with SQLite backend.
# postgres: Runs a subset of tests with PostgreSQL to verify database compatibility.
build:
@@ -72,26 +69,25 @@ jobs:
name: go-cache
path: go-cache.tar.gz
retention-days: 10
- name: Force overlay2 storage driver
- name: Pin Docker to v28 (avoid v29 breaking changes)
if: steps.changed-files.outputs.files == 'true'
run: |
# Docker 29 runner images default to overlayfs, which breaks
# docker build via Go SDK libraries and docker save/load
# tarball formats. overlay2 is the long-standing default.
# Docker 29 breaks docker build via Go client libraries and
# docker load/save with certain tarball formats.
# Pin to Docker 28.x until our tooling is updated.
# https://github.com/actions/runner-images/issues/13474
sudo mkdir -p /etc/docker
echo '{"storage-driver":"overlay2"}' | sudo tee /etc/docker/daemon.json
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
| sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
| sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update -qq
VERSION=$(apt-cache madison docker-ce | grep '28\.5' | head -1 | awk '{print $3}')
sudo apt-get install -y --allow-downgrades \
"docker-ce=${VERSION}" "docker-ce-cli=${VERSION}"
sudo systemctl restart docker
docker version
- name: Login to Docker Hub
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_CI_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_CI_TOKEN }}
if: env.DOCKERHUB_USERNAME != ''
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
username: ${{ env.DOCKERHUB_USERNAME }}
password: ${{ env.DOCKERHUB_TOKEN }}
- name: Build headscale image
if: steps.changed-files.outputs.files == 'true'
run: |
@@ -127,21 +123,24 @@ jobs:
needs: build
if: needs.build.outputs.files-changed == 'true'
steps:
- name: Force overlay2 storage driver
- name: Pin Docker to v28 (avoid v29 breaking changes)
run: |
sudo mkdir -p /etc/docker
echo '{"storage-driver":"overlay2"}' | sudo tee /etc/docker/daemon.json
# Docker 29 breaks docker build via Go client libraries and
# docker load/save with certain tarball formats.
# Pin to Docker 28.x until our tooling is updated.
# https://github.com/actions/runner-images/issues/13474
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
| sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
| sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update -qq
VERSION=$(apt-cache madison docker-ce | grep '28\.5' | head -1 | awk '{print $3}')
sudo apt-get install -y --allow-downgrades \
"docker-ce=${VERSION}" "docker-ce-cli=${VERSION}"
sudo systemctl restart docker
docker version
- name: Login to Docker Hub
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_CI_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_CI_TOKEN }}
if: env.DOCKERHUB_USERNAME != ''
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
username: ${{ env.DOCKERHUB_USERNAME }}
password: ${{ env.DOCKERHUB_TOKEN }}
- name: Pull and save postgres image
run: |
docker pull postgres:latest
@@ -153,70 +152,8 @@ jobs:
name: postgres-image
path: postgres-image.tar.gz
retention-days: 10
build-tailscale-released:
runs-on: ubuntu-24.04-arm
needs: build
if: needs.build.outputs.files-changed == 'true'
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34
- uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3
with:
primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', '**/flake.lock') }}
restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }}
- name: Force overlay2 storage driver
run: |
sudo mkdir -p /etc/docker
echo '{"storage-driver":"overlay2"}' | sudo tee /etc/docker/daemon.json
sudo systemctl restart docker
docker version
- name: Login to Docker Hub
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_CI_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_CI_TOKEN }}
if: env.DOCKERHUB_USERNAME != ''
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
username: ${{ env.DOCKERHUB_USERNAME }}
password: ${{ env.DOCKERHUB_TOKEN }}
- name: List Tailscale versions to pre-pull
id: versions
run: |
versions=$(nix develop --command go run ./cmd/hi list-versions --set=must --exclude=head)
echo "versions=${versions}" >> "$GITHUB_OUTPUT"
echo "Pre-pulling: ${versions}"
- name: Pull Tailscale images
run: |
# Releases come from ghcr.io (anonymous, unmetered). The
# "unstable" floating tag on ghcr.io has been stale since 2022,
# so it still needs to come from Docker Hub. xargs -P 0 fans
# out one process per tag and returns non-zero if any pull
# fails.
refs=""
for v in ${{ steps.versions.outputs.versions }}; do
if [ "${v}" = "unstable" ]; then
refs="${refs} tailscale/tailscale:${v}"
else
refs="${refs} ghcr.io/tailscale/tailscale:${v}"
fi
done
echo "${refs}" | tr ' ' '\n' | grep -v '^$' \
| xargs -P 0 -I{} docker pull "{}"
echo "REFS=${refs}" >> "$GITHUB_ENV"
- name: Save Tailscale images to tarball
run: |
# Single docker save with all refs: one consistent snapshot, no
# parallel-daemon race.
docker save ${REFS} | gzip > tailscale-released-images.tar.gz
ls -lh tailscale-released-images.tar.gz
- name: Upload Tailscale released images
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: tailscale-released-images
path: tailscale-released-images.tar.gz
retention-days: 10
sqlite:
needs: [build, build-tailscale-released]
needs: build
if: needs.build.outputs.files-changed == 'true'
strategy:
fail-fast: false
@@ -374,7 +311,7 @@ jobs:
postgres_flag: "--postgres=0"
database_name: "sqlite"
postgres:
needs: [build, build-postgres, build-tailscale-released]
needs: [build, build-postgres]
if: needs.build.outputs.files-changed == 'true'
strategy:
fail-fast: false
-12
View File
@@ -13,7 +13,6 @@ linters:
- gochecknoinits
- gocognit
- godox
- gomodguard
- interfacebloat
- ireturn
- lll
@@ -31,17 +30,6 @@ linters:
- wrapcheck
- wsl
settings:
goconst:
# Test fixtures repeat strings (IPs, tags, hostnames) by their
# nature; extracting them obscures the test rather than helping.
# Production code stays strict.
ignore-tests: true
# Default is 3. Bump so "happens thrice" cases that are not part
# of a shared vocabulary do not get extracted.
min-occurrences: 5
# Default is 3. Short literals ("set", "get", "new") read better
# at call sites than behind a named constant.
min-len: 6
forbidigo:
forbid:
# Forbid time.Sleep everywhere with context-appropriate alternatives
+69 -49
View File
@@ -2,7 +2,7 @@
## 0.29.0 (202x-xx-xx)
**Minimum supported Tailscale client version: v1.80.0**
**Minimum supported Tailscale client version: v1.76.0**
### Tailscale ACL compatibility improvements
@@ -46,31 +46,36 @@ This feature is **beta** while behavioural coverage against Tailscale SaaS broad
### SSH policy tests (beta)
Headscale now evaluates the `sshTests` block in a policy file. Each entry names a source, one or
more destination hosts, and three optional user lists: `accept` asserts the listed login users
reach every destination via an accept- or check-action SSH rule, `deny` asserts none of them
reach any destination, and `check` requires reachability specifically through a check-action
rule. Tests 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.
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.
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.
[#3263](https://github.com/juanfont/headscale/pull/3263)
### SSH rule validation
SSH rule parsing now trims surrounding whitespace on `action`, `users`, `src`, and `dst`,
rejects empty or wildcard entries in `users`, rejects empty `acceptEnv`, and rejects negative
`checkPeriod`. `hosts:` aliases are rejected as SSH destinations, non-ASCII tag names are
rejected at parse time, and the wording for group-nesting cycles matches Tailscale SaaS.
[#3263](https://github.com/juanfont/headscale/pull/3263)
### Grants
We now support [Tailscale grants](https://tailscale.com/docs/features/access-control/grants)
@@ -80,9 +85,9 @@ field steers traffic through specific tagged subnet routers or exit nodes. The `
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
As part of this, we added `autogroup:danger-all`. It resolves to `0.0.0.0/0` and `::/0` all IP
addresses, including those outside the tailnet. This replaces the old behaviour where `*` matched
all IPs (see BREAKING below). The name is intentional: accepting traffic from the entire
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`)
@@ -106,7 +111,7 @@ 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
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
@@ -153,9 +158,28 @@ mode:
A wildcard `nodeAttrs` (`"target": ["*"]`) hands the caps to every
node when fine-grained control is not needed.
### Hostname sanitisation
### Hostname handling (cleanroom rewrite)
Hostnames are now santised using Tailscales `magicdns` sanitisation rules, matching Tailscale SaaS behavior. This means that hostnames with non-ASCII characters, special characters, or reserved DNS label characters are now transformed into valid DNS labels for MagicDNS. This improves our previously too strict sanitisation that rejected hostnames based on our guesswork and not based on the Tailscale upstream behaviour.
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:
@@ -168,24 +192,11 @@ Examples that previously regressed and now work:
| `My-PC!` | `My-PC!` | `my-pc` |
| `我的电脑` | `我的电脑` | `node` |
[#3202](https://github.com/juanfont/headscale/pull/3202)
### 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.
[#3194](https://github.com/juanfont/headscale/pull/3194)
### 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. [#3202](https://github.com/juanfont/headscale/pull/3202)
- 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
@@ -194,8 +205,8 @@ connected" routers that maintain their control session but cannot route packets.
- 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)
- Policies using tags, hosts, or IP addresses as sources for `autogroup:self` destinations will now fail validation
- 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
@@ -211,9 +222,10 @@ connected" routers that maintain their control session but cannot route packets.
- 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. [#3251](https://github.com/juanfont/headscale/pull/3251)
`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`:
policy file referenced by `policy.path` (defaults to
`/etc/headscale/policy.hujson`):
```jsonc
{
@@ -233,6 +245,16 @@ connected" routers that maintain their control session but cannot route packets.
- `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
@@ -273,7 +295,7 @@ connected" routers that maintain their control session but cannot route packets.
- `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)
- 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)
- **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
@@ -293,7 +315,6 @@ connected" routers that maintain their control session but cannot route packets.
- 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`
- Add `trusted_proxies` to gate `True-Client-IP` / `X-Real-IP` / `X-Forwarded-For` (previously honoured from any client) [#3268](https://github.com/juanfont/headscale/pull/3268)
#### Debug
@@ -305,9 +326,8 @@ connected" routers that maintain their control session but cannot route packets.
- 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)
- 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)
- Fix `tailscaled` restart on a node with no expiry resetting `NULL` to `0001-01-01 00:00:00` in the database, affecting both tagged and untagged nodes [#3197](https://github.com/juanfont/headscale/pull/3197)
- Backfill `nodes.expiry` rows persisted by older versions as `0001-01-01 00:00:00` to `NULL`, so nodes upgraded from <0.28 stop reporting as expired [#3284](https://github.com/juanfont/headscale/issues/3284)
- **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)
+1 -1
View File
@@ -12,7 +12,7 @@ WORKDIR /go/src/tailscale
ARG TARGETARCH
RUN GOARCH=$TARGETARCH go install -v ./cmd/derper
FROM alpine:3.23
FROM alpine:3.22
RUN apk add --no-cache ca-certificates iptables iproute2 ip6tables curl
COPY --from=build-env /go/bin/* /usr/local/bin/
+1 -1
View File
@@ -2,7 +2,7 @@
# and are in no way endorsed by Headscale's maintainers as an
# official nor supported release or distribution.
FROM docker.io/golang:1.26.3-trixie AS builder
FROM docker.io/golang:1.26.2-trixie AS builder
ARG VERSION=dev
ENV GOPATH /go
WORKDIR /go/src/headscale
+1 -1
View File
@@ -36,7 +36,7 @@ RUN GOARCH=$TARGETARCH go install -tags="${BUILD_TAGS}" -ldflags="\
-X tailscale.com/version.gitCommitStamp=$VERSION_GIT_HASH" \
-v ./cmd/tailscale ./cmd/tailscaled ./cmd/containerboot
FROM alpine:3.23
FROM alpine:3.22
# Upstream: ca-certificates ip6tables iptables iproute2
# Tests: curl python3 (traceroute via BusyBox)
RUN apk add --no-cache ca-certificates curl ip6tables iptables iproute2 python3
+2 -2
View File
@@ -1,4 +1,4 @@
FROM rust:1.95-trixie AS builder
FROM rust:1.94-bookworm AS builder
ARG TAILSCALE_RS_REPO=https://github.com/tailscale/tailscale-rs.git
ARG TAILSCALE_RS_REF=main
@@ -15,7 +15,7 @@ RUN sed -i '/^axum = \["dep:axum"\]/a insecure-keyfetch = ["ts_control/insecure-
RUN cargo build --release --features axum,insecure-keyfetch --example axum
FROM debian:trixie-slim
FROM debian:bookworm-slim
RUN apt-get update && \
apt-get install -y --no-install-recommends \
+10 -9
View File
@@ -41,9 +41,9 @@ var apiKeysCmd = &cobra.Command{
}
var listAPIKeys = &cobra.Command{
Use: cmdList,
Use: "list",
Short: "List the Api keys for headscale",
Aliases: []string{"ls", cmdShow},
Aliases: []string{"ls", "show"},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
response, err := client.ListApiKeys(ctx, &v1.ListApiKeysRequest{})
if err != nil {
@@ -51,8 +51,9 @@ var listAPIKeys = &cobra.Command{
}
return printListOutput(cmd, response.GetApiKeys(), func() error {
tableData := make(pterm.TableData, 1, 1+len(response.GetApiKeys()))
tableData[0] = []string{"ID", "Prefix", colExpiration, colCreated}
tableData := pterm.TableData{
{"ID", "Prefix", "Expiration", "Created"},
}
for _, key := range response.GetApiKeys() {
expiration := "-"
@@ -81,7 +82,7 @@ var createAPIKeyCmd = &cobra.Command{
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.`,
Aliases: []string{"c", cmdNew},
Aliases: []string{"c", "new"},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
expiration, err := expirationFromFlag(cmd)
if err != nil {
@@ -116,9 +117,9 @@ func apiKeyIDOrPrefix(cmd *cobra.Command) (uint64, string, error) {
}
var expireAPIKeyCmd = &cobra.Command{
Use: cmdExpire,
Use: "expire",
Short: "Expire an ApiKey",
Aliases: []string{"revoke", aliasExp, "e"},
Aliases: []string{"revoke", "exp", "e"},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
id, prefix, err := apiKeyIDOrPrefix(cmd)
if err != nil {
@@ -138,9 +139,9 @@ var expireAPIKeyCmd = &cobra.Command{
}
var deleteAPIKeyCmd = &cobra.Command{
Use: cmdDelete,
Use: "delete",
Short: "Delete an ApiKey",
Aliases: []string{"remove", aliasDel},
Aliases: []string{"remove", "del"},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
id, prefix, err := apiKeyIDOrPrefix(cmd)
if err != nil {
+16 -15
View File
@@ -89,9 +89,9 @@ var registerNodeCmd = &cobra.Command{
}
var listNodesCmd = &cobra.Command{
Use: cmdList,
Use: "list",
Short: "List nodes",
Aliases: []string{"ls", cmdShow},
Aliases: []string{"ls", "show"},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
user, _ := cmd.Flags().GetString("user")
@@ -101,7 +101,7 @@ var listNodesCmd = &cobra.Command{
}
return printListOutput(cmd, response.GetNodes(), func() error {
tableData, err := nodesToPtables(response.GetNodes())
tableData, err := nodesToPtables(user, response.GetNodes())
if err != nil {
return fmt.Errorf("converting to table: %w", err)
}
@@ -145,12 +145,12 @@ var listNodeRoutesCmd = &cobra.Command{
}
var expireNodeCmd = &cobra.Command{
Use: cmdExpire,
Use: "expire",
Short: "Expire (log out) a node in your network",
Long: `Expiring a node will keep the node in the database and force it to reauthenticate.
Use --disable to disable key expiry (node will never expire).`,
Aliases: []string{"logout", aliasExp, "e"},
Aliases: []string{"logout", "exp", "e"},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
identifier, _ := cmd.Flags().GetUint64("identifier")
disableExpiry, _ := cmd.Flags().GetBool("disable")
@@ -229,9 +229,9 @@ var renameNodeCmd = &cobra.Command{
}
var deleteNodeCmd = &cobra.Command{
Use: cmdDelete,
Use: "delete",
Short: "Delete a node",
Aliases: []string{aliasDel},
Aliases: []string{"del"},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
identifier, _ := cmd.Flags().GetUint64("identifier")
@@ -252,7 +252,7 @@ var deleteNodeCmd = &cobra.Command{
"Do you want to remove the node %s?",
getResponse.GetNode().GetName(),
)) {
return printOutput(cmd, map[string]string{colResult: "Node not deleted"}, "Node not deleted")
return printOutput(cmd, map[string]string{"Result": "Node not deleted"}, "Node not deleted")
}
_, err = client.DeleteNode(ctx, deleteRequest)
@@ -262,7 +262,7 @@ var deleteNodeCmd = &cobra.Command{
return printOutput(
cmd,
map[string]string{colResult: "Node deleted"},
map[string]string{"Result": "Node deleted"},
"Node deleted",
)
}),
@@ -304,7 +304,10 @@ be assigned to nodes.`,
},
}
func nodesToPtables(nodes []*v1.Node) (pterm.TableData, error) {
func nodesToPtables(
currentUser string,
nodes []*v1.Node,
) (pterm.TableData, error) {
tableHeader := []string{
"ID",
"Hostname",
@@ -316,12 +319,11 @@ func nodesToPtables(nodes []*v1.Node) (pterm.TableData, error) {
"IP addresses",
"Ephemeral",
"Last seen",
colExpiration,
"Expiration",
"Connected",
"Expired",
}
tableData := make(pterm.TableData, 1, 1+len(nodes))
tableData[0] = tableHeader
tableData := pterm.TableData{tableHeader}
for _, node := range nodes {
var ephemeral bool
@@ -445,8 +447,7 @@ func nodeRoutesToPtables(
"Available",
"Serving (Primary)",
}
tableData := make(pterm.TableData, 1, 1+len(nodes))
tableData[0] = tableHeader
tableData := pterm.TableData{tableHeader}
for _, node := range nodes {
nodeData := []string{
+4 -4
View File
@@ -48,7 +48,7 @@ func init() {
policyCmd.AddCommand(setPolicy)
checkPolicy.Flags().StringP("file", "f", "", "Path to a policy file in HuJSON format")
checkPolicy.Flags().BoolP(bypassFlag, "", false, "Open the database directly (no gRPC, no running server) to resolve user references and to evaluate the policy's tests and sshTests blocks. Required when those checks are needed.")
checkPolicy.Flags().BoolP(bypassFlag, "", false, "Open the database directly (no gRPC, no running server) to validate user@ token references and to evaluate the policy's tests and sshTests blocks. Required when those checks are needed.")
mustMarkRequired(checkPolicy, "file")
policyCmd.AddCommand(checkPolicy)
}
@@ -61,7 +61,7 @@ var policyCmd = &cobra.Command{
var getPolicy = &cobra.Command{
Use: "get",
Short: "Print the current ACL Policy",
Aliases: []string{cmdShow, "view", "fetch"},
Aliases: []string{"show", "view", "fetch"},
RunE: func(cmd *cobra.Command, args []string) error {
var policyData string
@@ -205,9 +205,9 @@ var checkPolicy = &cobra.Command{
return fmt.Errorf("loading nodes: %w", err)
}
// [policy.NewPolicyManager] validates structure and user references
// NewPolicyManager validates structure and user references
// but intentionally skips test evaluation (boot path).
// [policy.PolicyManager.SetPolicy] is the user-write boundary and is what runs the
// 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 {
+18 -17
View File
@@ -42,9 +42,9 @@ var preauthkeysCmd = &cobra.Command{
}
var listPreAuthKeys = &cobra.Command{
Use: cmdList,
Use: "list",
Short: "List all preauthkeys",
Aliases: []string{"ls", cmdShow},
Aliases: []string{"ls", "show"},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
response, err := client.ListPreAuthKeys(ctx, &v1.ListPreAuthKeysRequest{})
if err != nil {
@@ -52,16 +52,17 @@ var listPreAuthKeys = &cobra.Command{
}
return printListOutput(cmd, response.GetPreAuthKeys(), func() error {
tableData := make(pterm.TableData, 1, 1+len(response.GetPreAuthKeys()))
tableData[0] = []string{
"ID",
"Key/Prefix",
"Reusable",
"Ephemeral",
"Used",
colExpiration,
colCreated,
"Owner",
tableData := pterm.TableData{
{
"ID",
"Key/Prefix",
"Reusable",
"Ephemeral",
"Used",
"Expiration",
"Created",
"Owner",
},
}
for _, key := range response.GetPreAuthKeys() {
@@ -99,7 +100,7 @@ var listPreAuthKeys = &cobra.Command{
var createPreAuthKeyCmd = &cobra.Command{
Use: "create",
Short: "Creates a new preauthkey",
Aliases: []string{"c", cmdNew},
Aliases: []string{"c", "new"},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
user, _ := cmd.Flags().GetUint64("user")
reusable, _ := cmd.Flags().GetBool("reusable")
@@ -129,9 +130,9 @@ var createPreAuthKeyCmd = &cobra.Command{
}
var expirePreAuthKeyCmd = &cobra.Command{
Use: cmdExpire,
Use: "expire",
Short: "Expire a preauthkey",
Aliases: []string{"revoke", aliasExp, "e"},
Aliases: []string{"revoke", "exp", "e"},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
id, _ := cmd.Flags().GetUint64("id")
@@ -153,9 +154,9 @@ var expirePreAuthKeyCmd = &cobra.Command{
}
var deletePreAuthKeyCmd = &cobra.Command{
Use: cmdDelete,
Use: "delete",
Short: "Delete a preauthkey",
Aliases: []string{aliasDel, "rm", "d"},
Aliases: []string{"del", "rm", "d"},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
id, _ := cmd.Flags().GetUint64("id")
+1 -1
View File
@@ -30,7 +30,7 @@ func init() {
Bool("force", false, "Disable prompts and forces the execution")
// Re-enable usage output only for flag-parsing errors; runtime errors
// from [cobra.Command.RunE] should never dump usage text.
// from RunE should never dump usage text.
rootCmd.SetFlagErrorFunc(func(cmd *cobra.Command, err error) error {
cmd.SilenceUsage = false
+44 -57
View File
@@ -4,19 +4,6 @@ import (
"testing"
)
const (
v23 = "0.23.0"
v23Alpha1 = "0.23.0-alpha.1"
v23Beta1 = "0.23.0-beta.1"
v23RC1 = "0.23.0-rc.1"
v23Dev = "0.23.0-dev"
v231 = "0.23.1"
v24Alpha1Tag = "v0.24.0-alpha.1"
v24RCTag = "v0.24.0-rc.1"
v24Tag = "v0.24.0"
)
func TestFilterPreReleasesIfStable(t *testing.T) {
tests := []struct {
name string
@@ -27,64 +14,64 @@ func TestFilterPreReleasesIfStable(t *testing.T) {
}{
{
name: "stable version filters alpha tag",
currentVersion: v23,
tag: v24Alpha1Tag,
currentVersion: "0.23.0",
tag: "v0.24.0-alpha.1",
expectedFilter: true,
description: "When on stable release, alpha tags should be filtered",
},
{
name: "stable version filters beta tag",
currentVersion: v23,
currentVersion: "0.23.0",
tag: "v0.24.0-beta.2",
expectedFilter: true,
description: "When on stable release, beta tags should be filtered",
},
{
name: "stable version filters rc tag",
currentVersion: v23,
tag: v24RCTag,
currentVersion: "0.23.0",
tag: "v0.24.0-rc.1",
expectedFilter: true,
description: "When on stable release, rc tags should be filtered",
},
{
name: "stable version allows stable tag",
currentVersion: v23,
tag: v24Tag,
currentVersion: "0.23.0",
tag: "v0.24.0",
expectedFilter: false,
description: "When on stable release, stable tags should not be filtered",
},
{
name: "alpha version allows alpha tag",
currentVersion: v23Alpha1,
currentVersion: "0.23.0-alpha.1",
tag: "v0.24.0-alpha.2",
expectedFilter: false,
description: "When on alpha release, alpha tags should not be filtered",
},
{
name: "alpha version allows beta tag",
currentVersion: v23Alpha1,
currentVersion: "0.23.0-alpha.1",
tag: "v0.24.0-beta.1",
expectedFilter: false,
description: "When on alpha release, beta tags should not be filtered",
},
{
name: "alpha version allows rc tag",
currentVersion: v23Alpha1,
tag: v24RCTag,
currentVersion: "0.23.0-alpha.1",
tag: "v0.24.0-rc.1",
expectedFilter: false,
description: "When on alpha release, rc tags should not be filtered",
},
{
name: "alpha version allows stable tag",
currentVersion: v23Alpha1,
tag: v24Tag,
currentVersion: "0.23.0-alpha.1",
tag: "v0.24.0",
expectedFilter: false,
description: "When on alpha release, stable tags should not be filtered",
},
{
name: "beta version allows alpha tag",
currentVersion: v23Beta1,
tag: v24Alpha1Tag,
currentVersion: "0.23.0-beta.1",
tag: "v0.24.0-alpha.1",
expectedFilter: false,
description: "When on beta release, alpha tags should not be filtered",
},
@@ -97,28 +84,28 @@ func TestFilterPreReleasesIfStable(t *testing.T) {
},
{
name: "beta version allows rc tag",
currentVersion: v23Beta1,
tag: v24RCTag,
currentVersion: "0.23.0-beta.1",
tag: "v0.24.0-rc.1",
expectedFilter: false,
description: "When on beta release, rc tags should not be filtered",
},
{
name: "beta version allows stable tag",
currentVersion: v23Beta1,
tag: v24Tag,
currentVersion: "0.23.0-beta.1",
tag: "v0.24.0",
expectedFilter: false,
description: "When on beta release, stable tags should not be filtered",
},
{
name: "rc version allows alpha tag",
currentVersion: v23RC1,
tag: v24Alpha1Tag,
currentVersion: "0.23.0-rc.1",
tag: "v0.24.0-alpha.1",
expectedFilter: false,
description: "When on rc release, alpha tags should not be filtered",
},
{
name: "rc version allows beta tag",
currentVersion: v23RC1,
currentVersion: "0.23.0-rc.1",
tag: "v0.24.0-beta.1",
expectedFilter: false,
description: "When on rc release, beta tags should not be filtered",
@@ -132,78 +119,78 @@ func TestFilterPreReleasesIfStable(t *testing.T) {
},
{
name: "rc version allows stable tag",
currentVersion: v23RC1,
tag: v24Tag,
currentVersion: "0.23.0-rc.1",
tag: "v0.24.0",
expectedFilter: false,
description: "When on rc release, stable tags should not be filtered",
},
{
name: "stable version with patch filters alpha",
currentVersion: v231,
tag: v24Alpha1Tag,
currentVersion: "0.23.1",
tag: "v0.24.0-alpha.1",
expectedFilter: true,
description: "Stable version with patch number should filter alpha tags",
},
{
name: "stable version with patch allows stable",
currentVersion: v231,
tag: v24Tag,
currentVersion: "0.23.1",
tag: "v0.24.0",
expectedFilter: false,
description: "Stable version with patch number should allow stable tags",
},
{
name: "tag with alpha substring in version number",
currentVersion: v23,
currentVersion: "0.23.0",
tag: "v1.0.0-alpha.1",
expectedFilter: true,
description: "Tags with alpha in version string should be filtered on stable",
},
{
name: "tag with beta substring in version number",
currentVersion: v23,
currentVersion: "0.23.0",
tag: "v1.0.0-beta.1",
expectedFilter: true,
description: "Tags with beta in version string should be filtered on stable",
},
{
name: "tag with rc substring in version number",
currentVersion: v23,
currentVersion: "0.23.0",
tag: "v1.0.0-rc.1",
expectedFilter: true,
description: "Tags with rc in version string should be filtered on stable",
},
{
name: "empty tag on stable version",
currentVersion: v23,
currentVersion: "0.23.0",
tag: "",
expectedFilter: false,
description: "Empty tags should not be filtered",
},
{
name: "dev version allows all tags",
currentVersion: v23Dev,
tag: v24Alpha1Tag,
currentVersion: "0.23.0-dev",
tag: "v0.24.0-alpha.1",
expectedFilter: false,
description: "Dev versions should not filter any tags (pre-release allows all)",
},
{
name: "stable version filters dev tag",
currentVersion: v23,
currentVersion: "0.23.0",
tag: "v0.24.0-dev",
expectedFilter: true,
description: "When on stable release, dev tags should be filtered",
},
{
name: "dev version allows dev tag",
currentVersion: v23Dev,
currentVersion: "0.23.0-dev",
tag: "v0.24.0-dev.1",
expectedFilter: false,
description: "When on dev release, dev tags should not be filtered",
},
{
name: "dev version allows stable tag",
currentVersion: v23Dev,
tag: v24Tag,
currentVersion: "0.23.0-dev",
tag: "v0.24.0",
expectedFilter: false,
description: "When on dev release, stable tags should not be filtered",
},
@@ -235,25 +222,25 @@ func TestIsPreReleaseVersion(t *testing.T) {
}{
{
name: "stable version",
version: v23,
version: "0.23.0",
expected: false,
description: "Stable version should not be pre-release",
},
{
name: "alpha version",
version: v23Alpha1,
version: "0.23.0-alpha.1",
expected: true,
description: "Alpha version should be pre-release",
},
{
name: "beta version",
version: v23Beta1,
version: "0.23.0-beta.1",
expected: true,
description: "Beta version should be pre-release",
},
{
name: "rc version",
version: v23RC1,
version: "0.23.0-rc.1",
expected: true,
description: "RC version should be pre-release",
},
@@ -271,7 +258,7 @@ func TestIsPreReleaseVersion(t *testing.T) {
},
{
name: "dev version",
version: v23Dev,
version: "0.23.0-dev",
expected: true,
description: "Dev version should be pre-release",
},
@@ -283,7 +270,7 @@ func TestIsPreReleaseVersion(t *testing.T) {
},
{
name: "version with patch number",
version: v231,
version: "0.23.1",
expected: false,
description: "Stable version with patch should not be pre-release",
},
-23
View File
@@ -1,23 +0,0 @@
package cli
// Shared CLI vocabulary used across multiple command definitions in this
// package. Centralising the strings prevents goconst drift and ensures a
// typo in a subcommand name fails to compile rather than silently
// breaking the binding.
const (
// Subcommand verbs (cobra Use field).
cmdList = "list"
cmdShow = "show"
cmdNew = "new"
cmdDelete = "delete"
cmdExpire = "expire"
// Subcommand aliases.
aliasDel = "del"
aliasExp = "exp"
// Output table column headers and printOutput map keys.
colResult = "Result"
colCreated = "Created"
colExpiration = "Expiration"
)
+6 -8
View File
@@ -70,7 +70,7 @@ var userCmd = &cobra.Command{
var createUserCmd = &cobra.Command{
Use: "create NAME",
Short: "Creates a new user",
Aliases: []string{"c", cmdNew},
Aliases: []string{"c", "new"},
Args: func(cmd *cobra.Command, args []string) error {
if len(args) < 1 {
return errMissingParameter
@@ -115,7 +115,7 @@ var createUserCmd = &cobra.Command{
var destroyUserCmd = &cobra.Command{
Use: "destroy --identifier ID or --name NAME",
Short: "Destroys a user",
Aliases: []string{cmdDelete},
Aliases: []string{"delete"},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
id, username, err := usernameAndIDFromFlag(cmd)
if err != nil {
@@ -142,7 +142,7 @@ var destroyUserCmd = &cobra.Command{
"Do you want to remove the user %q (%d) and any associated preauthkeys?",
user.GetName(), user.GetId(),
)) {
return printOutput(cmd, map[string]string{colResult: "User not destroyed"}, "User not destroyed")
return printOutput(cmd, map[string]string{"Result": "User not destroyed"}, "User not destroyed")
}
deleteRequest := &v1.DeleteUserRequest{Id: user.GetId()}
@@ -157,9 +157,9 @@ var destroyUserCmd = &cobra.Command{
}
var listUsersCmd = &cobra.Command{
Use: cmdList,
Use: "list",
Short: "List all the users",
Aliases: []string{"ls", cmdShow},
Aliases: []string{"ls", "show"},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
request := &v1.ListUsersRequest{}
@@ -183,9 +183,7 @@ var listUsersCmd = &cobra.Command{
}
return printListOutput(cmd, response.GetUsers(), func() error {
tableData := make(pterm.TableData, 1, 1+len(response.GetUsers()))
tableData[0] = []string{"ID", "Name", "Username", "Email", colCreated}
tableData := pterm.TableData{{"ID", "Name", "Username", "Email", "Created"}}
for _, user := range response.GetUsers() {
tableData = append(
tableData,
+7 -7
View File
@@ -67,9 +67,9 @@ func newHeadscaleServerWithConfig() (*hscontrol.Headscale, error) {
return app, nil
}
// grpcRunE wraps a cobra [cobra.Command.RunE] func, injecting a ready
// gRPC client and context. Connection lifecycle is managed by the
// wrapper — callers never see the underlying conn or cancel func.
// grpcRunE wraps a cobra RunE func, injecting a ready gRPC client and
// context. Connection lifecycle is managed by the wrapper — callers
// never see the underlying conn or cancel func.
func grpcRunE(
fn func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error,
) func(*cobra.Command, []string) error {
@@ -103,7 +103,7 @@ func newHeadscaleCLIWithConfig() (context.Context, v1.HeadscaleServiceClient, *g
address := cfg.CLI.Address
// If the address is not set, we assume that we are on the server hosting [hscontrol].
// If the address is not set, we assume that we are on the server hosting hscontrol.
if address == "" {
log.Debug().
Str("socket", cfg.UnixSocket).
@@ -112,9 +112,9 @@ func newHeadscaleCLIWithConfig() (context.Context, v1.HeadscaleServiceClient, *g
address = cfg.UnixSocket
// Try to give the user better feedback if we cannot write to the headscale
// socket. Note: [os.OpenFile] on a Unix domain socket returns ENXIO on
// socket. Note: os.OpenFile on a Unix domain socket returns ENXIO on
// Linux which is expected — only permission errors are actionable here.
// The actual gRPC connection uses [net.Dial] which handles sockets properly.
// The actual gRPC connection uses net.Dial which handles sockets properly.
socket, err := os.OpenFile(cfg.UnixSocket, os.O_WRONLY, SocketWritePermissions) //nolint
if err != nil {
if os.IsPermission(err) {
@@ -269,7 +269,7 @@ func printListOutput(
// printError writes err to stderr, formatting it as JSON/YAML when the
// --output flag requests machine-readable output. Used exclusively by
// [Execute] so that every error surfaces in the format the caller asked for.
// Execute() so that every error surfaces in the format the caller asked for.
func printError(err error, outputFormat string) {
type errOutput struct {
Error string `json:"error"`
+17 -48
View File
@@ -14,7 +14,6 @@ import (
"strings"
"time"
"github.com/cenkalti/backoff/v5"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/mount"
@@ -523,9 +522,9 @@ func checkImageAvailableLocally(ctx context.Context, cli *client.Client, imageNa
return true, nil
}
// ensureImageAvailable pulls imageName if missing, using Docker Hub
// credentials and retrying transient errors.
// ensureImageAvailable checks if the image is available locally first, then pulls if needed.
func ensureImageAvailable(ctx context.Context, cli *client.Client, imageName string, verbose bool) error {
// First check if image is available locally
available, err := checkImageAvailableLocally(ctx, cli, imageName)
if err != nil {
return fmt.Errorf("checking local image availability: %w", err)
@@ -539,64 +538,34 @@ func ensureImageAvailable(ctx context.Context, cli *client.Client, imageName str
return nil
}
// Image not available locally, try to pull it
if verbose {
log.Printf("Image %s not found locally, pulling...", imageName)
}
registryAuth, err := dockertestutil.RegistryAuth()
reader, err := cli.ImagePull(ctx, imageName, image.PullOptions{})
if err != nil {
return fmt.Errorf("resolving registry auth: %w", err)
return fmt.Errorf("pulling image %s: %w", imageName, err)
}
defer reader.Close()
_, err = backoff.Retry(
ctx,
func() (struct{}, error) {
reader, pullErr := cli.ImagePull(ctx, imageName, image.PullOptions{RegistryAuth: registryAuth})
if pullErr != nil {
if isPermanentDockerPullError(pullErr) {
return struct{}{}, backoff.Permanent(pullErr)
}
if verbose {
_, err = io.Copy(os.Stdout, reader)
if err != nil {
return fmt.Errorf("reading pull output: %w", err)
}
} else {
_, err = io.Copy(io.Discard, reader)
if err != nil {
return fmt.Errorf("reading pull output: %w", err)
}
return struct{}{}, fmt.Errorf("pulling image %s: %w", imageName, pullErr)
}
defer reader.Close()
sink := io.Discard
if verbose {
sink = os.Stdout
}
_, copyErr := io.Copy(sink, reader)
if copyErr != nil {
return struct{}{}, fmt.Errorf("reading pull output: %w", copyErr)
}
return struct{}{}, nil
},
backoff.WithBackOff(backoff.NewExponentialBackOff()),
backoff.WithMaxElapsedTime(60*time.Second),
)
if err != nil {
return err
}
if !verbose {
log.Printf("Image %s pulled successfully", imageName)
}
return nil
}
func isPermanentDockerPullError(err error) bool {
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "manifest unknown") ||
strings.Contains(msg, "manifest not found") ||
strings.Contains(msg, "repository does not exist") ||
strings.Contains(msg, "name unknown") ||
strings.Contains(msg, "no such image")
}
// listControlFiles displays the headscale test artifacts created in the control logs directory.
func listControlFiles(logsDir string) {
entries, err := os.ReadDir(logsDir)
@@ -830,7 +799,7 @@ func extractContainerLogs(ctx context.Context, cli *client.Client, containerID,
// extractContainerFiles extracts database file and directories from headscale containers.
// Note: The actual file extraction is now handled by the integration tests themselves
// via [SaveProfile], [SaveMapResponses], and [SaveDatabase] functions in hsic.go.
// via SaveProfile, SaveMapResponses, and SaveDatabase functions in hsic.go.
func extractContainerFiles(ctx context.Context, cli *client.Client, containerID, containerName, logsDir string, verbose bool) error {
// Files are now extracted directly by the integration tests
// This function is kept for potential future use or other file types
+45 -84
View File
@@ -7,20 +7,6 @@ import (
"log"
"os/exec"
"strings"
"github.com/juanfont/headscale/integration/dockertestutil"
)
const (
statusPass = "PASS"
statusFail = "FAIL"
statusWarn = "WARN"
nameDockerDaemon = "Docker Daemon"
nameDockerContext = "Docker Context"
nameDockerSocket = "Docker Socket"
nameGolangImage = "Golang Image"
nameGoInstall = "Go Installation"
)
var ErrSystemChecksFailed = errors.New("system checks failed")
@@ -45,10 +31,9 @@ func runDoctorCheck(ctx context.Context) error {
results = append(results, dockerResult)
// If Docker is available, run additional checks
if dockerResult.Status == statusPass {
if dockerResult.Status == "PASS" {
results = append(results, checkDockerContext(ctx))
results = append(results, checkDockerSocket(ctx))
results = append(results, checkDockerHubCredentials())
results = append(results, checkGolangImage(ctx))
}
@@ -66,7 +51,7 @@ func runDoctorCheck(ctx context.Context) error {
// Return error if any critical checks failed
for _, result := range results {
if result.Status == statusFail {
if result.Status == "FAIL" {
return fmt.Errorf("%w - see details above", ErrSystemChecksFailed)
}
}
@@ -82,7 +67,7 @@ func checkDockerBinary() DoctorResult {
if err != nil {
return DoctorResult{
Name: "Docker Binary",
Status: statusFail,
Status: "FAIL",
Message: "Docker binary not found in PATH",
Suggestions: []string{
"Install Docker: https://docs.docker.com/get-docker/",
@@ -94,7 +79,7 @@ func checkDockerBinary() DoctorResult {
return DoctorResult{
Name: "Docker Binary",
Status: statusPass,
Status: "PASS",
Message: "Docker binary found",
}
}
@@ -104,8 +89,8 @@ func checkDockerDaemon(ctx context.Context) DoctorResult {
cli, err := createDockerClient(ctx)
if err != nil {
return DoctorResult{
Name: nameDockerDaemon,
Status: statusFail,
Name: "Docker Daemon",
Status: "FAIL",
Message: fmt.Sprintf("Cannot create Docker client: %v", err),
Suggestions: []string{
"Start Docker daemon/service",
@@ -120,8 +105,8 @@ func checkDockerDaemon(ctx context.Context) DoctorResult {
_, err = cli.Ping(ctx)
if err != nil {
return DoctorResult{
Name: nameDockerDaemon,
Status: statusFail,
Name: "Docker Daemon",
Status: "FAIL",
Message: fmt.Sprintf("Cannot ping Docker daemon: %v", err),
Suggestions: []string{
"Ensure Docker daemon is running",
@@ -132,8 +117,8 @@ func checkDockerDaemon(ctx context.Context) DoctorResult {
}
return DoctorResult{
Name: nameDockerDaemon,
Status: statusPass,
Name: "Docker Daemon",
Status: "PASS",
Message: "Docker daemon is running and accessible",
}
}
@@ -143,8 +128,8 @@ func checkDockerContext(ctx context.Context) DoctorResult {
contextInfo, err := getCurrentDockerContext(ctx)
if err != nil {
return DoctorResult{
Name: nameDockerContext,
Status: statusWarn,
Name: "Docker Context",
Status: "WARN",
Message: "Could not detect Docker context, using default settings",
Suggestions: []string{
"Check: docker context ls",
@@ -155,15 +140,15 @@ func checkDockerContext(ctx context.Context) DoctorResult {
if contextInfo == nil {
return DoctorResult{
Name: nameDockerContext,
Status: statusPass,
Name: "Docker Context",
Status: "PASS",
Message: "Using default Docker context",
}
}
return DoctorResult{
Name: nameDockerContext,
Status: statusPass,
Name: "Docker Context",
Status: "PASS",
Message: "Using Docker context: " + contextInfo.Name,
}
}
@@ -173,8 +158,8 @@ func checkDockerSocket(ctx context.Context) DoctorResult {
cli, err := createDockerClient(ctx)
if err != nil {
return DoctorResult{
Name: nameDockerSocket,
Status: statusFail,
Name: "Docker Socket",
Status: "FAIL",
Message: fmt.Sprintf("Cannot access Docker socket: %v", err),
Suggestions: []string{
"Check Docker socket permissions",
@@ -188,8 +173,8 @@ func checkDockerSocket(ctx context.Context) DoctorResult {
info, err := cli.Info(ctx)
if err != nil {
return DoctorResult{
Name: nameDockerSocket,
Status: statusFail,
Name: "Docker Socket",
Status: "FAIL",
Message: fmt.Sprintf("Cannot get Docker info: %v", err),
Suggestions: []string{
"Check Docker daemon status",
@@ -199,33 +184,9 @@ func checkDockerSocket(ctx context.Context) DoctorResult {
}
return DoctorResult{
Name: nameDockerSocket,
Status: statusPass,
Message: fmt.Sprintf("Docker socket accessible (Server: %s)", info.ServerVersion),
}
}
// checkDockerHubCredentials warns when pulls would be anonymous and
// therefore rate-limited.
func checkDockerHubCredentials() DoctorResult {
_, _, source := dockertestutil.Credentials()
if source == dockertestutil.CredentialSourceAnonymous {
return DoctorResult{
Name: "Docker Hub Credentials",
Status: "WARN",
Message: "No Docker Hub credentials found — pulls will be rate-limited (100/6h per IP)",
Suggestions: []string{
"Run: docker login",
"Or export DOCKERHUB_USERNAME and DOCKERHUB_TOKEN",
"In CI: ensure the docker/login-action step is configured with secrets",
},
}
}
return DoctorResult{
Name: "Docker Hub Credentials",
Name: "Docker Socket",
Status: "PASS",
Message: fmt.Sprintf("Credentials available (source: %s)", source),
Message: fmt.Sprintf("Docker socket accessible (Server: %s)", info.ServerVersion),
}
}
@@ -234,8 +195,8 @@ func checkGolangImage(ctx context.Context) DoctorResult {
cli, err := createDockerClient(ctx)
if err != nil {
return DoctorResult{
Name: nameGolangImage,
Status: statusFail,
Name: "Golang Image",
Status: "FAIL",
Message: "Cannot create Docker client for image check",
}
}
@@ -248,8 +209,8 @@ func checkGolangImage(ctx context.Context) DoctorResult {
available, err := checkImageAvailableLocally(ctx, cli, imageName)
if err != nil {
return DoctorResult{
Name: nameGolangImage,
Status: statusFail,
Name: "Golang Image",
Status: "FAIL",
Message: fmt.Sprintf("Cannot check golang image %s: %v", imageName, err),
Suggestions: []string{
"Check Docker daemon status",
@@ -260,8 +221,8 @@ func checkGolangImage(ctx context.Context) DoctorResult {
if available {
return DoctorResult{
Name: nameGolangImage,
Status: statusPass,
Name: "Golang Image",
Status: "PASS",
Message: fmt.Sprintf("Golang image %s is available locally", imageName),
}
}
@@ -270,8 +231,8 @@ func checkGolangImage(ctx context.Context) DoctorResult {
err = ensureImageAvailable(ctx, cli, imageName, false)
if err != nil {
return DoctorResult{
Name: nameGolangImage,
Status: statusFail,
Name: "Golang Image",
Status: "FAIL",
Message: fmt.Sprintf("Golang image %s not available locally and cannot pull: %v", imageName, err),
Suggestions: []string{
"Check internet connectivity",
@@ -283,8 +244,8 @@ func checkGolangImage(ctx context.Context) DoctorResult {
}
return DoctorResult{
Name: nameGolangImage,
Status: statusPass,
Name: "Golang Image",
Status: "PASS",
Message: fmt.Sprintf("Golang image %s is now available", imageName),
}
}
@@ -294,8 +255,8 @@ func checkGoInstallation(ctx context.Context) DoctorResult {
_, err := exec.LookPath("go")
if err != nil {
return DoctorResult{
Name: nameGoInstall,
Status: statusFail,
Name: "Go Installation",
Status: "FAIL",
Message: "Go binary not found in PATH",
Suggestions: []string{
"Install Go: https://golang.org/dl/",
@@ -309,8 +270,8 @@ func checkGoInstallation(ctx context.Context) DoctorResult {
output, err := cmd.Output()
if err != nil {
return DoctorResult{
Name: nameGoInstall,
Status: statusFail,
Name: "Go Installation",
Status: "FAIL",
Message: fmt.Sprintf("Cannot get Go version: %v", err),
}
}
@@ -318,8 +279,8 @@ func checkGoInstallation(ctx context.Context) DoctorResult {
version := strings.TrimSpace(string(output))
return DoctorResult{
Name: nameGoInstall,
Status: statusPass,
Name: "Go Installation",
Status: "PASS",
Message: version,
}
}
@@ -332,7 +293,7 @@ func checkGitRepository(ctx context.Context) DoctorResult {
if err != nil {
return DoctorResult{
Name: "Git Repository",
Status: statusFail,
Status: "FAIL",
Message: "Not in a Git repository",
Suggestions: []string{
"Run from within the headscale git repository",
@@ -343,7 +304,7 @@ func checkGitRepository(ctx context.Context) DoctorResult {
return DoctorResult{
Name: "Git Repository",
Status: statusPass,
Status: "PASS",
Message: "Running in Git repository",
}
}
@@ -370,7 +331,7 @@ func checkRequiredFiles(ctx context.Context) DoctorResult {
if len(missingFiles) > 0 {
return DoctorResult{
Name: "Required Files",
Status: statusFail,
Status: "FAIL",
Message: "Missing required files: " + strings.Join(missingFiles, ", "),
Suggestions: []string{
"Ensure you're in the headscale project root directory",
@@ -382,7 +343,7 @@ func checkRequiredFiles(ctx context.Context) DoctorResult {
return DoctorResult{
Name: "Required Files",
Status: statusPass,
Status: "PASS",
Message: "All required files found",
}
}
@@ -396,11 +357,11 @@ func displayDoctorResults(results []DoctorResult) {
var icon string
switch result.Status {
case statusPass:
case "PASS":
icon = "✅"
case statusWarn:
case "WARN":
icon = "⚠️"
case statusFail:
case "FAIL":
icon = "❌"
default:
icon = "❓"
-89
View File
@@ -1,89 +0,0 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/creachadair/command"
"github.com/juanfont/headscale/hscontrol/capver"
)
var (
errUnknownSet = errors.New("unknown --set value (want must|all)")
errUnknownFormat = errors.New("unknown --format value (want space|newline|json)")
)
// ListVersionsConfig holds flags for the list-versions subcommand.
type ListVersionsConfig struct {
Set string `flag:"set,default=must,Version set: must|all"`
Exclude string `flag:"exclude,Comma-separated versions to exclude (e.g. head,unstable)"`
Format string `flag:"format,default=space,Output format: space|newline|json"`
}
var listVersionsConfig ListVersionsConfig
// listVersions prints the Tailscale versions used by integration tests
// in a format CI can shell out to. Mirrors integration/scenario.go
// AllVersions and MustTestVersions: "head" and "unstable" are bare
// tags, releases get a "v" prefix so each entry can be appended to
// "ghcr.io/tailscale/tailscale:" directly.
func listVersions(env *command.Env) error {
release := capver.TailscaleLatestMajorMinor(capver.SupportedMajorMinorVersions, true)
all := append([]string{"head", "unstable"}, release...)
must := append(append([]string{}, all[0:4]...), all[len(all)-2:]...)
var versions []string
switch listVersionsConfig.Set {
case "must":
versions = must
case "all":
versions = all
default:
return fmt.Errorf("%w: %q", errUnknownSet, listVersionsConfig.Set)
}
excluded := make(map[string]bool)
if listVersionsConfig.Exclude != "" {
for v := range strings.SplitSeq(listVersionsConfig.Exclude, ",") {
excluded[strings.TrimSpace(v)] = true
}
}
out := make([]string, 0, len(versions))
for _, v := range versions {
if excluded[v] {
continue
}
if v != "head" && v != "unstable" {
v = "v" + v
}
out = append(out, v)
}
switch listVersionsConfig.Format {
case "space":
fmt.Println(strings.Join(out, " "))
case "newline":
for _, v := range out {
fmt.Println(v)
}
case "json":
b, err := json.Marshal(out)
if err != nil {
return err
}
fmt.Println(string(b))
default:
return fmt.Errorf("%w: %q", errUnknownFormat, listVersionsConfig.Format)
}
return nil
}
-7
View File
@@ -29,13 +29,6 @@ func main() {
return runDoctorCheck(env.Context())
},
},
{
Name: "list-versions",
Help: "Print Tailscale versions used by integration tests",
Usage: "list-versions [flags]",
SetFlags: command.Flags(flax.MustBind, &listVersionsConfig),
Run: listVersions,
},
{
Name: "clean",
Help: "Clean Docker resources",
+1 -1
View File
@@ -94,7 +94,7 @@ func detectGoVersion() string {
return "1.26.1"
}
// splitLines splits a string into lines without using [strings.Split].
// splitLines splits a string into lines without using strings.Split.
func splitLines(s string) []string {
var (
lines []string
+3 -3
View File
@@ -160,7 +160,7 @@ func (sc *StatsCollector) monitorDockerEvents(ctx context.Context, runID string,
continue
}
// Convert to [types.Container] format for consistency
// Convert to types.Container format for consistency
cont := types.Container{ //nolint:staticcheck // SA1019: use container.Summary
ID: containerInfo.ID,
Names: []string{containerInfo.Name},
@@ -256,7 +256,7 @@ func (sc *StatsCollector) collectStatsForContainer(ctx context.Context, containe
err := decoder.Decode(&stats)
if err != nil {
// [io.EOF] is expected when container stops or stream ends
// EOF is expected when container stops or stream ends
if err.Error() != "EOF" && verbose {
log.Printf("Failed to decode stats for container %s: %v", containerID[:12], err)
}
@@ -312,7 +312,7 @@ func calculateCPUPercent(prevStats, stats *container.Stats) float64 { //nolint:s
// Calculate CPU percentage: (container CPU delta / system CPU delta) * number of CPUs * 100
numCPUs := float64(len(stats.CPUStats.CPUUsage.PercpuUsage))
if numCPUs == 0 {
// Fallback: if [PercpuUsage] is not available, assume 1 CPU
// Fallback: if PercpuUsage is not available, assume 1 CPU
numCPUs = 1.0
}
+3 -3
View File
@@ -12,7 +12,7 @@
// 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
// The JSON schema and goModFingerprint algorithm mirror upstream
// tailscale's tool/updateflakes so a future shared library extraction
// is straightforward.
package main
@@ -82,8 +82,8 @@ 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
// 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")
+8 -14
View File
@@ -39,13 +39,6 @@ grpc_listen_addr: 127.0.0.1:50443
# are doing.
grpc_allow_insecure: false
# CIDR(s) of reverse proxies (e.g. 127.0.0.1/32) whose
# True-Client-IP, X-Real-IP and X-Forwarded-For headers should
# be honoured. Empty (default) ignores those headers; setting
# this without a proxy in front lets clients spoof their logged
# source IP.
trusted_proxies: []
# The Noise section includes specific configuration for the
# TS2021 Noise protocol
noise:
@@ -87,7 +80,8 @@ prefixes:
# connection cannot be established.
# https://tailscale.com/blog/how-tailscale-works/#encrypted-tcp-relays-derp
#
# Headscale needs a list of DERP servers that can be presented to the clients.
# headscale needs a list of DERP servers that can be presented
# to the clients.
derp:
server:
# If enabled, runs the embedded DERP server and merges it into the rest of the DERP config
@@ -132,9 +126,9 @@ derp:
# Locally available DERP map files encoded in YAML
#
# This option is mostly interesting for people hosting their own DERP servers:
# This option is mostly interesting for people hosting
# their own DERP servers:
# https://tailscale.com/docs/reference/derp-servers/custom-derp-servers
# https://headscale.net/stable/ref/derp/
#
# paths:
# - /etc/headscale/derp-example.yaml
@@ -268,7 +262,7 @@ tls_letsencrypt_cache_dir: /var/lib/headscale/cache
# Type of ACME challenge to use, currently supported types:
# HTTP-01 or TLS-ALPN-01
# See: https://headscale.net/stable/ref/tls/
# See: docs/ref/tls.md for more information
tls_letsencrypt_challenge_type: HTTP-01
# When HTTP-01 challenge is chosen, letsencrypt must set up a
# verification endpoint, and it will be listening on:
@@ -303,6 +297,7 @@ policy:
# headscale supports Tailscale's DNS configuration and MagicDNS.
# Please have a look to their docs 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
#
@@ -319,7 +314,7 @@ policy:
# 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
# Whether to use [MagicDNS](https://tailscale.com/docs/features/magicdns).
magic_dns: true
# Defines the base domain to create the hostnames for MagicDNS.
@@ -360,7 +355,7 @@ dns:
# Extra DNS records
# so far only A and AAAA records are supported (on the tailscale side)
# See: https://headscale.net/stable/ref/dns/
# See: docs/ref/dns.md
extra_records: []
# - name: "grafana.myvpn.example.com"
# type: "A"
@@ -379,7 +374,6 @@ unix_socket: /var/run/headscale/headscale.sock
unix_socket_permission: "0770"
# OpenID Connect
# https://headscale.net/stable/ref/oidc/
# oidc:
# # Block startup until the identity provider is available and healthy.
# only_start_if_oidc_is_available: true
+1 -6
View File
@@ -13,9 +13,7 @@ provides on overview of Headscale's feature and compatibility with the Tailscale
- [x] [Global and restricted nameservers (split DNS)](https://tailscale.com/docs/reference/dns-in-tailscale#nameservers)
- [x] [search domains](https://tailscale.com/docs/reference/dns-in-tailscale#search-domains)
- [x] [Extra DNS records (Headscale only)](../ref/dns.md#setting-extra-dns-records)
- [x] File sharing
- [x] [Taildrive](https://tailscale.com/docs/features/taildrive)
- [x] [Taildrop](https://tailscale.com/docs/features/taildrop)
- [x] [Taildrop](https://tailscale.com/docs/features/taildrop)
- [x] [Tags](../ref/tags.md)
- [x] [Routes](../ref/routes.md)
- [x] [Subnet routers](../ref/routes.md#subnet-router)
@@ -33,9 +31,6 @@ provides on overview of Headscale's feature and compatibility with the Tailscale
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] [Node attributes](../ref/policy.md#node-attributes)
- [x] [Tests](https://tailscale.com/docs/reference/syntax/policy-file#tests) and
[sshTests](https://tailscale.com/docs/reference/syntax/policy-file#ssh-tests)
- [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
-24
View File
@@ -31,30 +31,6 @@ tls_cert_path: ""
tls_key_path: ""
```
### Trusted proxies
Headscale ignores `True-Client-IP`, `X-Real-IP` and `X-Forwarded-For`
unless the request's TCP peer matches `trusted_proxies`. Set this to
the CIDR(s) your reverse proxy connects from so the real client IP
appears in access logs:
```yaml title="config.yaml"
trusted_proxies:
- 127.0.0.1/32
- ::1/128
```
The reverse proxy must also strip any client-supplied
`True-Client-IP` / `X-Real-IP` / `X-Forwarded-For` on inbound requests
and set its own values. nginx's `$proxy_add_x_forwarded_for` only
appends to whatever the client sent — pair it with
`proxy_set_header X-Real-IP $remote_addr;` and clear the inbound XFF
yourself if your nginx version does not do so.
Leaving `trusted_proxies` empty when there is no proxy in front is
safe: the headers are dropped from every request and the access log
shows the directly-connecting TCP peer.
## nginx
The following example configuration can be used in your nginx setup, substituting values as necessary. `<IP:PORT>` should be the IP address and port where headscale is running. In most cases, this will be `http://localhost:8080`.
-49
View File
@@ -196,54 +196,5 @@ Used in Tailscale SSH rules to allow access to any user except root. Can only be
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.
## Node Attributes
[Node attributes](https://tailscale.com/docs/reference/syntax/policy-file#node-attributes) allow for device-specific
configuration and attributes. At least the following node attributes are currently supported by Headscale[^2]:
- `drive:access`, `drive:share`: [Taildrive support](https://tailscale.com/docs/features/taildrive).
- `nextdns:<profile>`, `nextdns:no-device-info`: [NextDNS integration](https://tailscale.com/docs/integrations/nextdns).
Be sure to set NextDNS as global resolver in the [configuration](configuration.md).
- `magicdns-aaaa`: Respond to AAAA queries on the local [MagicDNS](https://tailscale.com/docs/features/magicdns)
resolver at 100.100.100.100.
- `disable-ipv4`: Selectively disable IPv4 for specfic nodes. This is may be useful to workaround [CGNat
conflicts](https://tailscale.com/docs/reference/troubleshooting/network-configuration/cgnat-conflicts).
- `randomize-client-port`: Allocate a [random port for WireGuard
traffic](https://tailscale.com/docs/reference/syntax/policy-file#randomizeclientport) instead of the static default
port 41641.
- `disable-captive-portal-detection`: [Disable automatic captive portal
detection](https://tailscale.com/docs/integrations/captive-portals#disable-captive-portal-detection).
```json title="policy.json"
{
"nodeAttrs": [
{
// Enable MagicDNS AAAA records for all nodes
"target": ["*"]
"attr": ["magicdns-aaaa"]
}
]
}
```
## Network-wide policy options
The following options are applied for the entire tailnet. Consider [node attributes](#node-attributes) for a more
fine-grained configuration instead.
- `randomizeClientPort`: Allocate a [random port for WireGuard
traffic](https://tailscale.com/docs/reference/syntax/policy-file#randomizeclientport) instead of the static default
port 41641.
```json title="policy.json"
{
// Use a random WireGuard port for the entire tailnet
"randomizeClientPort": true
}
```
[^1]: Headscale also allows to store the policy in the database. This is typically only required in case a [web
interface](integration/web-ui.md) is used.
[^2]: Other key-only node attributes can be used as well. Find them in the client source code with `grep -E '^\s+NodeAttr\w+' tailcfg/tailcfg.go` or by using [GitHub code search (requires
login)](https://github.com/search?q=repo%3Atailscale%2Ftailscale%20language%3Ago%20path%3Atailcfg%2Ftailcfg.go%20symbol%3A%2FNodeAttr%5Cw%2B%2F&type=code).
+2 -2
View File
@@ -31,10 +31,10 @@ distributions are Ubuntu 22.04 or newer, Debian 12 or newer.
sudo nano /etc/headscale/config.yaml
```
1. Restart headscale to pick up configuration changes:
1. Enable and start the headscale service:
```shell
sudo systemctl restart headscale
sudo systemctl enable --now headscale
```
1. Verify that headscale is running as intended:
Generated
+3 -3
View File
@@ -20,11 +20,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1779351318,
"narHash": "sha256-f+JACbTqzZ+G92DSnXOUGRhGANb8Blh7CoeYOeBF8/U=",
"lastModified": 1777270315,
"narHash": "sha256-yKB4G6cKsQsWN7M6rZGk6gkJPDNPIzT05y4qzRyCDlI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "4a29d733e8a7d5b824c3d8c958a946a9867b3eb2",
"rev": "6368eda62c9775c38ef7f714b2555a741c20c72d",
"type": "github"
},
"original": {
+6 -6
View File
@@ -62,16 +62,16 @@
protoc-gen-grpc-gateway = buildGo rec {
pname = "grpc-gateway";
version = "2.29.0";
version = "2.28.0";
src = pkgs.fetchFromGitHub {
owner = "grpc-ecosystem";
repo = "grpc-gateway";
rev = "v${version}";
sha256 = "sha256-d9OIIGttyMBSNgpS6mbR5JEIm13qGu2gFHJazJAexdw=";
sha256 = "sha256-93omvHb+b+S0w4D+FGEEwYYDjgumJFDAruc1P4elfvA=";
};
vendorHash = "sha256-p51yD+v8+rPs+ztlX7r0VQ4XlwUkxu+PxgknKEvH00k=";
vendorHash = "sha256-jVP5zfFPfHeAEApKNJzZwuZLA+DjKgkL7m2DFG72UNs=";
nativeBuildInputs = [ pkgs.installShellFiles ];
@@ -97,16 +97,16 @@
# Build golangci-lint with Go 1.26 (upstream uses hardcoded Go version)
golangci-lint = buildGo rec {
pname = "golangci-lint";
version = "2.12.2";
version = "2.11.4";
src = pkgs.fetchFromGitHub {
owner = "golangci";
repo = "golangci-lint";
rev = "v${version}";
hash = "sha256-qR7fp1x2S+EwEAcplRHTvA3jWwLr/XSiYKSZtAwkrNU=";
hash = "sha256-B19aLvfNRY9TOYw/71f2vpNUuSIz8OI4dL0ijGezsas=";
};
vendorHash = "sha256-AG5wtLwWLz55bdp1oi3cW+9O3yj1W1P7MV9zxym7Pb4=";
vendorHash = "sha256-xuoj4+U4tB5gpABKq4Dbp2cxnljxdYoBbO8A7DqPM5E=";
subPackages = [ "cmd/golangci-lint" ];
+2 -2
View File
@@ -1,6 +1,6 @@
{
"vendor": {
"goModSum": "sha256-xos6ixeJRMrEengGJaRiSsrm+3S3R0OEsnv1e85Sqhw=",
"sri": "sha256-bZod9sUUyQ67x/HzZrQ7SK+o5gAUxJhx7Rr6VdIUj1I="
"goModSum": "sha256-IE0n9cSqO4XNX4RN+CGBk9VC46iACiZKDFf/215iivk=",
"sri": "sha256-ijEIP9NSomhlWOgsVN7tPvSuvkTiLtnvXvhZmatIDLM="
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc-gen-go-grpc v1.6.1
// - protoc (unknown)
// source: headscale/v1/headscale.proto
+37 -37
View File
@@ -1,6 +1,6 @@
module github.com/juanfont/headscale
go 1.26.3
go 1.26.2
require (
github.com/arl/statsviz v0.8.0
@@ -8,20 +8,20 @@ require (
github.com/chasefleming/elem-go v0.31.0
github.com/coder/websocket v1.8.14
github.com/coreos/go-oidc/v3 v3.18.0
github.com/creachadair/command v0.2.6
github.com/creachadair/flax v0.0.6
github.com/creachadair/command v0.2.2
github.com/creachadair/flax v0.0.5
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc
github.com/docker/docker v28.5.2+incompatible
github.com/fsnotify/fsnotify v1.10.1
github.com/fsnotify/fsnotify v1.9.0
github.com/glebarez/sqlite v1.11.0
github.com/go-chi/chi/v5 v5.2.5
github.com/go-chi/metrics v0.1.1
github.com/go-gormigrate/gormigrate/v2 v2.1.5
github.com/go-json-experiment/json v0.0.0-20260520185125-572e7c383686
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433
github.com/gofrs/uuid/v5 v5.4.0
github.com/google/go-cmp v0.7.0
github.com/gorilla/mux v1.8.1
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0
github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/jagottsicher/termcolor v1.0.2
github.com/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25
@@ -31,32 +31,31 @@ require (
github.com/prometheus/client_golang v1.23.2
github.com/prometheus/common v0.67.5
github.com/pterm/pterm v0.12.83
github.com/puzpuzpuz/xsync/v4 v4.5.0
github.com/realclientip/realclientip-go v1.0.0
github.com/rs/zerolog v1.35.1
github.com/puzpuzpuz/xsync/v4 v4.4.0
github.com/rs/zerolog v1.35.0
github.com/samber/lo v1.53.0
github.com/sasha-s/go-deadlock v0.3.9
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd
github.com/tailscale/squibble v0.0.0-20260411062017-141f5d618bc4
github.com/tailscale/tailsql v0.0.0-20260521144131-377d992d0d71
github.com/tailscale/squibble v0.0.0-20260303070345-3ac5157f405e
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.52.0
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a
golang.org/x/net v0.55.0
golang.org/x/crypto v0.50.0
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90
golang.org/x/net v0.53.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sync v0.20.0
google.golang.org/genproto/googleapis/api v0.0.0-20260519071638-aa98bba5eb94
google.golang.org/grpc v1.81.1
google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d
google.golang.org/grpc v1.80.0
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
pgregory.net/rapid v1.3.0
tailscale.com v1.98.3
pgregory.net/rapid v1.2.0
tailscale.com v1.97.0-pre.0.20260429005429-40088602c960
zombiezen.com/go/postgrestest v1.0.1
)
@@ -78,10 +77,10 @@ require (
// together, e.g:
// go get modernc.org/libc@v1.55.3 modernc.org/sqlite@v1.33.1
require (
modernc.org/libc v1.72.3 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.50.1
modernc.org/sqlite v1.48.2
)
// NOTE: gvisor must be updated in lockstep with
@@ -130,7 +129,7 @@ require (
github.com/containerd/continuity v0.4.5 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/creachadair/mds v0.28.0 // indirect
github.com/creachadair/mds v0.26.2 // indirect
github.com/creachadair/msync v0.8.2 // indirect
github.com/dblohm7/wingoes v0.0.0-20250822163801-6d8e6105c62d // indirect
github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33 // indirect
@@ -145,7 +144,7 @@ require (
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
github.com/go-jose/go-jose/v3 v3.0.5 // indirect
github.com/go-jose/go-jose/v3 v3.0.4 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
@@ -169,7 +168,7 @@ require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.9.2 // indirect
github.com/jackc/pgx/v5 v5.8.0 // indirect
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
@@ -204,6 +203,7 @@ require (
github.com/pires/go-proxyproto v0.9.2 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus-community/pro-bing v0.7.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
@@ -218,9 +218,9 @@ require (
github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d // 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-20260310221408-dcd97e42f251 // 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-20260427181203-e3ac4a0afb4e // indirect
github.com/tailscale/wireguard-go v0.0.0-20260304043104-4184faf59e56 // indirect
github.com/toqueteos/webbrowser v1.2.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
@@ -228,24 +228,24 @@ require (
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect
go.opentelemetry.io/otel v1.40.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 // indirect
go.opentelemetry.io/otel/metric v1.40.0 // indirect
go.opentelemetry.io/otel/trace v1.40.0 // indirect
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.40.0 // indirect
golang.org/x/mod v0.36.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/term v0.43.0 // indirect
golang.org/x/text v0.37.0 // 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.45.0 // indirect
golang.org/x/tools v0.44.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-20260519071638-aa98bba5eb94 // 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
+86 -86
View File
@@ -129,12 +129,12 @@ github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creachadair/command v0.2.6 h1:+d4xUSZza5nqRVVsVRf5jDbix/0uKxVqFrc30F4uyRI=
github.com/creachadair/command v0.2.6/go.mod h1:HWvS3zEpVakKlNXxT/j1U8tNoDaY7By7ORYQ+m1ha2U=
github.com/creachadair/flax v0.0.6 h1:IFUdRdfKynNTyR5SRlrHMUGnxxMZZen8RVpwcDq/ftk=
github.com/creachadair/flax v0.0.6/go.mod h1:F1PML0JZLXSNDMNiRGK2yjm5f+L9QCHchyHBldFymj8=
github.com/creachadair/mds v0.28.0 h1:VS4JsBSlClsqBrHT8cYMTMqFHJdU0P/Z1ES6zjnSMXo=
github.com/creachadair/mds v0.28.0/go.mod h1:dMBTCSy3iS3dwh4Rb1zxeZz2d7K8+N24GCTsayWtQRI=
github.com/creachadair/command v0.2.2 h1:4RGsUhqFf1imFC+vMWOOCiQdncThCdcdMJp0JNCjxxc=
github.com/creachadair/command v0.2.2/go.mod h1:Z6Zp6CSJcnaWWR4wHgdqzODnFdxFJAaa/DrcVkeUu3E=
github.com/creachadair/flax v0.0.5 h1:zt+CRuXQASxwQ68e9GHAOnEgAU29nF0zYMHOCrL5wzE=
github.com/creachadair/flax v0.0.5/go.mod h1:F1PML0JZLXSNDMNiRGK2yjm5f+L9QCHchyHBldFymj8=
github.com/creachadair/mds v0.26.2 h1:rCtvEV/bCRY0hGfwvvMg0p3yzKgBE8l/9OV4fjF9QQ8=
github.com/creachadair/mds v0.26.2/go.mod h1:dMBTCSy3iS3dwh4Rb1zxeZz2d7K8+N24GCTsayWtQRI=
github.com/creachadair/msync v0.8.2 h1:ujvc/SVJPn+bFwmjUHucXNTTn3opVe2YbQ46mBCnP08=
github.com/creachadair/msync v0.8.2/go.mod h1:LzxqD9kfIl/O3DczkwOgJplLPqwrTbIhINlf9bHIsEY=
github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc=
@@ -174,8 +174,8 @@ 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.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo=
@@ -192,12 +192,12 @@ github.com/go-chi/metrics v0.1.1 h1:CXhbnkAVVjb0k73EBRQ6Z2YdWFnbXZgNtg1Mboguibk=
github.com/go-chi/metrics v0.1.1/go.mod h1:mcGTM1pPalP7WCtb+akNYFO/lwNwBBLCuedepqjoPn4=
github.com/go-gormigrate/gormigrate/v2 v2.1.5 h1:1OyorA5LtdQw12cyJDEHuTrEV3GiXiIhS4/QTTa/SM8=
github.com/go-gormigrate/gormigrate/v2 v2.1.5/go.mod h1:mj9ekk/7CPF3VjopaFvWKN2v7fN3D9d3eEOAXRhi/+M=
github.com/go-jose/go-jose/v3 v3.0.5 h1:BLLJWbC4nMZOfuPVxoZIxeYsn6Nl2r1fITaJ78UQlVQ=
github.com/go-jose/go-jose/v3 v3.0.5/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY=
github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-json-experiment/json v0.0.0-20260520185125-572e7c383686 h1:NZBJxCpbHS1gzS6xAmyxbJznosZIIPk9IB42v62UvKA=
github.com/go-json-experiment/json v0.0.0-20260520185125-572e7c383686/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao=
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -258,8 +258,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=
github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
@@ -280,8 +280,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jagottsicher/termcolor v1.0.2 h1:fo0c51pQSuLBN1+yVX2ZE+hE+P7ULb/TY8eRowJnrsM=
@@ -404,6 +404,8 @@ github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Q
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus-community/pro-bing v0.7.0 h1:KFYFbxC2f2Fp6c+TyxbCOEarf7rbnzr9Gw8eIb0RfZA=
github.com/prometheus-community/pro-bing v0.7.0/go.mod h1:Moob9dvlY50Bfq6i88xIwfyw7xLFHH69LUgx9n5zqCE=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
@@ -421,17 +423,15 @@ github.com/pterm/pterm v0.12.36/go.mod h1:NjiL09hFhT/vWjQHSj1athJpx6H8cjpHXNAK5b
github.com/pterm/pterm v0.12.40/go.mod h1:ffwPLwlbXxP+rxT0GsgDTzS3y3rmpAO1NMjUkGTYf8s=
github.com/pterm/pterm v0.12.83 h1:ie+YmGmA727VuhxBlyGr74Ks+7McV6kT99IB8EU80aA=
github.com/pterm/pterm v0.12.83/go.mod h1:xlgc6bFWyJIMtmLJvGim+L7jhSReilOlOnodeIYe4Tk=
github.com/puzpuzpuz/xsync/v4 v4.5.0 h1:vOSWu6b57/emh+L/Cw0BeQfvxa/cogFywXHeGUxQxAg=
github.com/puzpuzpuz/xsync/v4 v4.5.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo=
github.com/realclientip/realclientip-go v1.0.0 h1:+yPxeC0mEaJzq1BfCt2h4BxlyrvIIBzR6suDc3BEF1U=
github.com/realclientip/realclientip-go v1.0.0/go.mod h1:CXnUdVwFRcXFJIRb/dTYqbT7ud48+Pi2pFm80bxDmcI=
github.com/puzpuzpuz/xsync/v4 v4.4.0 h1:vlSN6/CkEY0pY8KaB0yqo/pCLZvp9nhdbBdjipT4gWo=
github.com/puzpuzpuz/xsync/v4 v4.4.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI=
github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/safchain/ethtool v0.7.0 h1:rlJzfDetsVvT61uz8x1YIcFn12akMfuPulHtZjtb7Is=
github.com/safchain/ethtool v0.7.0/go.mod h1:MenQKEjXdfkjD3mp2QdCk8B/hwvkrlOTm/FD4gTpFxQ=
@@ -487,18 +487,18 @@ github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0=
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+yfntqhI3oAu9i27nEojcQ4NuBQOo5ZFA=
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc=
github.com/tailscale/setec v0.0.0-20260310221408-dcd97e42f251 h1:kNnJlwxSzue+VRJuDdZ/yebcMM2q9KqFmK9xQqH1YRc=
github.com/tailscale/setec v0.0.0-20260310221408-dcd97e42f251/go.mod h1:6NU8H/GLPVX2TnXAY1duyy9ylLaHwFpr0X93UPiYmNI=
github.com/tailscale/squibble v0.0.0-20260411062017-141f5d618bc4 h1:1ghkd9YIC4J7umZuu9jZz8afWJSj1hCRSzfvdI2Q3Vo=
github.com/tailscale/squibble v0.0.0-20260411062017-141f5d618bc4/go.mod h1:EVp9PDh7v69Do6aNzFfLvL0fdCph3XskGMi+WcS/uOM=
github.com/tailscale/tailsql v0.0.0-20260521144131-377d992d0d71 h1:IXwFgoMvxnXLCmQldzyuOO+b8Ko1cxqeuz5o3oaMkj8=
github.com/tailscale/tailsql v0.0.0-20260521144131-377d992d0d71/go.mod h1:N2Dm+UzRWz01zPMDl5XHkSVq91kNPWU13uUUibcOm+c=
github.com/tailscale/setec v0.0.0-20260115174028-19d190c5556d h1:N+TtzIaGYREbLbKZB0WU0vVnMSfaqUkSf3qMEi03hwE=
github.com/tailscale/setec v0.0.0-20260115174028-19d190c5556d/go.mod h1:6NU8H/GLPVX2TnXAY1duyy9ylLaHwFpr0X93UPiYmNI=
github.com/tailscale/squibble v0.0.0-20260303070345-3ac5157f405e h1:4yfp5/YDr+TzbUME/PalYJVXAsp7zA2Gv2xQMZ9Qors=
github.com/tailscale/squibble v0.0.0-20260303070345-3ac5157f405e/go.mod h1:xJkMmR3t+thnUQhA3Q4m2VSlS5pcOq+CIjmU/xfKKx4=
github.com/tailscale/tailsql v0.0.0-20260322172246-3ab0c1744d9c h1:7lJQ/zycbk1E9e0nUiMuwIDYprFTLpWXUwiPdi+tRlI=
github.com/tailscale/tailsql v0.0.0-20260322172246-3ab0c1744d9c/go.mod h1:bpNmZdvZKmBstrZunT+NXL6hmrFw5AsuT7MGiYS8sRc=
github.com/tailscale/web-client-prebuilt v0.0.0-20251127225136-f19339b67368 h1:0tpDdAj9sSfSZg4gMwNTdqMP592sBrq2Sm0w6ipnh7k=
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-20260427181203-e3ac4a0afb4e h1:GexFR7ak1iz26fxg8HWCpOEqAOL8UEZJ7J3JxeCalDs=
github.com/tailscale/wireguard-go v0.0.0-20260427181203-e3ac4a0afb4e/go.mod h1:6SerzcvHWQchKO2BfNdmquA77CHSECZuFl+D9fp4RnI=
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/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=
@@ -530,24 +530,24 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJu
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0=
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0/go.mod h1:khvBS2IggMFNwZK/6lEeHg/W57h/IX6J4URh57fuI40=
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
@@ -561,25 +561,25 @@ 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.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw=
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
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/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=
golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8=
golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
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.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
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=
@@ -605,8 +605,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -614,24 +614,24 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
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.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
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=
@@ -639,12 +639,12 @@ golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus
golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260519071638-aa98bba5eb94 h1:DddG61lE5LkX6144z22i0gma9BMBs5aZ9B8lZLobxyw=
google.golang.org/genproto/googleapis/api v0.0.0-20260519071638-aa98bba5eb94/go.mod h1:1dCETSCY2YKZNXQE3h4fun3TYwF5p8jejRKZgfWAgAY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260519071638-aa98bba5eb94 h1:eZCjr/aAF8c5ccm5pb6T4EXgIei5MlAAPWPJk+5ArfY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260519071638-aa98bba5eb94/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d h1:/aDRtSZJjyLQzm75d+a1wOJaqyKBMvIAfeQmoa3ORiI=
google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:etfGUgejTiadZAUaEP14NP97xi1RGeawqkjDARA/UOs=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -674,10 +674,10 @@ 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.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
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=
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
@@ -686,29 +686,29 @@ modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
modernc.org/sqlite v1.48.2 h1:5CnW4uP8joZtA0LedVqLbZV5GD7F/0x91AXeSyjoh5c=
modernc.org/sqlite v1.48.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
pgregory.net/rapid v1.3.0 h1:vBvO0VSqti75J1jjYqpgPNBLKMd1+gxa9fYo7vk/Exc=
pgregory.net/rapid v1.3.0/go.mod h1:dPlE4OBBxgXPqkP79flB6sJL1dx5azpI7HQ9MY9Z7uk=
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.98.3 h1:caAbG4UfkKfKPE6b1fj5t4ep5qrwEis5AJu91ruvePw=
tailscale.com v1.98.3/go.mod h1:U23ZwbZlKJMNU7CScy+lCVVlece/S5n09q0nyudncBI=
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=
zombiezen.com/go/postgrestest v1.0.1 h1:aXoADQAJmZDU3+xilYVut0pHhgc0sF8ZspPW9gFNwP4=
zombiezen.com/go/postgrestest v1.0.1/go.mod h1:marlZezr+k2oSJrvXHnZUs1olHqpE9czlz8ZYkVxliQ=
+6 -33
View File
@@ -99,10 +99,6 @@ type Headscale struct {
DERPServer *derpServer.DERPServer
// realIPMiddleware is nil when cfg.TrustedProxies is empty; the
// router skips the mount and r.RemoteAddr stays as the TCP peer.
realIPMiddleware func(http.Handler) http.Handler
// Things that generate changes
extraRecordMan *dns.ExtraRecordsMan
authProvider AuthProvider
@@ -144,13 +140,6 @@ func NewHeadscale(cfg *types.Config) (*Headscale, error) {
state: s,
}
if len(cfg.TrustedProxies) > 0 {
app.realIPMiddleware, err = trustedProxyRealIP(cfg.TrustedProxies)
if err != nil {
return nil, fmt.Errorf("building trusted_proxies middleware: %w", err)
}
}
// Initialize ephemeral garbage collector
ephemeralGC := db.NewEphemeralGarbageCollector(func(ni types.NodeID) {
node, ok := app.state.GetNodeByID(ni)
@@ -219,19 +208,7 @@ func NewHeadscale(cfg *types.Config) (*Headscale, error) {
}
for _, d := range magicDNSDomains {
// Empty non-nil slice rather than nil: tailcfg.DNSConfig.Clone
// and dns.Config.Clone in tailscale drop map entries whose
// value is nil (see tailscale.com/tailcfg/tailcfg_clone.go and
// tailscale.com/net/dns/dns_clone.go: `if sv == nil { continue }`).
// Sending nil here caused the client's wgengine LinkChange:major
// handler to clobber /etc/resolv.conf on every tunnel-IP rebind
// — the handler reapplies a Clone of lastDNSConfig and the magic
// DNS routes vanish, taking the resolver with them for ~6 min
// until the next route-changing netmap. Empty slice survives
// Clone and carries the same "resolve locally" semantics
// (tailscale.com/ipn/ipnlocal/node_backend.go:869 documents the
// empty-resolver Routes form for Issue 2706).
app.cfg.TailcfgDNSConfig.Routes[d.WithoutTrailingDot()] = []*dnstype.Resolver{}
app.cfg.TailcfgDNSConfig.Routes[d.WithoutTrailingDot()] = nil
}
}
@@ -274,7 +251,7 @@ func NewHeadscale(cfg *types.Config) (*Headscale, error) {
// Redirect to our TLS url.
func (h *Headscale) redirect(w http.ResponseWriter, req *http.Request) {
target := h.cfg.ServerURL + req.URL.RequestURI()
http.Redirect(w, req, target, http.StatusFound) //nolint:gosec // G710: target prefixed by trusted ServerURL
http.Redirect(w, req, target, http.StatusFound)
}
func (h *Headscale) scheduledTasks(ctx context.Context) {
@@ -535,11 +512,7 @@ func (h *Headscale) createRouter(grpcMux *grpcRuntime.ServeMux) *chi.Mux {
},
}))
r.Use(middleware.RequestID)
if h.realIPMiddleware != nil {
r.Use(h.realIPMiddleware)
}
r.Use(middleware.RealIP)
r.Use(middleware.RequestLogger(&zerologRequestLogger{}))
r.Use(middleware.Recoverer)
r.Use(securityHeaders)
@@ -580,7 +553,7 @@ func (h *Headscale) createRouter(grpcMux *grpcRuntime.ServeMux) *chi.Mux {
r.HandleFunc("/v1/*", grpcMux.ServeHTTP)
})
// Ping response endpoint: receives HEAD from clients responding
// to a [tailcfg.PingRequest]. The unguessable ping ID serves as authentication.
// to a PingRequest. The unguessable ping ID serves as authentication.
r.Head("/machine/ping-response", h.PingResponseHandler)
r.Get("/favicon.ico", FaviconHandler)
@@ -1144,7 +1117,7 @@ func (h *Headscale) Change(cs ...change.Change) {
h.mapBatcher.AddWork(cs...)
}
// HTTPHandler returns an [http.Handler] for the [Headscale] control server.
// HTTPHandler returns an http.Handler for the Headscale control server.
// The handler serves the Tailscale control protocol including the /key
// endpoint and /ts2021 Noise upgrade path.
func (h *Headscale) HTTPHandler() http.Handler {
@@ -1224,7 +1197,7 @@ func (l *acmeLogger) RoundTrip(req *http.Request) (*http.Response, error) {
return resp, nil
}
// [zerologRequestLogger] implements chi's [middleware.LogFormatter]
// zerologRequestLogger implements chi's middleware.LogFormatter
// to route HTTP request logs through zerolog.
type zerologRequestLogger struct{}
+11 -10
View File
@@ -73,8 +73,8 @@ func (h *Headscale) handleRegister(
// the Noise session's machine key matches the cached node.
// Without this check anyone holding a target's NodeKey could
// open a Noise session with a throwaway machine key and read
// the owner's User/Login back through [nodeToRegisterResponse].
// [Headscale.handleLogout] enforces the same check on its own path.
// the owner's User/Login back through nodeToRegisterResponse.
// handleLogout enforces the same check on its own path.
if node.MachineKey() != machineKey {
return nil, NewHTTPError(
http.StatusUnauthorized,
@@ -83,9 +83,10 @@ func (h *Headscale) handleRegister(
)
}
// When tailscaled restarts, it sends [tailcfg.RegisterRequest] with Auth=nil and Expiry=zero.
// When tailscaled restarts, it sends RegisterRequest with Auth=nil and Expiry=zero.
// Return the current node state without modification.
if req.Expiry.IsZero() && !node.IsExpired() {
// See: https://github.com/juanfont/headscale/issues/2862
if req.Expiry.IsZero() && node.Expiry().Valid() && !node.IsExpired() {
return nodeToRegisterResponse(node), nil
}
@@ -191,7 +192,7 @@ func (h *Headscale) handleLogout(
}
// If the request expiry is in the past, we consider it a logout.
// Zero expiry is handled in [Headscale.handleRegister] before calling this function.
// Zero expiry is handled in handleRegister() before calling this function.
if req.Expiry.Before(time.Now()) {
log.Debug().
EmbedObject(node).
@@ -253,7 +254,7 @@ func nodeToRegisterResponse(node types.NodeView) *tailcfg.RegisterResponse {
MachineAuthorized: true,
}
// For tagged nodes, use the [types.TaggedDevices] special user
// For tagged nodes, use the TaggedDevices special user
// For user-owned nodes, include User and Login information from the actual user
if node.IsTagged() {
resp.User = types.TaggedDevices.View().TailscaleUser()
@@ -302,8 +303,8 @@ func (h *Headscale) waitForFollowup(
}
// reqToNewRegisterResponse refreshes the registration flow by creating a new
// registration ID and returning the corresponding [tailcfg.RegisterResponse.AuthURL]
// so the client can restart the authentication process.
// registration ID and returning the corresponding AuthURL so the client can
// restart the authentication process.
func (h *Headscale) reqToNewRegisterResponse(
req tailcfg.RegisterRequest,
machineKey key.MachinePublic,
@@ -325,8 +326,8 @@ func (h *Headscale) reqToNewRegisterResponse(
}, nil
}
// registrationDataFromRequest builds the [types.RegistrationData] payload stored
// in the auth cache for a pending registration. The original [tailcfg.Hostinfo] is
// registrationDataFromRequest builds the RegistrationData payload stored
// in the auth cache for a pending registration. The original Hostinfo is
// retained so that consumers (auth callback, observability) see the
// fields the client originally announced; the bounded-LRU cap on the
// cache is what bounds the unauthenticated cache-fill DoS surface.
+10 -152
View File
@@ -1,7 +1,6 @@
package hscontrol
import (
"context"
"testing"
"time"
@@ -60,7 +59,7 @@ func createTestAppWithNodeExpiry(t *testing.T, nodeExpiry time.Duration) *Headsc
// a tagged node with:
// - Tags from the PreAuthKey
// - Nil UserID (tagged nodes are owned by tags, not a user)
// - [types.Node.IsTagged] returns true.
// - IsTagged() returns true.
func TestTaggedPreAuthKeyCreatesTaggedNode(t *testing.T) {
app := createTestApp(t)
@@ -113,7 +112,7 @@ func TestTaggedPreAuthKeyCreatesTaggedNode(t *testing.T) {
// authentication. This is critical for the container restart scenario (#2830).
//
// NOTE: This test verifies that re-authentication preserves the node's current tags
// without testing tag modification via [state.State.SetNodeTags] (which requires ACL policy setup).
// without testing tag modification via SetNodeTags (which requires ACL policy setup).
func TestReAuthDoesNotReapplyTags(t *testing.T) {
app := createTestApp(t)
@@ -180,7 +179,7 @@ func TestReAuthDoesNotReapplyTags(t *testing.T) {
}
// NOTE: TestSetTagsOnUserOwnedNode functionality is covered by gRPC tests in grpcv1_test.go
// which properly handle ACL policy setup. The test verifies that [headscaleV1APIServer.SetTags] can convert
// which properly handle ACL policy setup. The test verifies that SetTags can convert
// user-owned nodes to tagged nodes while preserving UserID.
// TestCannotRemoveAllTags tests that attempting to remove all tags from a
@@ -670,151 +669,10 @@ func TestTaggedNodeReauthPreservesDisabledExpiry(t *testing.T) {
"Tagged node should have expiry PRESERVED as disabled after re-auth")
}
// TestTaggedNodeRestartPreservesNilExpiry tests that a tagged node whose
// tailscaled restarts (sending Auth=nil, Expiry=zero) keeps its nil expiry.
//
// The handleRegister guard required node.Expiry().Valid(), false for the
// nil expiry tagged nodes are created with. The request fell through to
// handleLogout, which wrote &time.Time{} over the original nil and flipped
// the API representation from null to "0001-01-01T00:00:00Z".
func TestTaggedNodeRestartPreservesNilExpiry(t *testing.T) {
app := createTestApp(t)
user := app.state.CreateUserForTest("tag-restart")
tags := []string{"tag:agent"}
pak, err := app.state.CreatePreAuthKey(user.TypedID(), true, false, nil, tags)
require.NoError(t, err)
machineKey := key.NewMachine()
nodeKey := key.NewNode()
regReq := tailcfg.RegisterRequest{
Auth: &tailcfg.RegisterResponseAuth{
AuthKey: pak.Key,
},
NodeKey: nodeKey.Public(),
Hostinfo: &tailcfg.Hostinfo{
Hostname: "tagged-restart-test",
},
}
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)
require.True(t, node.IsTagged())
require.False(t, node.Expiry().Valid(), "tagged node should have nil expiry after registration")
require.False(t, node.IsExpired(), "tagged node with nil expiry should not be expired")
// tailscaled restart: RegisterRequest with Auth=nil and Expiry=time.Time{}
// (the Go zero value) is what the client sends when it restarts with
// persisted state.
restartReq := tailcfg.RegisterRequest{
Auth: nil,
NodeKey: nodeKey.Public(),
Expiry: time.Time{},
}
restartResp, err := app.handleRegister(context.Background(), restartReq, machineKey.Public())
require.NoError(t, err)
require.True(t, restartResp.MachineAuthorized,
"restart should not require re-authorization")
require.False(t, restartResp.NodeKeyExpired,
"restart should not mark node key as expired")
require.Equal(t, types.TaggedDevices.View().TailscaleUser(), restartResp.User,
"response should identify node as tagged device")
nodeAfterRestart, found := app.state.GetNodeByNodeKey(nodeKey.Public())
require.True(t, found)
assert.True(t, nodeAfterRestart.IsTagged(), "node should still be tagged")
assert.False(t, nodeAfterRestart.IsExpired(), "node should not be expired after restart")
assert.False(t, nodeAfterRestart.Expiry().Valid(),
"tagged node expiry must remain nil (not zero-time) after restart")
var dbNode types.Node
require.NoError(t,
app.state.DB().DB.First(&dbNode, nodeAfterRestart.ID().Uint64()).Error)
assert.Nil(t, dbNode.Expiry,
"database expiry column must be NULL after restart, not a pointer to zero-time")
}
// TestUntaggedNodeRestartPreservesNilExpiry tests that an untagged node
// registered against a preauth key with no default node.expiry keeps its
// nil expiry when tailscaled restarts. Same root cause as the tagged
// variant: the dropped node.Expiry().Valid() check covers any nil-expiry
// node, regardless of ownership.
func TestUntaggedNodeRestartPreservesNilExpiry(t *testing.T) {
app := createTestAppWithNodeExpiry(t, 0)
user := app.state.CreateUserForTest("untagged-restart")
pak, err := app.state.CreatePreAuthKey(user.TypedID(), true, false, nil, nil)
require.NoError(t, err)
machineKey := key.NewMachine()
nodeKey := key.NewNode()
regReq := tailcfg.RegisterRequest{
Auth: &tailcfg.RegisterResponseAuth{
AuthKey: pak.Key,
},
NodeKey: nodeKey.Public(),
Hostinfo: &tailcfg.Hostinfo{
Hostname: "untagged-restart-test",
},
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)
require.False(t, node.IsTagged(), "node should be user-owned, not tagged")
require.False(t, node.Expiry().Valid(),
"untagged node with no default expiry should have nil expiry after registration")
require.False(t, node.IsExpired())
restartReq := tailcfg.RegisterRequest{
Auth: nil,
NodeKey: nodeKey.Public(),
Expiry: time.Time{},
}
restartResp, err := app.handleRegister(context.Background(), restartReq, machineKey.Public())
require.NoError(t, err)
require.True(t, restartResp.MachineAuthorized,
"restart should not require re-authorization")
require.False(t, restartResp.NodeKeyExpired,
"restart should not mark node key as expired")
nodeAfterRestart, found := app.state.GetNodeByNodeKey(nodeKey.Public())
require.True(t, found)
assert.False(t, nodeAfterRestart.IsTagged(), "node should still be user-owned")
assert.False(t, nodeAfterRestart.IsExpired(), "node should not be expired after restart")
assert.False(t, nodeAfterRestart.Expiry().Valid(),
"untagged node expiry must remain nil (not zero-time) after restart")
var dbNode types.Node
require.NoError(t,
app.state.DB().DB.First(&dbNode, nodeAfterRestart.ID().Uint64()).Error)
assert.Nil(t, dbNode.Expiry,
"database expiry column must be NULL after restart, not a pointer to zero-time "+
"(this is what `sqlite3 ... 'select expiry from nodes'` sees)")
}
// TestExpiryDuringPersonalToTaggedConversion tests that when a personal node
// is converted to tagged via reauth with RequestTags, the expiry is cleared to nil.
// Previously expiry was NOT cleared because expiry handling ran
// BEFORE [state.State.processReauthTags].
// BUG #3048: Previously expiry was NOT cleared because expiry handling ran
// BEFORE processReauthTags.
func TestExpiryDuringPersonalToTaggedConversion(t *testing.T) {
app := createTestApp(t)
user := app.state.CreateUserForTest("expiry-test-user")
@@ -886,8 +744,8 @@ func TestExpiryDuringPersonalToTaggedConversion(t *testing.T) {
// TestExpiryDuringTaggedToPersonalConversion tests that when a tagged node
// is converted to personal via reauth with empty RequestTags, expiry is set
// from the client request.
// Previously expiry was NOT set because expiry handling ran
// BEFORE [state.State.processReauthTags] (node was still tagged at check time).
// BUG #3048: Previously expiry was NOT set because expiry handling ran
// BEFORE processReauthTags (node was still tagged at check time).
func TestExpiryDuringTaggedToPersonalConversion(t *testing.T) {
app := createTestApp(t)
user := app.state.CreateUserForTest("expiry-test-user2")
@@ -1145,7 +1003,7 @@ func TestNodeExpiryZeroDisablesDefault(t *testing.T) {
assert.False(t, node.IsExpired(), "node should not be expired")
// With node.expiry=0 and zero client expiry, the node gets a zero expiry
// which [types.Node.IsExpired] treats as "never expires" — backwards compatible.
// which IsExpired() treats as "never expires" — backwards compatible.
if node.Expiry().Valid() {
assert.True(t, node.Expiry().Get().IsZero(),
"with node.expiry=0 and zero client expiry, expiry should be zero time")
@@ -1266,11 +1124,11 @@ func TestReregistrationAppliesDefaultExpiry(t *testing.T) {
// 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 `&regReq.Expiry` (pointer to [time.Time]{}) instead of nil,
// which assigned `&regReq.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 ([state.State.createAndSaveNewNode] also receives nil
// 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) {
+34 -34
View File
@@ -31,7 +31,7 @@ type interactiveStep struct {
stepType string // stepTypeInitialRequest, stepTypeAuthCompletion, or stepTypeFollowupRequest
expectAuthURL bool
expectCacheEntry bool
callAuthPath bool // Real call to [state.State.HandleNodeFromAuthPath], not mocked
callAuthPath bool // Real call to HandleNodeFromAuthPath, not mocked
}
//nolint:gocyclo // comprehensive test function with many scenarios
@@ -140,7 +140,7 @@ func TestAuthenticationFlows(t *testing.T) {
return "", err
}
// Wait for node to be available in [state.NodeStore]
// Wait for node to be available in NodeStore
require.EventuallyWithT(t, func(c *assert.CollectT) {
_, found := app.state.GetNodeByNodeKey(nodeKey1.Public())
assert.True(c, found, "node should be available in NodeStore")
@@ -209,7 +209,7 @@ func TestAuthenticationFlows(t *testing.T) {
return "", err
}
// Wait for node to be available in [state.NodeStore]
// Wait for node to be available in NodeStore
require.EventuallyWithT(t, func(c *assert.CollectT) {
_, found := app.state.GetNodeByNodeKey(nodeKey1.Public())
assert.True(c, found, "node should be available in NodeStore")
@@ -409,7 +409,7 @@ func TestAuthenticationFlows(t *testing.T) {
t.Logf("Setup registered node: %+v", resp)
// Wait for node to be available in [state.NodeStore] with debug info
// Wait for node to be available in NodeStore with debug info
var attemptCount int
require.EventuallyWithT(t, func(c *assert.CollectT) {
@@ -470,7 +470,7 @@ func TestAuthenticationFlows(t *testing.T) {
return "", err
}
// Wait for node to be available in [state.NodeStore]
// Wait for node to be available in NodeStore
require.EventuallyWithT(t, func(c *assert.CollectT) {
_, found := app.state.GetNodeByNodeKey(nodeKey1.Public())
assert.True(c, found, "node should be available in NodeStore")
@@ -520,7 +520,7 @@ func TestAuthenticationFlows(t *testing.T) {
return "", err
}
// Wait for node to be available in [state.NodeStore]
// Wait for node to be available in NodeStore
require.EventuallyWithT(t, func(c *assert.CollectT) {
_, found := app.state.GetNodeByNodeKey(nodeKey1.Public())
assert.True(c, found, "node should be available in NodeStore")
@@ -637,7 +637,7 @@ func TestAuthenticationFlows(t *testing.T) {
return "", err
}
// Wait for node to be available in [state.NodeStore]
// Wait for node to be available in NodeStore
require.EventuallyWithT(t, func(c *assert.CollectT) {
_, found := app.state.GetNodeByNodeKey(nodeKey1.Public())
assert.True(c, found, "node should be available in NodeStore")
@@ -687,7 +687,7 @@ func TestAuthenticationFlows(t *testing.T) {
app.state.SetAuthCacheEntry(regID, nodeToRegister)
// Simulate successful registration
// [Headscale.handleRegister] will receive the value when it starts waiting
// handleRegister will receive the value when it starts waiting
go func() {
user := app.state.CreateUserForTest("followup-user")
@@ -826,7 +826,7 @@ func TestAuthenticationFlows(t *testing.T) {
},
// TEST: Nil hostinfo is handled with defensive code
// WHAT: Tests that nil hostinfo in register request is handled gracefully
// INPUT: Register request with [tailcfg.Hostinfo] field set to nil
// INPUT: Register request with Hostinfo field set to nil
// EXPECTED: Node registers successfully with generated hostname starting with "node-"
// WHY: Defensive code prevents nil pointer panics; creates valid default hostinfo
{
@@ -856,7 +856,7 @@ 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 [tailcfg.Hostinfo] the raw hostname stays empty and GivenName
// With nil Hostinfo the raw hostname stays empty and GivenName
// falls back to the literal "node" per the SaaS spec.
node, found := app.state.GetNodeByNodeKey(nodeKey1.Public())
assert.True(t, found)
@@ -954,7 +954,7 @@ func TestAuthenticationFlows(t *testing.T) {
// TEST: PreAuthKey registration rejects client-provided RequestTags
// WHAT: Tests that PreAuthKey registrations cannot use client-provided tags
// INPUT: PreAuthKey registration with [tailcfg.Hostinfo.RequestTags] set
// INPUT: PreAuthKey registration with RequestTags in Hostinfo
// EXPECTED: Registration fails with "requested tags [...] are invalid or not permitted" error
// WHY: PreAuthKey nodes get their tags from the key itself, not from client requests
{
@@ -1240,7 +1240,7 @@ func TestAuthenticationFlows(t *testing.T) {
// TEST: Zero-time expiry is handled correctly
// WHAT: Tests registration with expiry set to zero time value
// INPUT: Register request with Expiry set to [time.Time]{} (zero value)
// INPUT: Register request with Expiry set to time.Time{} (zero value)
// EXPECTED: Node registers successfully; zero time treated as no expiry
// WHY: Zero time is valid Go default; should be handled gracefully
{
@@ -1280,7 +1280,7 @@ func TestAuthenticationFlows(t *testing.T) {
},
// TEST: Malformed hostinfo with very long hostname is truncated
// WHAT: Tests that excessively long hostname is truncated to DNS label limit
// INPUT: [tailcfg.Hostinfo] with 110-character hostname (exceeds 63-char DNS limit)
// INPUT: Hostinfo with 110-character hostname (exceeds 63-char DNS limit)
// EXPECTED: Node registers successfully; hostname truncated to 63 characters
// WHY: Defensive code enforces DNS label limit (RFC 1123); prevents errors
{
@@ -1845,7 +1845,7 @@ func TestAuthenticationFlows(t *testing.T) {
},
// TEST: Logout with expiry exactly at current time
// WHAT: Tests logout when expiry is set to exact current time (boundary case)
// INPUT: Existing node sends request with expiry=[time.Now]() (not past, not future)
// INPUT: Existing node sends request with expiry=time.Now() (not past, not future)
// EXPECTED: Node is logged out (treated as expired)
// WHY: Edge case: current time should be treated as expired
{
@@ -2225,7 +2225,7 @@ func TestAuthenticationFlows(t *testing.T) {
},
// TEST: Interactive workflow with nil hostinfo
// WHAT: Tests interactive registration when request has nil hostinfo
// INPUT: Interactive registration request with [tailcfg.Hostinfo]=nil
// INPUT: Interactive registration request with Hostinfo=nil
// EXPECTED: Node registers successfully with generated default hostname
// WHY: Defensive code handles nil hostinfo in interactive flow
{
@@ -2761,7 +2761,7 @@ func TestNodeStoreLookup(t *testing.T) {
t.Logf("Registered node successfully: %+v", resp)
// Wait for node to be available in [state.NodeStore]
// Wait for node to be available in NodeStore
var node types.NodeView
require.EventuallyWithT(t, func(c *assert.CollectT) {
@@ -3072,7 +3072,7 @@ func TestWebFlowReauthDifferentUser(t *testing.T) {
})
t.Run("returned_node_is_user2_new_node", func(t *testing.T) {
// The node returned from [state.State.HandleNodeFromAuthPath] should be user2's NEW node
// The node returned from HandleNodeFromAuthPath should be user2's NEW node
assert.Equal(t, user2.ID, node.UserID().Get(), "Returned node should belong to user2")
assert.NotEqual(t, user1NodeID, node.ID(), "Returned node should be NEW, not transferred from user1")
t.Logf("✓ HandleNodeFromAuthPath returned user2's new node (ID: %d)", node.ID())
@@ -3166,7 +3166,7 @@ func createTestApp(t *testing.T) *Headscale {
// 1. Node registers successfully with a single-use pre-auth key
// 2. Node is running fine
// 3. Node restarts (e.g., after headscale upgrade or tailscale container restart)
// 4. Node sends [tailcfg.RegisterRequest] with the same pre-auth key
// 4. Node sends RegisterRequest with the same pre-auth key
// 5. BUG: Headscale rejects the request with "authkey expired" or "authkey already used"
//
// Expected behavior:
@@ -3223,7 +3223,7 @@ func TestGitHubIssue2830_NodeRestartWithUsedPreAuthKey(t *testing.T) {
require.NoError(t, err)
assert.True(t, usedPak.Used, "pre-auth key should be marked as used after initial registration")
// STEP 2: Simulate node restart - node sends [tailcfg.RegisterRequest] again with same pre-auth key
// STEP 2: Simulate node restart - node sends RegisterRequest again with same pre-auth key
// This happens when:
// - Tailscale container restarts
// - Tailscaled service restarts
@@ -3508,7 +3508,7 @@ func TestGitHubIssue2830_ExistingNodeCanReregisterWithUsedPreAuthKey(t *testing.
// WITHOUT THE FIX: This would fail with "authkey already used" error
// WITH THE FIX: This succeeds because it's the same node re-registering with its own key
// Simulate sending the same [tailcfg.RegisterRequest] again (same MachineKey, same AuthKey)
// Simulate sending the same RegisterRequest again (same MachineKey, same AuthKey)
// This is exactly what happens when a container restarts
reregisterReq := tailcfg.RegisterRequest{
Auth: &tailcfg.RegisterResponseAuth{
@@ -3787,15 +3787,15 @@ func TestAuthKeyTaggedToUserOwnedViaReauth(t *testing.T) {
nodeAfterReauth.IsTagged(), nodeAfterReauth.UserID().Get())
}
// TestDeletedPreAuthKeyNotRecreatedOnNodeUpdate tests that when a [types.PreAuthKey] is deleted,
// subsequent node updates (like those triggered by [tailcfg.MapRequest]s) do not recreate the key.
// TestDeletedPreAuthKeyNotRecreatedOnNodeUpdate tests that when a PreAuthKey is deleted,
// subsequent node updates (like those triggered by MapRequests) do not recreate the key.
//
// This reproduces the bug where:
// 1. Create a tagged preauthkey and register a node
// 2. Delete the preauthkey (confirmed gone from pre_auth_keys DB table)
// 3. Node sends [tailcfg.MapRequest] (e.g., after tailscaled restart)
// 3. Node sends MapRequest (e.g., after tailscaled restart)
// 4. BUG: The preauthkey reappears because GORM's Updates() upserts the stale AuthKey
// data that still exists in the [state.NodeStore]'s in-memory cache.
// data that still exists in the NodeStore's in-memory cache.
//
// The fix is to use Omit("AuthKey") on all node Updates() calls to prevent GORM
// from touching the AuthKey association.
@@ -3864,11 +3864,11 @@ func TestDeletedPreAuthKeyNotRecreatedOnNodeUpdate(t *testing.T) {
require.Nil(t, dbNode.AuthKeyID, "node's AuthKeyID should be NULL after PreAuthKey deletion")
t.Log("Node's AuthKeyID is NULL in database")
// The [state.NodeStore] may still have stale AuthKey data in memory.
// Now simulate what happens when the node sends a [tailcfg.MapRequest] after a tailscaled restart.
// This triggers [state.State.persistNodeToDB] which calls GORM's Updates().
// The NodeStore may still have stale AuthKey data in memory.
// Now simulate what happens when the node sends a MapRequest after a tailscaled restart.
// This triggers persistNodeToDB which calls GORM's Updates().
// Simulate a [tailcfg.MapRequest] by updating the node through the state layer
// Simulate a MapRequest by updating the node through the state layer
// This mimics what poll.go does when processing MapRequests
mapReq := tailcfg.MapRequest{
NodeKey: nodeKey.Public(),
@@ -3879,8 +3879,8 @@ func TestDeletedPreAuthKeyNotRecreatedOnNodeUpdate(t *testing.T) {
},
}
// Process the [tailcfg.MapRequest]-like update
// This calls [state.State.UpdateNodeFromMapRequest] which eventually calls [state.State.persistNodeToDB]
// Process the MapRequest-like update
// This calls UpdateNodeFromMapRequest which eventually calls persistNodeToDB
_, err = app.state.UpdateNodeFromMapRequest(node.ID(), mapReq)
require.NoError(t, err, "UpdateNodeFromMapRequest should succeed")
t.Log("Simulated MapRequest update completed")
@@ -3943,7 +3943,7 @@ func TestTaggedNodeWithoutUserToDifferentUser(t *testing.T) {
alice := app.state.CreateUserForTest("alice")
require.NotNil(t, alice, "Alice user should be created")
// Step 4: Re-register the node to alice via [state.State.HandleNodeFromAuthPath]
// Step 4: Re-register the node to alice via HandleNodeFromAuthPath
// This is what happens when running: headscale auth register --auth-id <id> --user alice
nodeKey2 := key.NewNode()
registrationID := types.MustAuthID()
@@ -3960,7 +3960,7 @@ func TestTaggedNodeWithoutUserToDifferentUser(t *testing.T) {
// This should NOT panic - before the fix, this would panic with:
// panic: runtime error: invalid memory address or nil pointer dereference
// at [types.UserView.Name] because the existing node has no User
// at UserView.Name() because the existing node has no User
nodeAfterReauth, _, err := app.state.HandleNodeFromAuthPath(
registrationID,
types.UserID(alice.ID),
@@ -3977,8 +3977,8 @@ func TestTaggedNodeWithoutUserToDifferentUser(t *testing.T) {
require.False(t, nodeAfterReauth.IsTagged(), "Node should no longer be tagged")
require.Empty(t, nodeAfterReauth.Tags().AsSlice(), "Node should have no tags")
// Verify [types.NodeView.Owner] works without panicking - this is what the mapper's
// [generateUserProfiles] calls, and it would panic with a nil pointer
// Verify Owner() works without panicking - this is what the mapper's
// generateUserProfiles calls, and it would panic with a nil pointer
// dereference if node.User was not set during the tag→user conversion.
owner := nodeAfterReauth.Owner()
require.True(t, owner.Valid(), "Owner should be valid after conversion (mapper would panic if nil)")
+18 -11
View File
@@ -12,17 +12,24 @@ import (
"tailscale.com/util/set"
)
// minVersionParts is the minimum number of version parts needed for major.minor.
const minVersionParts = 2
const (
// minVersionParts is the minimum number of version parts needed for major.minor.
minVersionParts = 2
// CanOldCodeBeCleanedUp is called at server startup to panic when
// [MinSupportedCapabilityVersion] has crossed a threshold at which a
// backwards-compat emit path can be deleted. Each entry pairs a
// [tailcfg.CapabilityVersion] threshold with the message identifying
// the code to remove; today there are none.
// legacyDERPCapVer is the capability version when LegacyDERP can be cleaned up.
legacyDERPCapVer = 111
)
// CanOldCodeBeCleanedUp is intended to be called on startup to see if
// there are old code that can ble cleaned up, entries should contain
// a CapVer where something can be cleaned up and a panic if it can.
// This is only intended to catch things in tests.
//
// All capability-version-gated cleanups should be registered here.
// All uses of Capability version checks should be listed here.
func CanOldCodeBeCleanedUp() {
if MinSupportedCapabilityVersion >= legacyDERPCapVer {
panic("LegacyDERP can be cleaned up in tail.go")
}
}
func tailscaleVersSorted() []string {
@@ -39,12 +46,12 @@ func capVersSorted() []tailcfg.CapabilityVersion {
return capVers
}
// TailscaleVersion returns the Tailscale version for the given [tailcfg.CapabilityVersion].
// TailscaleVersion returns the Tailscale version for the given CapabilityVersion.
func TailscaleVersion(ver tailcfg.CapabilityVersion) string {
return capVerToTailscaleVer[ver]
}
// CapabilityVersion returns the [tailcfg.CapabilityVersion] for the given Tailscale version.
// CapabilityVersion returns the CapabilityVersion for the given Tailscale version.
// It accepts both full versions (v1.90.1) and minor versions (v1.90).
func CapabilityVersion(ver string) tailcfg.CapabilityVersion {
if !strings.HasPrefix(ver, "v") {
@@ -108,7 +115,7 @@ func TailscaleLatestMajorMinor(n int, stripV bool) []string {
return majorSl[len(majorSl)-n:]
}
// CapVerLatest returns the n latest [tailcfg.CapabilityVersion] values.
// CapVerLatest returns the n latest CapabilityVersions.
func CapVerLatest(n int) []tailcfg.CapabilityVersion {
if n <= 0 {
return nil
+1 -3
View File
@@ -42,7 +42,6 @@ var tailscaleToCapVer = map[string]tailcfg.CapabilityVersion{
"v1.92": 131,
"v1.94": 131,
"v1.96": 133,
"v1.98": 138,
}
var capVerToTailscaleVer = map[tailcfg.CapabilityVersion]string{
@@ -78,7 +77,6 @@ var capVerToTailscaleVer = map[tailcfg.CapabilityVersion]string{
130: "v1.90",
131: "v1.92",
133: "v1.96",
138: "v1.98",
}
// SupportedMajorMinorVersions is the number of major.minor Tailscale versions supported.
@@ -86,4 +84,4 @@ const SupportedMajorMinorVersions = 10
// MinSupportedCapabilityVersion represents the minimum capability version
// supported by this Headscale instance (latest 10 minor versions)
const MinSupportedCapabilityVersion tailcfg.CapabilityVersion = 113
const MinSupportedCapabilityVersion tailcfg.CapabilityVersion = 109
+4 -4
View File
@@ -9,9 +9,10 @@ var tailscaleLatestMajorMinorTests = []struct {
stripV bool
expected []string
}{
{3, false, []string{"v1.94", "v1.96", "v1.98"}},
{2, true, []string{"1.96", "1.98"}},
{3, false, []string{"v1.92", "v1.94", "v1.96"}},
{2, true, []string{"1.94", "1.96"}},
{10, true, []string{
"1.78",
"1.80",
"1.82",
"1.84",
@@ -21,7 +22,6 @@ var tailscaleLatestMajorMinorTests = []struct {
"1.92",
"1.94",
"1.96",
"1.98",
}},
{0, false, nil},
}
@@ -30,7 +30,7 @@ var capVerMinimumTailscaleVersionTests = []struct {
input tailcfg.CapabilityVersion
expected string
}{
{113, "v1.80"},
{109, "v1.78"},
{32, "v1.24"},
{41, "v1.30"},
{46, "v1.32"},
+6 -6
View File
@@ -28,7 +28,7 @@ var (
ErrAPIKeyInvalidGeneration = errors.New("generated API key failed validation")
)
// CreateAPIKey creates a new [types.APIKey] in a user, and returns it.
// CreateAPIKey creates a new ApiKey in a user, and returns it.
func (hsdb *HSDatabase) CreateAPIKey(
expiration *time.Time,
) (string, *types.APIKey, error) {
@@ -84,7 +84,7 @@ func (hsdb *HSDatabase) CreateAPIKey(
return keyStr, &key, nil
}
// ListAPIKeys returns the list of [types.APIKey] values for a user.
// ListAPIKeys returns the list of ApiKeys for a user.
func (hsdb *HSDatabase) ListAPIKeys() ([]types.APIKey, error) {
keys := []types.APIKey{}
@@ -96,7 +96,7 @@ func (hsdb *HSDatabase) ListAPIKeys() ([]types.APIKey, error) {
return keys, nil
}
// GetAPIKey returns a [types.APIKey] for a given key.
// GetAPIKey returns a ApiKey for a given key.
func (hsdb *HSDatabase) GetAPIKey(prefix string) (*types.APIKey, error) {
key := types.APIKey{}
if result := hsdb.DB.First(&key, "prefix = ?", prefix); result.Error != nil {
@@ -106,7 +106,7 @@ func (hsdb *HSDatabase) GetAPIKey(prefix string) (*types.APIKey, error) {
return &key, nil
}
// GetAPIKeyByID returns a [types.APIKey] for a given id.
// GetAPIKeyByID returns a ApiKey for a given id.
func (hsdb *HSDatabase) GetAPIKeyByID(id uint64) (*types.APIKey, error) {
key := types.APIKey{}
if result := hsdb.DB.Find(&types.APIKey{ID: id}).First(&key); result.Error != nil {
@@ -116,7 +116,7 @@ func (hsdb *HSDatabase) GetAPIKeyByID(id uint64) (*types.APIKey, error) {
return &key, nil
}
// DestroyAPIKey destroys a [types.APIKey]. Returns error if the [types.APIKey]
// DestroyAPIKey destroys a ApiKey. Returns error if the ApiKey
// does not exist.
func (hsdb *HSDatabase) DestroyAPIKey(key types.APIKey) error {
if result := hsdb.DB.Unscoped().Delete(key); result.Error != nil {
@@ -126,7 +126,7 @@ func (hsdb *HSDatabase) DestroyAPIKey(key types.APIKey) error {
return nil
}
// ExpireAPIKey marks a [types.APIKey] as expired.
// ExpireAPIKey marks a ApiKey as expired.
func (hsdb *HSDatabase) ExpireAPIKey(key *types.APIKey) error {
err := hsdb.DB.Model(&key).Update("Expiration", time.Now()).Error
if err != nil {
-22
View File
@@ -722,28 +722,6 @@ WHERE tags IS NOT NULL AND tags != '[]' AND tags != '';
},
Rollback: func(db *gorm.DB) error { return nil },
},
{
// Clear zero-time node expiry values to NULL.
// Versions before 0.28 persisted a pointer to a zero
// time.Time as '0001-01-01 00:00:00+00:00' rather than
// NULL, which 0.29 reports as an expired node. This
// normalises the existing rows so the column once
// again means "no expiry" when unset.
ID: "202605221435-clear-zero-time-node-expiry",
Migrate: func(tx *gorm.DB) error {
err := tx.Exec(`
UPDATE nodes
SET expiry = NULL
WHERE expiry IS NOT NULL AND expiry < '1900-01-01';
`).Error
if err != nil {
return fmt.Errorf("clearing zero-time node expiry: %w", err)
}
return nil
},
Rollback: func(db *gorm.DB) error { return nil },
},
},
)
+1 -55
View File
@@ -122,7 +122,7 @@ func TestSQLiteMigrationAndDataValidation(t *testing.T) {
// Expected: tags = ["tag:server"] (no duplicates)
node4 := findNode("node4")
require.NotNil(t, node4, "node4 should exist")
assert.Equal(t, []string{"tag:server"}, node4.Tags.List(), "node4 should have tag:server without duplicates") //nolint:goconst // descriptive test assertions read better with the literal inline
assert.Equal(t, []string{"tag:server"}, node4.Tags, "node4 should have tag:server without duplicates")
// Node 5: user2 has no RequestTags
// Expected: tags = [] (unchanged)
@@ -144,60 +144,6 @@ func TestSQLiteMigrationAndDataValidation(t *testing.T) {
assert.NotContains(t, node7.Tags, "tag:forbidden", "node7 should NOT have tag:forbidden (unauthorized)")
},
},
// Test for the zero-time node expiry migration
// (202605221435-clear-zero-time-node-expiry). Pre-0.28 versions
// stored a zero time.Time as '0001-01-01 00:00:00+00:00' rather
// than NULL, which caused 0.29 to report those nodes as expired.
// Fixes: https://github.com/juanfont/headscale/issues/3284
{
dbPath: "testdata/sqlite/zero_time_expiry_migration_test.sql",
wantFunc: func(t *testing.T, hsdb *HSDatabase) {
t.Helper()
nodes, err := Read(hsdb.DB, func(rx *gorm.DB) (types.Nodes, error) {
return ListNodes(rx)
})
require.NoError(t, err)
require.Len(t, nodes, 5, "should have all 5 nodes")
byHostname := make(map[string]*types.Node, len(nodes))
for _, n := range nodes {
byHostname[n.Hostname] = n
}
// Node 1 had a zero-time expiry; should be cleared.
node1 := byHostname["node1"]
require.NotNil(t, node1, "node1 should exist")
assert.Nil(t, node1.Expiry, "node1 zero-time expiry should be cleared to NULL")
assert.False(t, node1.IsExpired(), "node1 should not be reported as expired")
// Node 2 already had NULL expiry; should still be NULL.
node2 := byHostname["node2"]
require.NotNil(t, node2, "node2 should exist")
assert.Nil(t, node2.Expiry, "node2 NULL expiry should be preserved")
assert.False(t, node2.IsExpired(), "node2 should not be reported as expired")
// Node 3 had a real future expiry; should be preserved.
node3 := byHostname["node3"]
require.NotNil(t, node3, "node3 should exist")
require.NotNil(t, node3.Expiry, "node3 future expiry should be preserved")
assert.Equal(t, 2099, node3.Expiry.UTC().Year(), "node3 expiry year should be 2099")
assert.False(t, node3.IsExpired(), "node3 with future expiry should not be expired")
// Node 4 had a real past expiry; should be preserved.
node4 := byHostname["node4"]
require.NotNil(t, node4, "node4 should exist")
require.NotNil(t, node4.Expiry, "node4 past expiry should be preserved")
assert.Equal(t, 2020, node4.Expiry.UTC().Year(), "node4 expiry year should be 2020")
assert.True(t, node4.IsExpired(), "node4 with past expiry should still be expired")
// Node 5 also had a zero-time expiry; should be cleared.
node5 := byHostname["node5"]
require.NotNil(t, node5, "node5 should exist")
assert.Nil(t, node5.Expiry, "node5 zero-time expiry should be cleared to NULL")
assert.False(t, node5.IsExpired(), "node5 should not be reported as expired")
},
},
}
for _, tt := range tests {
@@ -17,8 +17,8 @@ const (
fifty = 50 * time.Millisecond
)
// TestEphemeralGarbageCollectorGoRoutineLeak is a test for a goroutine leak in [EphemeralGarbageCollector].
// It creates a new [EphemeralGarbageCollector], schedules several nodes for deletion with a short expiry,
// TestEphemeralGarbageCollectorGoRoutineLeak is a test for a goroutine leak in EphemeralGarbageCollector().
// It creates a new EphemeralGarbageCollector, schedules several nodes for deletion with a short expiry,
// and verifies that the nodes are deleted when the expiry time passes, and then
// for any leaked goroutines after the garbage collector is closed.
func TestEphemeralGarbageCollectorGoRoutineLeak(t *testing.T) {
@@ -89,8 +89,8 @@ func TestEphemeralGarbageCollectorGoRoutineLeak(t *testing.T) {
t.Logf("Final number of goroutines: %d", runtime.NumGoroutine())
}
// TestEphemeralGarbageCollectorReschedule is a test for the rescheduling of nodes in [EphemeralGarbageCollector].
// It creates a new [EphemeralGarbageCollector], schedules a node for deletion with a longer expiry,
// TestEphemeralGarbageCollectorReschedule is a test for the rescheduling of nodes in EphemeralGarbageCollector().
// It creates a new EphemeralGarbageCollector, schedules a node for deletion with a longer expiry,
// and then reschedules it with a shorter expiry, and verifies that the node is deleted only once.
func TestEphemeralGarbageCollectorReschedule(t *testing.T) {
// Deletion tracking mechanism
@@ -145,8 +145,8 @@ func TestEphemeralGarbageCollectorReschedule(t *testing.T) {
deleteMutex.Unlock()
}
// TestEphemeralGarbageCollectorCancelAndReschedule is a test for the cancellation and rescheduling of nodes in [EphemeralGarbageCollector].
// It creates a new [EphemeralGarbageCollector], schedules a node for deletion, cancels it, and then reschedules it,
// TestEphemeralGarbageCollectorCancelAndReschedule is a test for the cancellation and rescheduling of nodes in EphemeralGarbageCollector().
// It creates a new EphemeralGarbageCollector, schedules a node for deletion, cancels it, and then reschedules it,
// and verifies that the node is deleted only once.
func TestEphemeralGarbageCollectorCancelAndReschedule(t *testing.T) {
// Deletion tracking mechanism
@@ -214,8 +214,8 @@ func TestEphemeralGarbageCollectorCancelAndReschedule(t *testing.T) {
deleteMutex.Unlock()
}
// TestEphemeralGarbageCollectorCloseBeforeTimerFires is a test for the closing of the [EphemeralGarbageCollector] before the timer fires.
// It creates a new [EphemeralGarbageCollector], schedules a node for deletion, closes the GC, and verifies that the node is not deleted.
// TestEphemeralGarbageCollectorCloseBeforeTimerFires is a test for the closing of the EphemeralGarbageCollector before the timer fires.
// It creates a new EphemeralGarbageCollector, schedules a node for deletion, closes the GC, and verifies that the node is not deleted.
func TestEphemeralGarbageCollectorCloseBeforeTimerFires(t *testing.T) {
// Deletion tracking
var (
@@ -264,7 +264,7 @@ func TestEphemeralGarbageCollectorCloseBeforeTimerFires(t *testing.T) {
deleteMutex.Unlock()
}
// TestEphemeralGarbageCollectorScheduleAfterClose verifies that calling [EphemeralGarbageCollector.Schedule] after [EphemeralGarbageCollector.Close]
// TestEphemeralGarbageCollectorScheduleAfterClose verifies that calling Schedule after Close
// is a no-op and doesn't cause any panics, goroutine leaks, or other issues.
func TestEphemeralGarbageCollectorScheduleAfterClose(t *testing.T) {
// Count initial goroutines to check for leaks
@@ -339,7 +339,7 @@ func TestEphemeralGarbageCollectorScheduleAfterClose(t *testing.T) {
}
// TestEphemeralGarbageCollectorConcurrentScheduleAndClose tests the behavior of the garbage collector
// when [EphemeralGarbageCollector.Schedule] and [EphemeralGarbageCollector.Close] are called concurrently from multiple goroutines.
// when Schedule and Close are called concurrently from multiple goroutines.
func TestEphemeralGarbageCollectorConcurrentScheduleAndClose(t *testing.T) {
// Count initial goroutines
initialGoroutines := runtime.NumGoroutine()
+3 -2
View File
@@ -49,7 +49,7 @@ type IPAllocator struct {
usedIPs netipx.IPSetBuilder
}
// NewIPAllocator returns a new [IPAllocator] singleton which
// NewIPAllocator returns a new IPAllocator singleton which
// can be used to hand out unique IP addresses within the
// provided IPv4 and IPv6 prefix. It needs to be created
// when headscale starts and needs to finish its read
@@ -272,7 +272,7 @@ func isTailscaleReservedIP(ip netip.Addr) bool {
}
// BackfillNodeIPs will take a database transaction, and
// iterate through all of the current nodes ([types.Node]) in headscale
// iterate through all of the current nodes in headscale
// and ensure it has IP addresses according to the current
// configuration.
// This means that if both IPv4 and IPv6 is set in the
@@ -346,6 +346,7 @@ func (db *HSDatabase) BackfillNodeIPs(i *IPAllocator) ([]string, error) {
// Use Updates() with Select() to only update IP fields, avoiding overwriting
// other fields like Expiry. We need Select() because Updates() alone skips
// zero values, but we DO want to update IPv4/IPv6 to nil when removing them.
// See issue #2862.
err := tx.Model(node).Select("ipv4", "ipv6").Updates(node).Error
if err != nil {
return fmt.Errorf("saving node(%d) after adding IPs: %w", node.ID, err)
+94 -10
View File
@@ -1,9 +1,11 @@
package db
import (
"encoding/json"
"errors"
"fmt"
"net/netip"
"slices"
"sort"
"strconv"
"sync"
@@ -15,6 +17,7 @@ import (
"github.com/juanfont/headscale/hscontrol/util/zlog/zf"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
"tailscale.com/net/tsaddr"
"tailscale.com/types/key"
"tailscale.com/util/dnsname"
)
@@ -109,7 +112,7 @@ func (hsdb *HSDatabase) getNode(uid types.UserID, name string) (*types.Node, err
})
}
// getNode finds a [types.Node] by name and user and returns the [types.Node] struct.
// getNode finds a Node by name and user and returns the Node struct.
func getNode(tx *gorm.DB, uid types.UserID, name string) (*types.Node, error) {
nodes, err := ListNodesByUser(tx, uid)
if err != nil {
@@ -129,7 +132,7 @@ func (hsdb *HSDatabase) GetNodeByID(id types.NodeID) (*types.Node, error) {
return GetNodeByID(hsdb.DB, id)
}
// GetNodeByID finds a [types.Node] by ID and returns the [types.Node] struct.
// GetNodeByID finds a Node by ID and returns the Node struct.
func GetNodeByID(tx *gorm.DB, id types.NodeID) (*types.Node, error) {
mach := types.Node{}
if result := tx.
@@ -147,7 +150,7 @@ func (hsdb *HSDatabase) GetNodeByMachineKey(machineKey key.MachinePublic) (*type
return GetNodeByMachineKey(hsdb.DB, machineKey)
}
// GetNodeByMachineKey finds a [types.Node] by its [key.MachinePublic] and returns the [types.Node] struct.
// GetNodeByMachineKey finds a Node by its MachineKey and returns the Node struct.
func GetNodeByMachineKey(
tx *gorm.DB,
machineKey key.MachinePublic,
@@ -168,7 +171,7 @@ func (hsdb *HSDatabase) GetNodeByNodeKey(nodeKey key.NodePublic) (*types.Node, e
return GetNodeByNodeKey(hsdb.DB, nodeKey)
}
// GetNodeByNodeKey finds a [types.Node] by its [key.NodePublic] and returns the [types.Node] struct.
// GetNodeByNodeKey finds a Node by its NodeKey and returns the Node struct.
func GetNodeByNodeKey(
tx *gorm.DB,
nodeKey key.NodePublic,
@@ -185,6 +188,87 @@ func GetNodeByNodeKey(
return &mach, nil
}
func (hsdb *HSDatabase) SetTags(
nodeID types.NodeID,
tags []string,
) error {
return hsdb.Write(func(tx *gorm.DB) error {
return SetTags(tx, nodeID, tags)
})
}
// SetTags takes a NodeID and update the forced tags.
// It will overwrite any tags with the new list.
func SetTags(
tx *gorm.DB,
nodeID types.NodeID,
tags []string,
) error {
if len(tags) == 0 {
// if no tags are provided, we remove all tags
err := tx.Model(&types.Node{}).Where("id = ?", nodeID).Update("tags", "[]").Error
if err != nil {
return fmt.Errorf("removing tags: %w", err)
}
return nil
}
slices.Sort(tags)
tags = slices.Compact(tags)
b, err := json.Marshal(tags)
if err != nil {
return err
}
err = tx.Model(&types.Node{}).Where("id = ?", nodeID).Update("tags", string(b)).Error
if err != nil {
return fmt.Errorf("updating tags: %w", err)
}
return nil
}
// SetApprovedRoutes takes a Node struct pointer and updates the approved routes.
func SetApprovedRoutes(
tx *gorm.DB,
nodeID types.NodeID,
routes []netip.Prefix,
) error {
if len(routes) == 0 {
// if no routes are provided, we remove all
err := tx.Model(&types.Node{}).Where("id = ?", nodeID).Update("approved_routes", "[]").Error
if err != nil {
return fmt.Errorf("removing approved routes: %w", err)
}
return nil
}
// When approving exit routes, ensure both IPv4 and IPv6 are included
// If either 0.0.0.0/0 or ::/0 is being approved, both should be approved
hasIPv4Exit := slices.Contains(routes, tsaddr.AllIPv4())
hasIPv6Exit := slices.Contains(routes, tsaddr.AllIPv6())
if hasIPv4Exit && !hasIPv6Exit {
routes = append(routes, tsaddr.AllIPv6())
} else if hasIPv6Exit && !hasIPv4Exit {
routes = append(routes, tsaddr.AllIPv4())
}
b, err := json.Marshal(routes)
if err != nil {
return err
}
if err := tx.Model(&types.Node{}).Where("id = ?", nodeID).Update("approved_routes", string(b)).Error; err != nil { //nolint:noinlineerr
return fmt.Errorf("updating approved routes: %w", err)
}
return nil
}
// SetLastSeen sets a node's last seen field indicating that we
// have recently communicating with this node.
func (hsdb *HSDatabase) SetLastSeen(nodeID types.NodeID, lastSeen time.Time) error {
@@ -199,7 +283,7 @@ func SetLastSeen(tx *gorm.DB, nodeID types.NodeID, lastSeen time.Time) error {
return tx.Model(&types.Node{}).Where("id = ?", nodeID).Update("last_seen", lastSeen).Error
}
// RenameNode takes a [types.Node] struct and a new [types.Node.GivenName] for the nodes
// RenameNode takes a Node struct and a new GivenName for the nodes
// and renames it. Validation should be done in the state layer before calling this function.
func RenameNode(tx *gorm.DB,
nodeID types.NodeID, newName string,
@@ -245,7 +329,7 @@ func (hsdb *HSDatabase) DeleteNode(node *types.Node) error {
})
}
// DeleteNode deletes a [types.Node] from the database.
// DeleteNode deletes a Node from the database.
// Caller is responsible for notifying all of change.
func DeleteNode(tx *gorm.DB,
node *types.Node,
@@ -259,7 +343,7 @@ func DeleteNode(tx *gorm.DB,
return nil
}
// DeleteEphemeralNode deletes a [types.Node] from the database, note that this method
// DeleteEphemeralNode deletes a Node from the database, note that this method
// will remove it straight, and not notify any changes or consider any routes.
// It is intended for Ephemeral nodes.
func (hsdb *HSDatabase) DeleteEphemeralNode(
@@ -276,7 +360,7 @@ func (hsdb *HSDatabase) DeleteEphemeralNode(
}
// RegisterNodeForTest is used only for testing purposes to register a node directly in the database.
// Production code should use [state.State.HandleNodeFromAuthPath] or [state.State.HandleNodeFromPreAuthKey].
// Production code should use state.HandleNodeFromAuthPath or state.HandleNodeFromPreAuthKey.
func RegisterNodeForTest(tx *gorm.DB, node types.Node, ipv4 *netip.Addr, ipv6 *netip.Addr) (*types.Node, error) {
if !testing.Testing() {
panic("RegisterNodeForTest can only be called during tests")
@@ -387,7 +471,7 @@ func NodeSetMachineKey(
// EphemeralGarbageCollector is a garbage collector that will delete nodes after
// a certain amount of time.
// It is used to delete ephemeral nodes ([types.Node.IsEphemeral]) that have disconnected and should be
// It is used to delete ephemeral nodes that have disconnected and should be
// cleaned up.
type EphemeralGarbageCollector struct {
mu sync.Mutex
@@ -399,7 +483,7 @@ type EphemeralGarbageCollector struct {
cancelCh chan struct{}
}
// NewEphemeralGarbageCollector creates a new [EphemeralGarbageCollector], it takes
// NewEphemeralGarbageCollector creates a new EphemeralGarbageCollector, it takes
// a deleteFunc that will be called when a node is scheduled for deletion.
func NewEphemeralGarbageCollector(deleteFunc func(types.NodeID)) *EphemeralGarbageCollector {
return &EphemeralGarbageCollector{
+52 -4
View File
@@ -178,6 +178,55 @@ func TestDisableNodeExpiry(t *testing.T) {
assert.Nil(t, nodeFromDB.Expiry, "expiry should be nil after disabling")
}
func TestSetTags(t *testing.T) {
db, err := newSQLiteTestDB()
require.NoError(t, err)
user, err := db.CreateUser(types.User{Name: "test"})
require.NoError(t, err)
pak, err := db.CreatePreAuthKey(user.TypedID(), false, false, nil, nil)
require.NoError(t, err)
pakID := pak.ID
_, err = db.getNode(types.UserID(user.ID), "testnode")
require.Error(t, err)
nodeKey := key.NewNode()
machineKey := key.NewMachine()
node := &types.Node{
ID: 0,
MachineKey: machineKey.Public(),
NodeKey: nodeKey.Public(),
Hostname: "testnode",
UserID: &user.ID,
RegisterMethod: util.RegisterMethodAuthKey,
AuthKeyID: &pakID,
}
trx := db.DB.Save(node)
require.NoError(t, trx.Error)
// assign simple tags
sTags := []string{"tag:test", "tag:foo"}
err = db.SetTags(node.ID, sTags)
require.NoError(t, err)
node, err = db.getNode(types.UserID(user.ID), "testnode")
require.NoError(t, err)
assert.Equal(t, sTags, node.Tags)
// assign duplicate tags, expect no errors but no doubles in DB
eTags := []string{"tag:bar", "tag:test", "tag:unknown", "tag:test"}
err = db.SetTags(node.ID, eTags)
require.NoError(t, err)
node, err = db.getNode(types.UserID(user.ID), "testnode")
require.NoError(t, err)
assert.Equal(t, []string{"tag:bar", "tag:test", "tag:unknown"}, node.Tags)
}
func TestAutoApproveRoutes(t *testing.T) {
tests := []struct {
name string
@@ -360,15 +409,13 @@ func TestAutoApproveRoutes(t *testing.T) {
assert.Equal(t, tt.expectChange, changed1)
if changed1 {
node.ApprovedRoutes = types.Prefixes(newRoutes1)
err = adb.DB.Save(&node).Error
err = SetApprovedRoutes(adb.DB, node.ID, newRoutes1)
require.NoError(t, err)
}
newRoutes2, changed2 := policy.ApproveRoutesWithPolicy(pm, nodeTagged.View(), nodeTagged.ApprovedRoutes, tt.routes)
if changed2 {
nodeTagged.ApprovedRoutes = types.Prefixes(newRoutes2)
err = adb.DB.Save(&nodeTagged).Error
err = SetApprovedRoutes(adb.DB, nodeTagged.ID, newRoutes2)
require.NoError(t, err)
}
@@ -578,6 +625,7 @@ func TestListEphemeralNodes(t *testing.T) {
assert.Equal(t, nodeEph.Hostname, ephemeralNodes[0].Hostname)
}
func TestListPeers(t *testing.T) {
// Setup test database
db, err := newSQLiteTestDB()
+2 -2
View File
@@ -31,7 +31,7 @@ func (hsdb *HSDatabase) GetPolicy() (*types.Policy, error) {
}
// GetPolicy returns the latest policy from the database.
// This standalone function can be used in contexts where [HSDatabase] is not available,
// This standalone function can be used in contexts where HSDatabase is not available,
// such as during migrations.
func GetPolicy(tx *gorm.DB) (*types.Policy, error) {
var p types.Policy
@@ -55,7 +55,7 @@ func GetPolicy(tx *gorm.DB) (*types.Policy, error) {
// PolicyBytes loads policy configuration from file or database based on the configured mode.
// Returns nil if no policy is configured, which is valid.
// This standalone function can be used in contexts where [HSDatabase] is not available,
// This standalone function can be used in contexts where HSDatabase is not available,
// such as during migrations.
func PolicyBytes(tx *gorm.DB, cfg *types.Config) ([]byte, error) {
switch cfg.Policy.Mode {
+8 -8
View File
@@ -40,7 +40,7 @@ const (
authKeyLength = 64
)
// CreatePreAuthKey creates a new [types.PreAuthKey] in a user, and returns it.
// CreatePreAuthKey creates a new PreAuthKey in a user, and returns it.
// The uid parameter can be nil for system-created tagged keys.
// For tagged keys, uid tracks "created by" (who created the key).
// For user-owned keys, uid tracks the node owner.
@@ -158,7 +158,7 @@ func (hsdb *HSDatabase) ListPreAuthKeys() ([]types.PreAuthKey, error) {
return Read(hsdb.DB, ListPreAuthKeys)
}
// ListPreAuthKeys returns all [types.PreAuthKey] values in the database.
// ListPreAuthKeys returns all PreAuthKeys in the database.
func ListPreAuthKeys(tx *gorm.DB) ([]types.PreAuthKey, error) {
var keys []types.PreAuthKey
@@ -170,7 +170,7 @@ func ListPreAuthKeys(tx *gorm.DB) ([]types.PreAuthKey, error) {
return keys, nil
}
// ListPreAuthKeysByUser returns all [types.PreAuthKey] values belonging to a specific user.
// ListPreAuthKeysByUser returns all PreAuthKeys belonging to a specific user.
func ListPreAuthKeysByUser(tx *gorm.DB, uid types.UserID) ([]types.PreAuthKey, error) {
var keys []types.PreAuthKey
@@ -290,13 +290,13 @@ func (hsdb *HSDatabase) GetPreAuthKey(key string) (*types.PreAuthKey, error) {
return GetPreAuthKey(hsdb.DB, key)
}
// GetPreAuthKey returns a [types.PreAuthKey] for a given key. The caller is responsible
// GetPreAuthKey returns a PreAuthKey for a given key. The caller is responsible
// for checking if the key is usable (expired or used).
func GetPreAuthKey(tx *gorm.DB, key string) (*types.PreAuthKey, error) {
return findAuthKey(tx, key)
}
// DestroyPreAuthKey destroys a preauthkey. Returns error if the [types.PreAuthKey]
// DestroyPreAuthKey destroys a preauthkey. Returns error if the PreAuthKey
// does not exist. This also clears the auth_key_id on any nodes that reference
// this key.
func DestroyPreAuthKey(tx *gorm.DB, id uint64) error {
@@ -331,10 +331,10 @@ func (hsdb *HSDatabase) DeletePreAuthKey(id uint64) error {
})
}
// UsePreAuthKey atomically marks a [types.PreAuthKey] as used. The UPDATE is
// UsePreAuthKey atomically marks a PreAuthKey as used. The UPDATE is
// guarded by `used = false` so two concurrent registrations racing for
// the same single-use key cannot both succeed: the first commits and
// the second returns [types.PAKError]("authkey already used"). Without the
// the second returns PAKError("authkey already used"). Without the
// guard the previous code (Update("used", true) with no WHERE) would
// silently let both transactions claim the key.
func UsePreAuthKey(tx *gorm.DB, k *types.PreAuthKey) error {
@@ -354,7 +354,7 @@ func UsePreAuthKey(tx *gorm.DB, k *types.PreAuthKey) error {
return nil
}
// ExpirePreAuthKey marks a [types.PreAuthKey] as expired.
// ExpirePreAuthKey marks a PreAuthKey as expired.
func ExpirePreAuthKey(tx *gorm.DB, id uint64) error {
now := time.Now()
return tx.Model(&types.PreAuthKey{}).Where("id = ?", id).Update("expiration", now).Error
@@ -1,82 +0,0 @@
-- Test SQL dump for zero-time node expiry migration
-- (202605221435-clear-zero-time-node-expiry)
--
-- Pre-0.28 versions of Headscale persisted a zero time.Time as the string
-- '0001-01-01 00:00:00+00:00' in nodes.expiry instead of NULL. Upgrading
-- to 0.29 surfaces those rows as "expired" because they look like a
-- timestamp at year 1. This dump exercises the data fix.
-- Fixes: https://github.com/juanfont/headscale/issues/3284
PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION;
-- Migrations table: all entries BEFORE the zero-time fix have been applied.
-- The new migration is intentionally absent so it runs against this dump.
CREATE TABLE `migrations` (`id` text,PRIMARY KEY (`id`));
INSERT INTO migrations VALUES('202312101416');
INSERT INTO migrations VALUES('202312101430');
INSERT INTO migrations VALUES('202402151347');
INSERT INTO migrations VALUES('2024041121742');
INSERT INTO migrations VALUES('202406021630');
INSERT INTO migrations VALUES('202409271400');
INSERT INTO migrations VALUES('202407191627');
INSERT INTO migrations VALUES('202408181235');
INSERT INTO migrations VALUES('202501221827');
INSERT INTO migrations VALUES('202501311657');
INSERT INTO migrations VALUES('202502070949');
INSERT INTO migrations VALUES('202502131714');
INSERT INTO migrations VALUES('202502171819');
INSERT INTO migrations VALUES('202505091439');
INSERT INTO migrations VALUES('202505141324');
INSERT INTO migrations VALUES('202507021200');
INSERT INTO migrations VALUES('202510311551');
INSERT INTO migrations VALUES('202511101554-drop-old-idx');
INSERT INTO migrations VALUES('202511011637-preauthkey-bcrypt');
INSERT INTO migrations VALUES('202511122344-remove-newline-index');
INSERT INTO migrations VALUES('202511131445-node-forced-tags-to-tags');
INSERT INTO migrations VALUES('202601121700-migrate-hostinfo-request-tags');
INSERT INTO migrations VALUES('202602201200-clear-tagged-node-user-id');
-- Users table
CREATE TABLE `users` (`id` integer PRIMARY KEY AUTOINCREMENT,`created_at` datetime,`updated_at` datetime,`deleted_at` datetime,`name` text,`display_name` text,`email` text,`provider_identifier` text,`provider` text,`profile_pic_url` text);
INSERT INTO users VALUES(1,'2024-01-01 00:00:00+00:00','2024-01-01 00:00:00+00:00',NULL,'user1','User One','user1@example.com',NULL,NULL,NULL);
-- Pre-auth keys table
CREATE TABLE `pre_auth_keys` (`id` integer PRIMARY KEY AUTOINCREMENT,`key` text,`user_id` integer,`reusable` numeric,`ephemeral` numeric DEFAULT false,`used` numeric DEFAULT false,`tags` text,`created_at` datetime,`expiration` datetime,`prefix` text,`hash` blob,CONSTRAINT `fk_pre_auth_keys_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE SET NULL);
-- API keys table
CREATE TABLE `api_keys` (`id` integer PRIMARY KEY AUTOINCREMENT,`prefix` text,`hash` blob,`created_at` datetime,`expiration` datetime,`last_seen` datetime);
-- Nodes table - current schema (after the tags rename + last_seen/expiry reordering)
CREATE TABLE IF NOT EXISTS "nodes" (`id` integer PRIMARY KEY AUTOINCREMENT,`machine_key` text,`node_key` text,`disco_key` text,`endpoints` text,`host_info` text,`ipv4` text,`ipv6` text,`hostname` text,`given_name` varchar(63),`user_id` integer,`register_method` text,`tags` text,`auth_key_id` integer,`last_seen` datetime,`expiry` datetime,`approved_routes` text,`created_at` datetime,`updated_at` datetime,`deleted_at` datetime,CONSTRAINT `fk_nodes_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE,CONSTRAINT `fk_nodes_auth_key` FOREIGN KEY (`auth_key_id`) REFERENCES `pre_auth_keys`(`id`));
-- Node 1: zero-time expiry. After migration: expiry IS NULL.
INSERT INTO nodes VALUES(1,'mkey:a0ab77456320823945ae0331823e3c0d516fae9585bd42698dfa1ac3d7679e01','nodekey:7c84167ab68f494942de14deb83587fd841843de2bac105b6c670048c1605501','discokey:53075b3c6cad3b62a2a29caea61beeb93f66b8c75cb89dac465236a5bbf57701','[]','{}','100.64.0.1','fd7a:115c:a1e0::1','node1','node1',1,'cli','[]',NULL,'2024-01-01 00:00:00+00:00','0001-01-01 00:00:00+00:00','[]','2024-01-01 00:00:00+00:00','2024-01-01 00:00:00+00:00',NULL);
-- Node 2: NULL expiry already. After migration: still NULL.
INSERT INTO nodes VALUES(2,'mkey:a0ab77456320823945ae0331823e3c0d516fae9585bd42698dfa1ac3d7679e02','nodekey:7c84167ab68f494942de14deb83587fd841843de2bac105b6c670048c1605502','discokey:53075b3c6cad3b62a2a29caea61beeb93f66b8c75cb89dac465236a5bbf57702','[]','{}','100.64.0.2','fd7a:115c:a1e0::2','node2','node2',1,'cli','[]',NULL,'2024-01-01 00:00:00+00:00',NULL,'[]','2024-01-01 00:00:00+00:00','2024-01-01 00:00:00+00:00',NULL);
-- Node 3: real future expiry. After migration: preserved.
INSERT INTO nodes VALUES(3,'mkey:a0ab77456320823945ae0331823e3c0d516fae9585bd42698dfa1ac3d7679e03','nodekey:7c84167ab68f494942de14deb83587fd841843de2bac105b6c670048c1605503','discokey:53075b3c6cad3b62a2a29caea61beeb93f66b8c75cb89dac465236a5bbf57703','[]','{}','100.64.0.3','fd7a:115c:a1e0::3','node3','node3',1,'cli','[]',NULL,'2024-01-01 00:00:00+00:00','2099-01-01 00:00:00+00:00','[]','2024-01-01 00:00:00+00:00','2024-01-01 00:00:00+00:00',NULL);
-- Node 4: real past expiry (legitimately expired). After migration: preserved.
INSERT INTO nodes VALUES(4,'mkey:a0ab77456320823945ae0331823e3c0d516fae9585bd42698dfa1ac3d7679e04','nodekey:7c84167ab68f494942de14deb83587fd841843de2bac105b6c670048c1605504','discokey:53075b3c6cad3b62a2a29caea61beeb93f66b8c75cb89dac465236a5bbf57704','[]','{}','100.64.0.4','fd7a:115c:a1e0::4','node4','node4',1,'cli','[]',NULL,'2024-01-01 00:00:00+00:00','2020-01-01 00:00:00+00:00','[]','2024-01-01 00:00:00+00:00','2024-01-01 00:00:00+00:00',NULL);
-- Node 5: another zero-time row to confirm the WHERE clause matches multiple rows.
INSERT INTO nodes VALUES(5,'mkey:a0ab77456320823945ae0331823e3c0d516fae9585bd42698dfa1ac3d7679e05','nodekey:7c84167ab68f494942de14deb83587fd841843de2bac105b6c670048c1605505','discokey:53075b3c6cad3b62a2a29caea61beeb93f66b8c75cb89dac465236a5bbf57705','[]','{}','100.64.0.5','fd7a:115c:a1e0::5','node5','node5',1,'cli','[]',NULL,'2024-01-01 00:00:00+00:00','0001-01-01 00:00:00+00:00','[]','2024-01-01 00:00:00+00:00','2024-01-01 00:00:00+00:00',NULL);
-- Policies table (empty)
CREATE TABLE `policies` (`id` integer PRIMARY KEY AUTOINCREMENT,`created_at` datetime,`updated_at` datetime,`deleted_at` datetime,`data` text);
DELETE FROM sqlite_sequence;
INSERT INTO sqlite_sequence VALUES('users',1);
INSERT INTO sqlite_sequence VALUES('nodes',5);
CREATE INDEX idx_users_deleted_at ON users(deleted_at);
CREATE UNIQUE INDEX idx_api_keys_prefix ON api_keys(prefix);
CREATE INDEX idx_policies_deleted_at ON policies(deleted_at);
CREATE UNIQUE INDEX idx_provider_identifier ON users(provider_identifier) WHERE provider_identifier IS NOT NULL;
CREATE UNIQUE INDEX idx_name_provider_identifier ON users(name, provider_identifier);
CREATE UNIQUE INDEX idx_name_no_provider_identifier ON users(name) WHERE provider_identifier IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_pre_auth_keys_prefix ON pre_auth_keys(prefix) WHERE prefix IS NOT NULL AND prefix != '';
COMMIT;
+6 -6
View File
@@ -24,7 +24,7 @@ func isTextUnmarshaler(rv reflect.Value) bool {
}
func maybeInstantiatePtr(rv reflect.Value) {
if rv.Kind() == reflect.Pointer && rv.IsNil() {
if rv.Kind() == reflect.Ptr && rv.IsNil() {
np := reflect.New(rv.Type().Elem())
rv.Set(np)
}
@@ -34,8 +34,8 @@ func decodingError(name string, err error) error {
return fmt.Errorf("decoding to %s: %w", name, err)
}
// TextSerialiser implements the [schema.SerializerInterface] for fields that
// have a type that implements [encoding.TextUnmarshaler].
// TextSerialiser implements the Serialiser interface for fields that
// have a type that implements encoding.TextUnmarshaler.
type TextSerialiser struct{}
func (TextSerialiser) Scan(ctx context.Context, field *schema.Field, dst reflect.Value, dbValue any) error {
@@ -43,7 +43,7 @@ func (TextSerialiser) Scan(ctx context.Context, field *schema.Field, dst reflect
// If the field is a pointer, we need to dereference it to get the actual type
// so we do not end with a second pointer.
if fieldValue.Elem().Kind() == reflect.Pointer {
if fieldValue.Elem().Kind() == reflect.Ptr {
fieldValue = fieldValue.Elem()
}
@@ -76,7 +76,7 @@ func (TextSerialiser) Scan(ctx context.Context, field *schema.Field, dst reflect
// If it is not a pointer, we need to assign the value to the
// field.
dstField := field.ReflectValueOf(ctx, dst)
if dstField.Kind() == reflect.Pointer {
if dstField.Kind() == reflect.Ptr {
dstField.Set(fieldValue)
} else {
dstField.Set(fieldValue.Elem())
@@ -97,7 +97,7 @@ func (TextSerialiser) Value(ctx context.Context, field *schema.Field, dst reflec
// If the value is nil, we return nil, however, go nil values are not
// always comparable, particularly when reflection is involved:
// https://dev.to/arxeiss/in-go-nil-is-not-equal-to-nil-sometimes-jn8
if v == nil || (reflect.ValueOf(v).Kind() == reflect.Pointer && reflect.ValueOf(v).IsNil()) {
if v == nil || (reflect.ValueOf(v).Kind() == reflect.Ptr && reflect.ValueOf(v).IsNil()) {
return nil, nil //nolint:nilnil // intentional: nil value for GORM serializer
}
+4 -4
View File
@@ -25,7 +25,7 @@ func (hsdb *HSDatabase) CreateUser(user types.User) (*types.User, error) {
})
}
// CreateUser creates a new [types.User]. Returns error if could not be created
// 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)
@@ -47,7 +47,7 @@ func (hsdb *HSDatabase) DestroyUser(uid types.UserID) error {
})
}
// DestroyUser destroys a [types.User]. Returns error if the [types.User] does
// DestroyUser destroys a User. Returns error if the User does
// not exist or if there are user-owned nodes associated with it.
// Tagged nodes have user_id = NULL so they do not block deletion.
func DestroyUser(tx *gorm.DB, uid types.UserID) error {
@@ -92,8 +92,8 @@ func (hsdb *HSDatabase) RenameUser(uid types.UserID, newName string) error {
var ErrCannotChangeOIDCUser = errors.New("cannot edit OIDC user")
// RenameUser renames a [types.User]. Returns error if the [types.User] does
// not exist or if another [types.User] exists with the new name.
// RenameUser renames a User. Returns error if the User does
// not exist or if another User exists with the new name.
func RenameUser(tx *gorm.DB, uid types.UserID, newName string) error {
var err error
+1 -1
View File
@@ -123,7 +123,7 @@ func TestDestroyUserErrors(t *testing.T) {
result := db.DB.First(&survivingNode, "id = ?", node.ID)
require.NoError(t, result.Error)
assert.Nil(t, survivingNode.UserID)
assert.Equal(t, []string{"tag:server"}, survivingNode.Tags.List())
assert.Equal(t, []string{"tag:server"}, survivingNode.Tags)
},
},
{
+3 -43
View File
@@ -3,7 +3,6 @@ package db
import (
"errors"
"fmt"
"regexp"
"strconv"
"strings"
"time"
@@ -85,7 +84,7 @@ func parseVersion(s string) (semver, error) {
}
// ensureDatabaseVersionTable creates the database_versions table if it
// does not already exist. Uses [gorm.DB.AutoMigrate] to handle dialect
// does not already exist. Uses GORM AutoMigrate to handle dialect
// differences between SQLite (datetime) and PostgreSQL (timestamp).
// This runs before gormigrate migrations.
func ensureDatabaseVersionTable(db *gorm.DB) error {
@@ -140,49 +139,10 @@ func setDatabaseVersion(db *gorm.DB, version string) error {
return nil
}
// pseudoVersionTimeLayout is Go's pseudo-version timestamp layout
// (golang.org/ref/mod#pseudo-versions): UTC yyyymmddhhmmss.
const pseudoVersionTimeLayout = "20060102150405"
// pseudoVersionSuffix matches the trailing "<sep><14 digits>-<12
// lowercase hex>" of a Go module pseudo-version. The base form
// (vX.0.0-<date>-<hash>) uses "-" before the timestamp; the
// pre-release-ancestor and release-ancestor forms
// (vX.Y.Z-pre.0.<date>-<hash> and vX.Y.(Z+1)-0.<date>-<hash>) use "."
// because the digit-only "0" marker precedes the timestamp.
var pseudoVersionSuffix = regexp.MustCompile(`[-.](\d{14})-[0-9a-f]{12}$`)
// pseudoVersionTime returns the embedded commit time when v is a
// syntactically and semantically valid Go module pseudo-version. The
// timestamp must parse as a real UTC time; lookalikes with malformed
// dates (e.g. month 13, day 30 in February) are rejected.
func pseudoVersionTime(v string) (time.Time, bool) {
m := pseudoVersionSuffix.FindStringSubmatch(v)
if m == nil {
return time.Time{}, false
}
t, err := time.Parse(pseudoVersionTimeLayout, m[1])
if err != nil {
return time.Time{}, false
}
return t, true
}
// isDev reports whether a version string represents a development build
// that should skip version checking. Go module pseudo-versions (used by
// untagged main-sha builds, where runtime/debug.BuildInfo falls back to
// vX.Y.Z-<timestamp>-<commit>) are treated as dev to avoid poisoning
// database_versions with synthetic baselines.
// that should skip version checking.
func isDev(version string) bool {
if version == "" || version == "dev" || version == "(devel)" {
return true
}
_, ok := pseudoVersionTime(version)
return ok
return version == "" || version == "dev" || version == "(devel)"
}
// checkVersionUpgradePath verifies that the running headscale version
+5 -200
View File
@@ -3,7 +3,6 @@ package db
import (
"fmt"
"testing"
"time"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
@@ -57,206 +56,12 @@ func TestSemverString(t *testing.T) {
assert.Equal(t, "v0.28.3", s.String())
}
func TestPseudoVersionTime(t *testing.T) {
parseTS := func(s string) time.Time {
t.Helper()
ts, err := time.Parse(pseudoVersionTimeLayout, s)
require.NoError(t, err)
return ts
}
tests := []struct {
name string
input string
wantOK bool
wantTime time.Time
}{
// Accept: all three Go pseudo-version shapes.
{
name: "no ancestor tag (v0.0.0 base)",
input: "v0.0.0-20260522092201-58a85b68b3d9",
wantOK: true,
wantTime: parseTS("20260522092201"),
},
{
name: "ancestor is pre-release tag",
input: "v0.29.0-beta.1.0.20260522092201-58a85b68b3d9",
wantOK: true,
wantTime: parseTS("20260522092201"),
},
{
name: "ancestor is release tag",
input: "v0.29.1-0.20260522092201-58a85b68b3d9",
wantOK: true,
wantTime: parseTS("20260522092201"),
},
{
name: "earliest realistic Go module date",
input: "v0.0.0-20180101000000-000000000000",
wantOK: true,
wantTime: parseTS("20180101000000"),
},
// Reject: real release tags must not look like pseudo-versions.
{name: "release tag", input: "v0.29.0"},
{name: "pre-release tag", input: "v0.29.0-beta.1"},
{name: "rc tag", input: "v0.29.0-rc1"},
{name: "tag with build metadata", input: "v0.29.0+build123"},
// Reject: literals handled elsewhere.
{name: "empty", input: ""},
{name: "dev literal", input: "dev"},
{name: "devel literal", input: "(devel)"},
// Reject: malformed hash.
{name: "hash too short", input: "v0.0.0-20260522092201-58a85b6"},
{name: "hash too long", input: "v0.0.0-20260522092201-58a85b68b3d9aa"},
{name: "hash uppercase hex", input: "v0.0.0-20260522092201-58A85B68B3D9"},
{name: "hash non-hex", input: "v0.0.0-20260522092201-zzzzzzzzzzzz"},
// Reject: malformed timestamp.
{name: "timestamp too short", input: "v0.0.0-2026052209220-58a85b68b3d9"},
{name: "timestamp too long", input: "v0.0.0-202605220922010-58a85b68b3d9"},
{name: "invalid month", input: "v0.0.0-20261322092201-58a85b68b3d9"},
{name: "invalid day", input: "v0.0.0-20260230092201-58a85b68b3d9"},
{name: "invalid hour", input: "v0.0.0-20260522252201-58a85b68b3d9"},
{name: "invalid minute", input: "v0.0.0-20260522096001-58a85b68b3d9"},
{name: "invalid second", input: "v0.0.0-20260522092260-58a85b68b3d9"},
{name: "leap day on non-leap year", input: "v0.0.0-20230229000000-58a85b68b3d9"},
// Reject: missing components.
{name: "missing date and hash", input: "v0.0.0-"},
{name: "missing hash", input: "v0.0.0-20260522092201-"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := pseudoVersionTime(tt.input)
assert.Equal(t, tt.wantOK, ok)
if tt.wantOK {
assert.True(t, got.Equal(tt.wantTime),
"want %s, got %s", tt.wantTime, got)
}
})
}
}
func TestIsDev(t *testing.T) {
tests := []struct {
name string
input string
want bool
}{
// Existing literals.
{name: "empty", input: "", want: true},
{name: "dev", input: "dev", want: true},
{name: "devel", input: "(devel)", want: true},
{name: "release tag", input: "v0.28.0", want: false},
{name: "release tag no v", input: "0.28.0", want: false},
{name: "pre-release tag", input: "v0.29.0-beta.1", want: false},
// Go module pseudo-versions — all three shapes Go emits per
// golang.org/ref/mod#pseudo-versions. Untagged commits
// (such as main-sha docker builds) must be treated as dev
// so they neither poison database_versions nor trip the
// upgrade-path guard.
{
name: "pseudo v0.0.0 base",
input: "v0.0.0-20260522092201-58a85b68b3d9",
want: true,
},
{
name: "pseudo from pre-release ancestor",
input: "v0.29.0-beta.1.0.20260522092201-58a85b68b3d9",
want: true,
},
{
name: "pseudo from release ancestor",
input: "v0.29.1-0.20260522092201-58a85b68b3d9",
want: true,
},
// Malformed pseudo-version lookalikes must NOT be treated
// as dev — they fall through to the semver parser.
{
name: "malformed timestamp not dev",
input: "v0.0.0-20261322092201-58a85b68b3d9",
want: false,
},
{
name: "hash wrong length not dev",
input: "v0.0.0-20260522092201-58a85b6",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, isDev(tt.input))
})
}
}
// TestCheckVersionUpgradePath_StoredPseudoVersion exercises the
// upgrade path when database_versions holds a Go module pseudo-version
// written by an untagged main-sha build. Without dev handling, the
// stored pseudo-version parses as v0.0.0 and the next real release
// trips the multi-minor guard.
func TestCheckVersionUpgradePath_StoredPseudoVersion(t *testing.T) {
tests := []struct {
name string
stored string
currentVersion string
}{
{
name: "v0.0.0 base pseudo to real release",
stored: "v0.0.0-20260520093041-e4e742c776ee",
currentVersion: "v0.29.0-beta.1",
},
{
name: "pseudo from pre-release ancestor",
stored: "v0.29.0-beta.1.0.20260520093041-e4e742c776ee",
currentVersion: "v0.29.0",
},
{
name: "pseudo from release ancestor",
stored: "v0.28.1-0.20260520093041-e4e742c776ee",
currentVersion: "v0.29.0",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
db := versionTestDB(t)
require.NoError(t, setDatabaseVersion(db, tt.stored))
err := checkVersionUpgradePathFromVersions(db, tt.currentVersion)
assert.NoError(t, err)
})
}
}
// TestCheckVersionUpgradePath_CurrentPseudoDoesNotPoison locks the
// contract that a main-sha (pseudo-version) binary must preserve the
// stored real release so the next real release can upgrade cleanly.
// Mirrors the gating in db.go around setDatabaseVersion.
func TestCheckVersionUpgradePath_CurrentPseudoDoesNotPoison(t *testing.T) {
db := versionTestDB(t)
require.NoError(t, setDatabaseVersion(db, "v0.28.0"))
current := "v0.0.0-20260522092201-58a85b68b3d9"
err := checkVersionUpgradePathFromVersions(db, current)
require.NoError(t, err)
// Mirror db.go: only write the current version when !isDev.
if !isDev(current) {
require.NoError(t, setDatabaseVersion(db, current))
}
stored, err := getDatabaseVersion(db)
require.NoError(t, err)
assert.Equal(t, "v0.28.0", stored,
"pseudo-version run must not overwrite stored release")
assert.True(t, isDev(""))
assert.True(t, isDev("dev"))
assert.True(t, isDev("(devel)"))
assert.False(t, isDev("v0.28.0"))
assert.False(t, isDev("0.28.0"))
}
// versionTestDB creates an in-memory SQLite database with the
+12 -12
View File
@@ -19,9 +19,9 @@ import (
"tailscale.com/tsweb"
)
// protectedDebugHandler wraps an [http.Handler] with an access check that
// protectedDebugHandler wraps an http.Handler with an access check that
// allows requests from loopback, Tailscale CGNAT IPs, and private
// (RFC 1918 / RFC 4193) addresses. This extends [tsweb.Protected] which
// (RFC 1918 / RFC 4193) addresses. This extends tsweb.Protected which
// only allows loopback and Tailscale IPs.
func protectedDebugHandler(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -31,7 +31,7 @@ func protectedDebugHandler(h http.Handler) http.Handler {
return
}
// [tsweb.AllowDebugAccess] rejects X-Forwarded-For and non-TS IPs.
// tsweb.AllowDebugAccess rejects X-Forwarded-For and non-TS IPs.
// Additionally allow private/LAN addresses so operators can reach
// debug endpoints from their local network without tailscaled.
ipStr, _, err := net.SplitHostPort(r.RemoteAddr)
@@ -177,7 +177,7 @@ func (h *Headscale) debugHTTPServer() *http.Server {
}
}))
// [state.NodeStore] endpoint
// NodeStore endpoint
debug.Handle("nodestore", "NodeStore information", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check Accept header to determine response format
acceptHeader := r.Header.Get("Accept")
@@ -301,7 +301,7 @@ func (h *Headscale) debugHTTPServer() *http.Server {
_, _ = w.Write(resJSON)
}))
// [mapper.Batcher] endpoint
// Batcher endpoint
debug.Handle("batcher", "Batcher connected nodes", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check Accept header to determine response format
acceptHeader := r.Header.Get("Accept")
@@ -329,7 +329,7 @@ func (h *Headscale) debugHTTPServer() *http.Server {
}
}))
// Ping endpoint: sends a [tailcfg.PingRequest] to a node and waits for it to respond.
// 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 (
@@ -361,12 +361,12 @@ func (h *Headscale) debugHTTPServer() *http.Server {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(templates.PingPage(query, result, nodes).Render())) //nolint:gosec // G705: templ component auto-escapes
_, _ = w.Write([]byte(templates.PingPage(query, result, nodes).Render()))
}))
// [statsviz.Register] would mount handlers directly on the raw mux,
// 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].
// each handler with protectedDebugHandler.
statsvizSrv, err := statsviz.NewServer()
if err == nil {
debugMux.Handle("/debug/statsviz/", protectedDebugHandler(statsvizSrv.Index()))
@@ -402,9 +402,9 @@ func (h *Headscale) debugBatcher() string {
activeConnections int
}
debugInfo := h.mapBatcher.Debug()
nodes := make([]nodeStatus, 0, len(debugInfo))
var nodes []nodeStatus
debugInfo := h.mapBatcher.Debug()
for nodeID, info := range debugInfo {
nodes = append(nodes, nodeStatus{
id: nodeID,
@@ -510,7 +510,7 @@ func (h *Headscale) connectedNodesList() []templates.ConnectedNode {
const pingTimeout = 30 * time.Second
// doPing sends a [tailcfg.PingRequest] to the node identified by query and waits for a response.
// 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{
+5 -5
View File
@@ -73,11 +73,11 @@ func loadDERPMapFromURL(addr url.URL) (*tailcfg.DERPMap, error) {
return &derpMap, err
}
// mergeDERPMaps naively merges a list of [tailcfg.DERPMap] values into a single
// [tailcfg.DERPMap], it will _only_ look at the Regions, an integer.
// If a region exists in two of the given [tailcfg.DERPMap] values, the region
// form the _last_ [tailcfg.DERPMap] will be preserved.
// An empty [tailcfg.DERPMap] list will result in a [tailcfg.DERPMap] with no regions.
// mergeDERPMaps naively merges a list of DERPMaps into a single
// DERPMap, it will _only_ look at the Regions, an integer.
// If a region exists in two of the given DERPMaps, the region
// form the _last_ DERPMap will be preserved.
// An empty DERPMap list will result in a DERPMap with no regions.
func mergeDERPMaps(derpMaps []*tailcfg.DERPMap) *tailcfg.DERPMap {
result := tailcfg.DERPMap{
OmitDefaultRegions: false,
+2 -2
View File
@@ -30,7 +30,7 @@ type ExtraRecordsMan struct {
hashes map[string][32]byte
}
// NewExtraRecordsManager creates a new [ExtraRecordsMan] and starts watching the file at the given path.
// NewExtraRecordsManager creates a new ExtraRecordsMan and starts watching the file at the given path.
func NewExtraRecordsManager(path string) (*ExtraRecordsMan, error) {
watcher, err := fsnotify.NewWatcher()
if err != nil {
@@ -177,7 +177,7 @@ func (e *ExtraRecordsMan) updateRecords() {
e.updateCh <- e.records.Slice()
}
// readExtraRecordsFromPath reads a JSON file of [tailcfg.DNSRecord]
// readExtraRecordsFromPath reads a JSON file of tailcfg.DNSRecord
// and returns the records and the hash of the file.
func readExtraRecordsFromPath(path string) ([]tailcfg.DNSRecord, [32]byte, error) {
b, err := os.ReadFile(path)
+9 -9
View File
@@ -59,7 +59,7 @@ func (api headscaleV1APIServer) CreateUser(
return nil, status.Errorf(codes.Internal, "creating user: %s", err)
}
// [state.State.CreateUser] returns a policy change response if the user creation affected policy.
// CreateUser returns a policy change response if the user creation affected policy.
// This triggers a full policy re-evaluation for all connected nodes.
api.h.Change(policyChanged)
@@ -105,7 +105,7 @@ func (api headscaleV1APIServer) DeleteUser(
return nil, err
}
// Use the change returned from [state.State.DeleteUser] which includes proper policy updates
// Use the change returned from DeleteUser which includes proper policy updates
api.h.Change(policyChanged)
return &v1.DeleteUserResponse{}, nil
@@ -293,7 +293,7 @@ func (api headscaleV1APIServer) RegisterNode(
return nil, fmt.Errorf("auto approving routes: %w", err)
}
// Send both changes. Empty changes are ignored by [Headscale.Change].
// Send both changes. Empty changes are ignored by Change().
api.h.Change(nodeChange, routeChange)
return &v1.RegisterNodeResponse{Node: node.Proto()}, nil
@@ -396,11 +396,11 @@ func (api headscaleV1APIServer) SetApprovedRoutes(
return nil, status.Error(codes.InvalidArgument, err.Error())
}
// Always propagate node changes from [state.State.SetApprovedRoutes]
// Always propagate node changes from SetApprovedRoutes
api.h.Change(nodeChange)
proto := node.Proto()
// Populate [types.Node.SubnetRoutes] with [tailcfg.Node.PrimaryRoutes] to ensure it includes only the
// Populate SubnetRoutes with PrimaryRoutes to ensure it includes only the
// routes that are actively served from the node (per architectural requirement in types/node.go)
primaryRoutes := api.h.state.GetNodePrimaryRoutes(node.ID())
proto.SubnetRoutes = util.PrefixesToString(primaryRoutes)
@@ -554,7 +554,7 @@ func nodesToProto(state *state.State, nodes views.Slice[types.NodeView]) []*v1.N
for index, node := range nodes.All() {
resp := node.Proto()
// Tags-as-identity: tagged nodes show as [types.TaggedDevices] user in API responses
// Tags-as-identity: tagged nodes show as TaggedDevices user in API responses
// (UserID may be set internally for "created by" tracking)
if node.IsTagged() {
resp.User = types.TaggedDevices.Proto()
@@ -852,9 +852,9 @@ func (api headscaleV1APIServer) DebugCreateNode(
authRegReq := types.NewRegisterAuthRequest(regData)
api.h.state.SetAuthCacheEntry(registrationId, authRegReq)
// Echo back a synthetic [types.Node] so the debug response surface stays
// stable. The actual node is created later by [headscaleV1APIServer.AuthApprove] via
// [state.State.HandleNodeFromAuthPath] using the cached [types.RegistrationData].
// Echo back a synthetic Node so the debug response surface stays
// stable. The actual node is created later by AuthApprove via
// HandleNodeFromAuthPath using the cached RegistrationData.
echoNode := types.Node{
NodeKey: regData.NodeKey,
MachineKey: regData.MachineKey,
+17 -13
View File
@@ -155,7 +155,7 @@ func TestSetTags_Conversion(t *testing.T) {
}
}
// TestSetTags_TaggedNode tests that [headscaleV1APIServer.SetTags] correctly identifies tagged nodes
// TestSetTags_TaggedNode tests that SetTags correctly identifies tagged nodes
// and doesn't reject them with the "user-owned nodes" error.
// Note: This test doesn't validate ACL tag authorization - that's tested elsewhere.
func TestSetTags_TaggedNode(t *testing.T) {
@@ -193,7 +193,7 @@ func TestSetTags_TaggedNode(t *testing.T) {
// Create API server instance
apiServer := newHeadscaleV1APIServer(app)
// Test: [headscaleV1APIServer.SetTags] should work on tagged nodes.
// Test: SetTags should work on tagged nodes.
resp, err := apiServer.SetTags(context.Background(), &v1.SetTagsRequest{
NodeId: uint64(taggedNode.ID()),
Tags: []string{"tag:initial"}, // Keep existing tag to avoid ACL validation issues
@@ -212,7 +212,7 @@ func TestSetTags_TaggedNode(t *testing.T) {
}
}
// TestSetTags_CannotRemoveAllTags tests that [headscaleV1APIServer.SetTags] rejects attempts to remove
// TestSetTags_CannotRemoveAllTags tests that SetTags rejects attempts to remove
// all tags from a tagged node, enforcing Tailscale's requirement that tagged
// nodes must have at least one tag.
func TestSetTags_CannotRemoveAllTags(t *testing.T) {
@@ -265,8 +265,9 @@ func TestSetTags_CannotRemoveAllTags(t *testing.T) {
}
// TestSetTags_ClearsUserIDInDatabase tests that converting a user-owned node
// to a tagged node via [headscaleV1APIServer.SetTags] correctly persists user_id = NULL in the
// to a tagged node via SetTags correctly persists user_id = NULL in the
// database, not just in-memory.
// https://github.com/juanfont/headscale/issues/3161
func TestSetTags_ClearsUserIDInDatabase(t *testing.T) {
t.Parallel()
@@ -308,7 +309,7 @@ func TestSetTags_ClearsUserIDInDatabase(t *testing.T) {
nodeID := node.ID()
// Convert to tagged via [headscaleV1APIServer.SetTags] API.
// Convert to tagged via SetTags API.
apiServer := newHeadscaleV1APIServer(app)
_, err = apiServer.SetTags(context.Background(), &v1.SetTagsRequest{
NodeId: uint64(nodeID),
@@ -403,8 +404,9 @@ func TestSetTags_NodeDisappearsFromUserListing(t *testing.T) {
assert.Contains(t, allResp.GetNodes()[0].GetTags(), "tag:web")
}
// TestSetTags_NodeStoreAndDBConsistency verifies that after [headscaleV1APIServer.SetTags], the
// in-memory [state.NodeStore] and the database agree on the node's ownership state.
// TestSetTags_NodeStoreAndDBConsistency verifies that after SetTags, the
// in-memory NodeStore and the database agree on the node's ownership state.
// https://github.com/juanfont/headscale/issues/3161
func TestSetTags_NodeStoreAndDBConsistency(t *testing.T) {
t.Parallel()
@@ -476,8 +478,9 @@ func TestSetTags_NodeStoreAndDBConsistency(t *testing.T) {
// TestSetTags_UserDeletionDoesNotCascadeToTaggedNode tests that deleting the
// original user does not cascade-delete a node that was converted to tagged
// via [headscaleV1APIServer.SetTags]. This catches the real-world consequence of stale user_id:
// via SetTags. This catches the real-world consequence of stale user_id:
// ON DELETE CASCADE would destroy the tagged node.
// https://github.com/juanfont/headscale/issues/3161
func TestSetTags_UserDeletionDoesNotCascadeToTaggedNode(t *testing.T) {
t.Parallel()
@@ -528,7 +531,7 @@ func TestSetTags_UserDeletionDoesNotCascadeToTaggedNode(t *testing.T) {
_, err = app.state.DeleteUser(*user.TypedID())
require.NoError(t, err)
// The tagged node must survive in both [state.NodeStore] and database.
// The tagged node must survive in both NodeStore and database.
nsNode, found := app.state.GetNodeByID(nodeID)
require.True(t, found, "tagged node must survive user deletion in NodeStore")
assert.True(t, nsNode.IsTagged())
@@ -552,7 +555,7 @@ func TestDeleteUser_ReturnsProperChangeSignal(t *testing.T) {
require.NotNil(t, user)
// Delete the user and verify a non-empty change is returned
// Without the fix, [state.State.DeleteUser] returned an empty change,
// Issue #2967: Without the fix, DeleteUser returned an empty change,
// causing stale policy state until another user operation triggered an update.
changeSignal, err := app.state.DeleteUser(*user.TypedID())
require.NoError(t, err, "DeleteUser should succeed")
@@ -561,7 +564,8 @@ func TestDeleteUser_ReturnsProperChangeSignal(t *testing.T) {
// TestDeleteUser_TaggedNodeSurvives tests that deleting a user succeeds when
// the user's only nodes are tagged, and that those nodes remain in the
// [state.NodeStore] with nil UserID.
// NodeStore with nil UserID.
// https://github.com/juanfont/headscale/issues/3077
func TestDeleteUser_TaggedNodeSurvives(t *testing.T) {
t.Parallel()
@@ -601,7 +605,7 @@ func TestDeleteUser_TaggedNodeSurvives(t *testing.T) {
nodeID := node.ID()
// [state.NodeStore] should not list the tagged node under any user.
// NodeStore should not list the tagged node under any user.
nodesForUser := app.state.ListNodesByUser(types.UserID(user.ID))
assert.Equal(t, 0, nodesForUser.Len(),
"tagged nodes should not appear in nodesByUser index")
@@ -611,7 +615,7 @@ func TestDeleteUser_TaggedNodeSurvives(t *testing.T) {
require.NoError(t, err)
assert.False(t, changeSignal.IsEmpty())
// Tagged node survives in the [state.NodeStore].
// Tagged node survives in the NodeStore.
nodeAfter, found := app.state.GetNodeByID(nodeID)
require.True(t, found, "tagged node should survive user deletion")
assert.True(t, nodeAfter.IsTagged())
+2 -2
View File
@@ -129,7 +129,7 @@ func parseCapabilityVersion(req *http.Request) (tailcfg.CapabilityVersion, error
}
// verifyBodyLimit caps the request body for /verify. The DERP verify
// protocol payload ([tailcfg.DERPAdmitClientRequest]) is a few hundred
// protocol payload (tailcfg.DERPAdmitClientRequest) is a few hundred
// bytes; 4 KiB is generous and prevents an unauthenticated client from
// OOMing the public router with arbitrarily large POSTs.
const verifyBodyLimit int64 = 4 * 1024
@@ -358,7 +358,7 @@ func authIDFromRequest(req *http.Request) (types.AuthID, error) {
// Listens in /register/:registration_id.
//
// This is not part of the Tailscale control API, as we could send whatever URL
// in the [tailcfg.RegisterResponse.AuthURL] field.
// in the RegisterResponse.AuthURL field.
func (a *AuthProviderWeb) RegisterHandler(
writer http.ResponseWriter,
req *http.Request,
+3 -3
View File
@@ -15,8 +15,8 @@ import (
var errTestUnexpected = errors.New("unexpected failure")
// TestHandleVerifyRequest_OversizedBodyRejected verifies that the
// /verify handler refuses POST bodies larger than [verifyBodyLimit].
// The [http.MaxBytesReader] is applied in [Headscale.VerifyHandler], so we simulate
// /verify handler refuses POST bodies larger than verifyBodyLimit.
// The MaxBytesReader is applied in VerifyHandler, so we simulate
// the same wrapping here.
func TestHandleVerifyRequest_OversizedBodyRejected(t *testing.T) {
t.Parallel()
@@ -47,7 +47,7 @@ func TestHandleVerifyRequest_OversizedBodyRejected(t *testing.T) {
"oversized body must surface 413")
}
// errorAsHTTPError is a small local helper that unwraps an [HTTPError]
// errorAsHTTPError is a small local helper that unwraps an HTTPError
// from an error chain.
func errorAsHTTPError(err error) (HTTPError, bool) {
var h HTTPError
+18 -20
View File
@@ -27,7 +27,7 @@ var (
)
// offlineNodeCleanupThreshold is how long a node must be disconnected
// before [Batcher.cleanupOfflineNodes] removes its in-memory state.
// before cleanupOfflineNodes removes its in-memory state.
const offlineNodeCleanupThreshold = 15 * time.Minute
var mapResponseGenerated = promauto.NewCounterVec(prometheus.CounterOpts{
@@ -49,7 +49,7 @@ func NewBatcher(batchTime time.Duration, workers int, mapper *mapper) *Batcher {
}
}
// NewBatcherAndMapper creates a new [Batcher] with its [mapper].
// NewBatcherAndMapper creates a new Batcher with its mapper.
func NewBatcherAndMapper(cfg *types.Config, state *state.State) *Batcher {
m := newMapper(cfg, state)
b := NewBatcher(cfg.Tuning.BatchChangeDelay, cfg.Tuning.BatcherWorkers, m)
@@ -69,7 +69,7 @@ type nodeConnection interface {
updateSentPeers(resp *tailcfg.MapResponse)
}
// generateMapResponse generates a [tailcfg.MapResponse] for the given [types.NodeID] based on the provided [change.Change].
// generateMapResponse generates a [tailcfg.MapResponse] for the given NodeID based on the provided [change.Change].
func generateMapResponse(nc nodeConnection, mapper *mapper, r change.Change) (*tailcfg.MapResponse, error) {
nodeID := nc.nodeID()
version := nc.version()
@@ -130,19 +130,17 @@ func generateMapResponse(nc nodeConnection, mapper *mapper, r change.Change) (*t
// When a full update (SendAllPeers=true) produces zero visible peers
// (e.g., a restrictive policy isolates this node), the resulting
// [tailcfg.MapResponse] has Peers: []*tailcfg.Node{} (empty non-nil slice).
// MapResponse has Peers: []*tailcfg.Node{} (empty non-nil slice).
//
// The Tailscale client only treats Peers as a full authoritative
// replacement when len(Peers) > 0 (controlclient/map.go:462).
// An empty Peers slice is indistinguishable from a delta response,
// so the client silently preserves its existing peer state.
//
// This matters when a [change.FullUpdate] replaces a pending
// [change.PolicyChange] in the batcher ([Batcher.addToBatch]
// short-circuits on [change.HasFull]). The [change.PolicyChange]
// would have computed PeersRemoved via
// [multiChannelNodeConn.computePeerDiff], but the [change.FullUpdate]
// path uses [MapResponseBuilder.WithPeers] which sets Peers: [].
// This matters when a FullUpdate() replaces a pending PolicyChange()
// in the batcher (addToBatch short-circuits on HasFull). The
// PolicyChange would have computed PeersRemoved via computePeerDiff,
// but the FullUpdate path uses WithPeers which sets Peers: [].
//
// Fix: when a full update results in zero peers, compute the diff
// against lastSentPeers and add explicit PeersRemoved entries so
@@ -208,7 +206,7 @@ type workResult struct {
// work represents a unit of work to be processed by workers.
// All pending changes for a node are bundled into a single work item
// so that one worker processes them sequentially. This prevents
// out-of-order [tailcfg.MapResponse] delivery and races on lastSentPeers
// out-of-order MapResponse delivery and races on lastSentPeers
// that occur when multiple workers process changes for the same node.
type work struct {
changes []change.Change
@@ -227,9 +225,9 @@ var (
// Batcher batches and distributes map responses to connected nodes.
// It uses concurrent maps, per-node mutexes, and a worker pool.
//
// Lifecycle: Call [Batcher.Start] to spawn workers, then [Batcher.Close]
// to shut down. [Batcher.Close] blocks until all workers have exited.
// A [Batcher] must not be reused after [Batcher.Close].
// Lifecycle: Call Start() to spawn workers, then Close() to shut down.
// Close() blocks until all workers have exited. A Batcher must not
// be reused after Close().
type Batcher struct {
tick *time.Ticker
mapper *mapper
@@ -553,11 +551,11 @@ func (b *Batcher) addToBatch(changes ...change.Change) {
// still has it registered. By cleaning up here, we prevent "node not found"
// errors when workers try to generate map responses for deleted nodes.
//
// Safety: [change.Change.PeersRemoved] is ONLY populated when nodes are actually
// deleted from the system (via [change.NodeRemoved] in [state.State.DeleteNode]).
// Policy changes that affect peer visibility do NOT use this field - they set
// Safety: change.Change.PeersRemoved is ONLY populated when nodes are actually
// deleted from the system (via change.NodeRemoved in state.DeleteNode). Policy
// changes that affect peer visibility do NOT use this field - they set
// RequiresRuntimePeerComputation=true and compute removed peers at runtime,
// putting them in [tailcfg.MapResponse.PeersRemoved] (a different struct).
// putting them in tailcfg.MapResponse.PeersRemoved (a different struct).
// Therefore, this cleanup only removes nodes that are truly being deleted,
// not nodes that are still connected but have lost visibility of certain peers.
//
@@ -640,8 +638,8 @@ func (b *Batcher) processBatchedChanges() {
}
// cleanupOfflineNodes removes nodes that have been offline for too long to prevent memory leaks.
// Uses xsync.Map.Compute for atomic check-and-delete to prevent TOCTOU races where a node
// reconnects between the hasActiveConnections check and the Delete call.
// Uses Compute() for atomic check-and-delete to prevent TOCTOU races where a node
// reconnects between the hasActiveConnections() check and the Delete() call.
func (b *Batcher) cleanupOfflineNodes() {
var nodesToCleanup []types.NodeID
+8 -8
View File
@@ -158,14 +158,14 @@ type node struct {
//
// Returns TestData struct containing all created entities and a cleanup function.
func setupBatcherWithTestData(
tb testing.TB,
t testing.TB,
bf batcherFunc,
userCount, nodesPerUser, bufferSize int,
) (*TestData, func()) {
tb.Helper()
t.Helper()
// Create database and populate with test data first
tmpDir := tb.TempDir()
tmpDir := t.TempDir()
dbPath := tmpDir + "/headscale_test.db"
prefixV4 := netip.MustParsePrefix("100.64.0.0/10")
@@ -206,7 +206,7 @@ func setupBatcherWithTestData(
// Create database and populate it with test data
database, err := db.NewHeadscaleDatabase(cfg)
if err != nil {
tb.Fatalf("setting up database: %s", err)
t.Fatalf("setting up database: %s", err)
}
// Create test users and nodes in the database
@@ -226,12 +226,12 @@ func setupBatcherWithTestData(
// Now create state using the same database
state, err := state.NewState(cfg)
if err != nil {
tb.Fatalf("Failed to create state: %v", err)
t.Fatalf("Failed to create state: %v", err)
}
derpMap, err := derp.GetDERPMap(cfg.DERP)
require.NoError(tb, err)
require.NotNil(tb, derpMap)
require.NoError(t, err)
require.NotNil(t, derpMap)
state.SetDERPMap(derpMap)
@@ -248,7 +248,7 @@ func setupBatcherWithTestData(
_, err = state.SetPolicy([]byte(allowAllPolicy))
if err != nil {
tb.Fatalf("Failed to set allow-all policy: %v", err)
t.Fatalf("Failed to set allow-all policy: %v", err)
}
// Create batcher with the state and wrap it for testing
+8 -11
View File
@@ -14,7 +14,7 @@ import (
"tailscale.com/util/multierr"
)
// MapResponseBuilder provides a fluent interface for building [tailcfg.MapResponse].
// MapResponseBuilder provides a fluent interface for building tailcfg.MapResponse.
type MapResponseBuilder struct {
resp *tailcfg.MapResponse
mapper *mapper
@@ -180,10 +180,6 @@ func (b *MapResponseBuilder) WithUserProfiles(peers views.Slice[types.NodeView])
}
// WithPacketFilters adds packet filter rules based on policy.
//
// [State.FilterForNode] returns rules already reduced to only those relevant for this node.
// For autogroup:self policies, it returns per-node compiled rules.
// For global policies, it returns the global filter reduced for this node.
func (b *MapResponseBuilder) WithPacketFilters() *MapResponseBuilder {
node, ok := b.mapper.state.GetNodeByID(b.nodeID)
if !ok {
@@ -191,6 +187,9 @@ func (b *MapResponseBuilder) WithPacketFilters() *MapResponseBuilder {
return b
}
// FilterForNode returns rules already reduced to only those relevant for this node.
// For autogroup:self policies, it returns per-node compiled rules.
// For global policies, it returns the global filter reduced for this node.
filter, err := b.mapper.state.FilterForNode(node)
if err != nil {
b.addError(err)
@@ -234,8 +233,7 @@ func (b *MapResponseBuilder) WithPeerChanges(peers views.Slice[types.NodeView])
return b
}
// buildTailPeers converts [views.Slice] of [types.NodeView] to a slice of [tailcfg.Node]
// with policy filtering and sorting.
// buildTailPeers converts views.Slice[types.NodeView] to []tailcfg.Node with policy filtering and sorting.
func (b *MapResponseBuilder) buildTailPeers(peers views.Slice[types.NodeView]) ([]*tailcfg.Node, error) {
node, ok := b.mapper.state.GetNodeByID(b.nodeID)
if !ok {
@@ -243,10 +241,9 @@ func (b *MapResponseBuilder) buildTailPeers(peers views.Slice[types.NodeView]) (
}
// Get unreduced matchers for peer relationship determination.
// [State.MatchersForNode] returns unreduced matchers that include all rules where the
// node could be either source or destination. This is different from
// [State.FilterForNode] which returns reduced rules for packet filtering (only rules
// where node is destination).
// MatchersForNode returns unreduced matchers that include all rules where the node
// could be either source or destination. This is different from FilterForNode which
// returns reduced rules for packet filtering (only rules where node is destination).
matchers, err := b.mapper.state.MatchersForNode(node)
if err != nil {
return nil, err
+10 -18
View File
@@ -69,7 +69,7 @@ func newMapper(
}
}
// generateUserProfiles creates user profiles for [tailcfg.MapResponse].
// generateUserProfiles creates user profiles for MapResponse.
func generateUserProfiles(
node types.NodeView,
peers views.Slice[types.NodeView],
@@ -267,7 +267,7 @@ func addNextDNSMetadata(resolvers []*dnstype.Resolver, node types.NodeView) {
}
}
// fullMapResponse returns a [tailcfg.MapResponse] for the given node.
// fullMapResponse returns a MapResponse for the given node.
//
//nolint:unused
func (m *mapper) fullMapResponse(
@@ -312,20 +312,13 @@ func (m *mapper) selfMapResponse(
return ma, err
}
// policyChangeResponse creates a [tailcfg.MapResponse] for policy changes.
// policyChangeResponse creates a MapResponse for policy changes.
// It sends:
// - PeersRemoved for peers that are no longer visible after the policy change
// - PeersChanged for remaining peers (their AllowedIPs may have changed due to policy)
// - Updated PacketFilters
// - Updated SSHPolicy (SSH rules may reference users/groups that changed)
// - DNSConfig so the client's resolver state stays anchored even when a
// policy-triggered wgengine reconfigure races a netmon LinkChange (the
// LinkChange handler reapplies dns.Manager.Set with the engine's
// lastDNSConfig; if that snapshot is stale, the OS resolver loses the
// MagicDNS reverse-DNS routes and Nameservers and curl-by-FQDN stops
// resolving for the rest of the policy window).
// - Optionally, the node's own self info (when includeSelf is true)
//
// - PeersRemoved for peers that are no longer visible after the policy change
// - PeersChanged for remaining peers (their AllowedIPs may have changed due to policy)
// - Updated PacketFilters
// - Updated SSHPolicy (SSH rules may reference users/groups that changed)
// - Optionally, the node's own self info (when includeSelf is true)
// This avoids the issue where an empty Peers slice is interpreted by Tailscale
// clients as "no change" rather than "no peers".
// When includeSelf is true, the node's self info is included so that a node
@@ -341,7 +334,6 @@ func (m *mapper) policyChangeResponse(
builder := m.NewMapResponseBuilder(nodeID).
WithDebugType(policyResponseDebug).
WithCapabilityVersion(capVer).
WithDNSConfig().
WithPacketFilters().
WithSSHPolicy()
@@ -350,7 +342,7 @@ func (m *mapper) policyChangeResponse(
}
if len(removedPeers) > 0 {
// Convert [tailcfg.NodeID] to [types.NodeID] for [MapResponseBuilder.WithPeersRemoved]
// Convert tailcfg.NodeID to types.NodeID for WithPeersRemoved
removedIDs := make([]types.NodeID, len(removedPeers))
for i, id := range removedPeers {
removedIDs[i] = types.NodeID(id) //nolint:gosec // NodeID types are equivalent
@@ -371,7 +363,7 @@ func (m *mapper) policyChangeResponse(
return builder.Build()
}
// buildFromChange builds a [tailcfg.MapResponse] from a [change.Change] specification.
// buildFromChange builds a MapResponse from a change.Change specification.
// This provides fine-grained control over what gets included in the response.
func (m *mapper) buildFromChange(
nodeID types.NodeID,
+13 -13
View File
@@ -17,10 +17,10 @@ import (
"tailscale.com/tailcfg"
)
// errNoActiveConnections is returned by [multiChannelNodeConn.send] when a node
// has no active connections (disconnected but kept in the batcher for rapid
// reconnection). Callers must not update peer tracking state (lastSentPeers)
// after this error because the data was never delivered to any client.
// errNoActiveConnections is returned by send when a node has no active
// connections (disconnected but kept in the batcher for rapid reconnection).
// Callers must not update peer tracking state (lastSentPeers) after this
// error because the data was never delivered to any client.
var errNoActiveConnections = errors.New("no active connections")
// connectionEntry represents a single connection to a node.
@@ -51,9 +51,9 @@ type multiChannelNodeConn struct {
// workMu serializes change processing for this node across batch ticks.
// Without this, two workers could process consecutive ticks' bundles
// concurrently, causing out-of-order [tailcfg.MapResponse] delivery and races
// on lastSentPeers (Clear+Store in [multiChannelNodeConn.updateSentPeers] vs
// Range in [multiChannelNodeConn.computePeerDiff]).
// concurrently, causing out-of-order MapResponse delivery and races
// on lastSentPeers (Clear+Store in updateSentPeers vs Range in
// computePeerDiff).
workMu sync.Mutex
closeOnce sync.Once
@@ -62,7 +62,7 @@ type multiChannelNodeConn struct {
// disconnectedAt records when the last connection was removed.
// nil means the node is considered connected (or newly created);
// non-nil means the node disconnected at the stored timestamp.
// Used by [Batcher.cleanupOfflineNodes] to evict stale entries.
// Used by cleanupOfflineNodes to evict stale entries.
disconnectedAt atomic.Pointer[time.Time]
// lastSentPeers tracks which peers were last sent to this node.
@@ -182,8 +182,8 @@ func (mc *multiChannelNodeConn) markConnected() {
}
// markDisconnected records the current time as the moment the node
// lost its last connection. Used by [Batcher.cleanupOfflineNodes] to
// determine how long the node has been offline.
// lost its last connection. Used by cleanupOfflineNodes to determine
// how long the node has been offline.
func (mc *multiChannelNodeConn) markDisconnected() {
now := time.Now()
mc.disconnectedAt.Store(&now)
@@ -235,8 +235,8 @@ func (mc *multiChannelNodeConn) drainPending() []change.Change {
// connection can block for up to 50ms), the method snapshots connections under
// a read lock, sends without any lock held, then write-locks only to remove
// failures. New connections added between the snapshot and cleanup are safe:
// they receive a full initial map via [Batcher.AddNode], so missing this update
// causes no data loss.
// they receive a full initial map via AddNode, so missing this update causes
// no data loss.
func (mc *multiChannelNodeConn) send(data *tailcfg.MapResponse) error {
if data == nil {
return nil
@@ -389,7 +389,7 @@ func (mc *multiChannelNodeConn) version() tailcfg.CapabilityVersion {
return mc.connections[0].version
}
// updateSentPeers updates the tracked peer state based on a sent [tailcfg.MapResponse].
// updateSentPeers updates the tracked peer state based on a sent MapResponse.
// This must be called after successfully sending a response to keep track of
// what the client knows about, enabling accurate diffs for future updates.
func (mc *multiChannelNodeConn) updateSentPeers(resp *tailcfg.MapResponse) {
+4 -1
View File
@@ -70,6 +70,7 @@ func TestTailNode(t *testing.T) {
Name: "empty",
StableID: "0",
HomeDERP: 0,
LegacyDERPString: "127.3.3.40:0",
Hostinfo: hiview(tailcfg.Hostinfo{}),
MachineAuthorized: true,
@@ -147,7 +148,8 @@ func TestTailNode(t *testing.T) {
PrimaryRoutes: []netip.Prefix{
netip.MustParsePrefix("192.168.0.0/24"),
},
HomeDERP: 0,
HomeDERP: 0,
LegacyDERPString: "127.3.3.40:0",
Hostinfo: hiview(tailcfg.Hostinfo{
RoutableIPs: []netip.Prefix{
tsaddr.AllIPv4(),
@@ -184,6 +186,7 @@ func TestTailNode(t *testing.T) {
Name: "minimal.example.com.",
StableID: "0",
HomeDERP: 0,
LegacyDERPString: "127.3.3.40:0",
Hostinfo: hiview(tailcfg.Hostinfo{}),
MachineAuthorized: true,
+63
View File
@@ -1,6 +1,10 @@
package hscontrol
import (
"net/http"
"strconv"
"github.com/gorilla/mux"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"tailscale.com/envknob"
@@ -38,4 +42,63 @@ var (
Name: "mapresponse_ended_total",
Help: "total count of new mapsessions ended",
}, []string{"reason"})
httpDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
Namespace: prometheusNamespace,
Name: "http_duration_seconds",
Help: "Duration of HTTP requests.",
}, []string{"path"})
httpCounter = promauto.NewCounterVec(prometheus.CounterOpts{
Namespace: prometheusNamespace,
Name: "http_requests_total",
Help: "Total number of http requests processed",
}, []string{"code", "method", "path"},
)
)
// prometheusMiddleware implements mux.MiddlewareFunc.
func prometheusMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
route := mux.CurrentRoute(r)
path, _ := route.GetPathTemplate()
// Ignore streaming and noise sessions
// it has its own router further down.
if path == "/ts2021" || path == "/machine/map" || path == "/derp" || path == "/derp/probe" || path == "/derp/latency-check" || path == "/bootstrap-dns" {
next.ServeHTTP(w, r)
return
}
rw := &respWriterProm{ResponseWriter: w}
timer := prometheus.NewTimer(httpDuration.WithLabelValues(path))
next.ServeHTTP(rw, r)
timer.ObserveDuration()
httpCounter.WithLabelValues(strconv.Itoa(rw.status), r.Method, path).Inc()
})
}
type respWriterProm struct {
http.ResponseWriter
status int
written int64
wroteHeader bool
}
func (r *respWriterProm) WriteHeader(code int) {
r.status = code
r.wroteHeader = true
r.ResponseWriter.WriteHeader(code)
}
func (r *respWriterProm) Write(b []byte) (int, error) {
if !r.wroteHeader {
r.WriteHeader(http.StatusOK)
}
n, err := r.ResponseWriter.Write(b)
r.written += int64(n)
return n, err
}
+16 -35
View File
@@ -65,14 +65,14 @@ const (
// The first 9 bytes from the server to client over Noise are either an HTTP/2
// settings frame (a normal HTTP/2 setup) or, as Tailscale added later, an "early payload"
// header that's also 9 bytes long: 5 bytes ([earlyPayloadMagic]) followed by 4 bytes
// of length. Then that many bytes of JSON-encoded [tailcfg.EarlyNoise].
// header that's also 9 bytes long: 5 bytes (earlyPayloadMagic) followed by 4 bytes
// of length. Then that many bytes of JSON-encoded tailcfg.EarlyNoise.
// The early payload is optional. Some servers may not send it... But we do!
earlyPayloadMagic = "\xff\xff\xffTS"
// noiseBodyLimit is the maximum allowed request body size for Noise protocol
// handlers. This prevents unauthenticated OOM attacks via unbounded [io.ReadAll].
// No legitimate Noise request ([tailcfg.MapRequest], [tailcfg.RegisterRequest], etc.) comes close
// handlers. This prevents unauthenticated OOM attacks via unbounded io.ReadAll.
// No legitimate Noise request (MapRequest, RegisterRequest, etc.) comes close
// to this limit; typical payloads are a few KB.
noiseBodyLimit int64 = 1048576 // 1 MiB
)
@@ -86,12 +86,12 @@ type noiseServer struct {
machineKey key.MachinePublic
nodeKey key.NodePublic
// [tailcfg.EarlyNoise]-related stuff
// EarlyNoise-related stuff
challenge key.ChallengePrivate
protocolVersion int
}
// NoiseUpgradeHandler is to upgrade the connection and hijack the [net.Conn]
// NoiseUpgradeHandler is to upgrade the connection and hijack the net.Conn
// in order to use the Noise-based TS2021 protocol. Listens in /ts2021.
func (h *Headscale) NoiseUpgradeHandler(
writer http.ResponseWriter,
@@ -136,7 +136,7 @@ func (h *Headscale) NoiseUpgradeHandler(
// This router is served only over the Noise connection, and exposes only the new API.
//
// The HTTP2 server that exposes this router is created for
// a single hijacked connection from /ts2021, using [netutil.NewOneConnListener]
// a single hijacked connection from /ts2021, using netutil.NewOneConnListener
r := chi.NewRouter()
@@ -158,12 +158,7 @@ func (h *Headscale) NoiseUpgradeHandler(
},
}))
r.Use(middleware.RequestID)
// The outer router resolved trusted_proxies on req before the
// upgrade; pin that value across the hijack so /machine/* logs the
// client IP instead of the reverse proxy's loopback peer.
r.Use(overrideRemoteAddr(req.RemoteAddr))
r.Use(middleware.RealIP)
r.Use(middleware.RequestLogger(&zerologRequestLogger{}))
r.Use(middleware.Recoverer)
@@ -295,27 +290,13 @@ func rejectUnsupported(
return false
}
// overrideRemoteAddr returns middleware that pins r.RemoteAddr to addr.
// Used inside the Noise tunnel: the HTTP/2 server derives r.RemoteAddr
// from the hijacked TCP socket (the reverse proxy's loopback peer), so
// the outer request's resolved client IP must be carried across the
// hijack boundary by hand.
func overrideRemoteAddr(addr string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.RemoteAddr = addr
next.ServeHTTP(w, r)
})
}
}
func (ns *noiseServer) NotImplementedHandler(writer http.ResponseWriter, req *http.Request) {
log.Trace().Caller().Str("path", req.URL.String()).Msg("not implemented handler hit")
http.Error(writer, "Not implemented yet", http.StatusNotImplemented)
}
// PingResponseHandler handles HEAD requests from clients responding to a
// [tailcfg.PingRequest]. The client calls this endpoint to prove connectivity.
// PingRequest. The client calls this endpoint to prove connectivity.
// The unguessable ping ID serves as authentication.
func (h *Headscale) PingResponseHandler(
writer http.ResponseWriter,
@@ -472,12 +453,12 @@ func (ns *noiseServer) SSHActionHandler(
}
// sshAction resolves the SSH action for the given request parameters.
// It returns the action to send to the client, or an [HTTPError] on failure.
// It returns the action to send to the client, or an HTTPError on failure.
//
// Three cases:
// 1. Initial request, auto-approved — source recently authenticated
// within the check period, accept immediately.
// 2. Initial request, needs auth — build a [tailcfg.SSHAction.HoldAndDelegate] URL and
// 2. Initial request, needs auth — build a HoldAndDelegate URL and
// wait for the user to authenticate.
// 3. Follow-up request — an auth_id is present, wait for the auth
// verdict and accept or reject.
@@ -529,7 +510,7 @@ func (ns *noiseServer) sshAction(
}
// sshActionHoldAndDelegate creates a new auth session bound to the
// (src, dst) pair and returns a [tailcfg.SSHAction.HoldAndDelegate] action that directs the
// (src, dst) pair and returns a HoldAndDelegate action that directs the
// client to authenticate.
func (ns *noiseServer) sshActionHoldAndDelegate(
reqLog zerolog.Logger,
@@ -651,8 +632,8 @@ func (ns *noiseServer) sshActionFollowUp(
case <-ctx.Done():
// The client disconnected (or its request timed out) before the
// auth session resolved. Return an error so the parked goroutine
// is freed; without this select [noiseServer.sshActionFollowUp] would block
// until the cache eviction callback signalled [types.AuthRequest.FinishAuth], which
// is freed; without this select sshActionFollowUp would block
// until the cache eviction callback signalled FinishAuth, which
// could be up to register_cache_expiration (15 minutes).
return nil, NewHTTPError(
http.StatusUnauthorized,
@@ -689,8 +670,8 @@ func (ns *noiseServer) sshActionFollowUp(
// This is the busiest endpoint, as it keeps the HTTP long poll that updates
// the clients when something in the network changes.
//
// The clients POST stuff like [tailcfg.Hostinfo] and their Endpoints here, but
// only after their first request (marked with the [tailcfg.MapRequest.ReadOnly] field).
// The clients POST stuff like HostInfo and their Endpoints here, but
// only after their first request (marked with the ReadOnly field).
//
// At this moment the updates are sent in a quite horrendous way, but they kinda work.
func (ns *noiseServer) PollNetMapHandler(
+11 -39
View File
@@ -21,8 +21,8 @@ import (
// newNoiseRouterWithBodyLimit builds a chi router with the same body-limit
// middleware used in the real Noise router but wired to a test handler that
// captures the [io.ReadAll] result. This lets us verify the limit without
// needing a full [Headscale] instance.
// captures the io.ReadAll result. This lets us verify the limit without
// needing a full Headscale instance.
func newNoiseRouterWithBodyLimit(readBody *[]byte, readErr *error) http.Handler {
r := chi.NewRouter()
r.Use(func(next http.Handler) http.Handler {
@@ -159,7 +159,7 @@ func TestNoiseBodyLimit_AtExactLimit(t *testing.T) {
}
// TestPollNetMapHandler_OversizedBody calls the real handler with a
// [http.MaxBytesReader]-wrapped body to verify it fails gracefully (json decode
// MaxBytesReader-wrapped body to verify it fails gracefully (json decode
// error on truncated data) rather than consuming unbounded memory.
func TestPollNetMapHandler_OversizedBody(t *testing.T) {
t.Parallel()
@@ -173,12 +173,12 @@ func TestPollNetMapHandler_OversizedBody(t *testing.T) {
ns.PollNetMapHandler(rec, req)
// Body is truncated → [json.Decoder.Decode] fails → [httpError] returns 500.
// Body is truncated → json.Decode fails → httpError returns 500.
assert.Equal(t, http.StatusInternalServerError, rec.Code)
}
// TestRegistrationHandler_OversizedBody calls the real handler with a
// [http.MaxBytesReader]-wrapped body to verify it returns an error response
// MaxBytesReader-wrapped body to verify it returns an error response
// rather than consuming unbounded memory.
func TestRegistrationHandler_OversizedBody(t *testing.T) {
t.Parallel()
@@ -192,8 +192,8 @@ func TestRegistrationHandler_OversizedBody(t *testing.T) {
ns.RegistrationHandler(rec, req)
// [json.Decoder.Decode] returns [http.MaxBytesError][regErr] wraps it → handler writes
// a [tailcfg.RegisterResponse] with the error and then [rejectUnsupported] kicks in
// json.Decode returns MaxBytesError → regErr wraps it → handler writes
// a RegisterResponse with the error and then rejectUnsupported kicks in
// for version 0 → returns 400.
assert.Equal(t, http.StatusBadRequest, rec.Code)
}
@@ -236,7 +236,7 @@ func TestSSHActionRoute_OldPathReturns404(t *testing.T) {
}
// newSSHActionRequest builds an httptest request with the chi URL params
// [noiseServer.SSHActionHandler] reads (src_node_id and dst_node_id), so the handler
// 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()
@@ -253,8 +253,8 @@ func newSSHActionRequest(t *testing.T, src, dst types.NodeID) *http.Request {
}
// putTestNodeInStore creates a node via the database test helper and
// also stages it into the in-memory [state.NodeStore] so handlers that read
// [state.NodeStore]-backed APIs (e.g. [state.State.GetNodeByID]) can see it.
// also stages it into the in-memory NodeStore so handlers that read
// NodeStore-backed APIs (e.g. State.GetNodeByID) can see it.
func putTestNodeInStore(t *testing.T, app *Headscale, user *types.User, hostname string) *types.Node {
t.Helper()
@@ -276,7 +276,7 @@ func TestSSHActionHandler_RejectsRogueMachineKey(t *testing.T) {
src := putTestNodeInStore(t, app, user, "src-node")
dst := putTestNodeInStore(t, app, user, "dst-node")
// [noiseServer] carries the wrong machine key — a fresh throwaway key,
// noiseServer carries the wrong machine key — a fresh throwaway key,
// not dst.MachineKey.
rogue := key.NewMachine().Public()
require.NotEqual(t, dst.MachineKey, rogue, "test sanity: rogue key must differ from dst")
@@ -368,31 +368,3 @@ func TestSSHActionFollowUp_RejectsBindingMismatch(t *testing.T) {
assert.Equal(t, http.StatusUnauthorized, rec.Code,
"binding mismatch must be rejected with 401")
}
// TestOverrideRemoteAddr asserts the middleware used inside the Noise
// tunnel pins r.RemoteAddr to the value captured from the outer
// (pre-hijack) request, so /machine/* requests log the trusted-proxy
// resolved client IP instead of the hijacked TCP socket's loopback peer.
func TestOverrideRemoteAddr(t *testing.T) {
t.Parallel()
const clientAddr = "192.168.91.240"
r := chi.NewRouter()
r.Use(overrideRemoteAddr(clientAddr))
var observed string
r.Get("/x", func(w http.ResponseWriter, r *http.Request) {
observed = r.RemoteAddr
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/x", nil)
req.RemoteAddr = "127.0.0.1:44388"
r.ServeHTTP(httptest.NewRecorder(), req)
assert.Equal(t, clientAddr, observed)
}
+18 -21
View File
@@ -27,15 +27,15 @@ const (
defaultOAuthOptionsCount = 3
authCacheExpiration = time.Minute * 15
// authCacheMaxEntries bounds the OIDC state→[AuthInfo] cache to prevent
// authCacheMaxEntries bounds the OIDC state→AuthInfo cache to prevent
// unauthenticated cache-fill DoS via repeated /register/{auth_id} or
// /auth/{auth_id} GETs that mint OIDC state cookies.
authCacheMaxEntries = 1024
// cookieNamePrefixLen is the number of leading characters from a
// state/nonce value that [getCookieName] splices into the cookie name.
// state/nonce value that getCookieName splices into the cookie name.
// State and nonce values that are shorter than this are rejected at
// the callback boundary so [getCookieName] cannot panic on a slice
// the callback boundary so getCookieName cannot panic on a slice
// out-of-range.
cookieNamePrefixLen = 6
)
@@ -69,7 +69,7 @@ type AuthProviderOIDC struct {
cfg *types.OIDCConfig
// authCache holds auth information between the auth and the callback
// steps. It is a bounded [expirable.LRU] keyed by OIDC state, evicting oldest
// steps. It is a bounded LRU keyed by OIDC state, evicting oldest
// entries to keep the cache footprint constant under attack.
authCache *expirable.LRU[string, AuthInfo]
@@ -286,9 +286,9 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
util.LogErr(err, "could not get userinfo; only using claims from id token")
}
// The [oidc.UserInfo] type only decodes some fields (Subject, Profile, Email, EmailVerified).
// The oidc.UserInfo type only decodes some fields (Subject, Profile, Email, EmailVerified).
// We are interested in other fields too (e.g. groups are required for allowedGroups) so we
// decode into our own [types.OIDCUserInfo] type using the underlying claims struct.
// decode into our own OIDCUserInfo type using the underlying claims struct.
var userinfo2 types.OIDCUserInfo
if userinfo != nil && userinfo.Claims(&userinfo2) == nil && userinfo2.Sub == claims.Sub {
// Update the user with the userinfo claims (with id token claims as fallback).
@@ -444,10 +444,10 @@ func extractCodeAndStateParamFromRequest(
return "", "", NewHTTPError(http.StatusBadRequest, "missing code or state parameter", errEmptyOIDCCallbackParams)
}
// Reject states that are too short for [getCookieName] to splice
// Reject states that are too short for getCookieName to splice
// into a cookie name. Without this guard a request with
// ?state=abc panics on the slice out-of-range and is recovered by
// chi's [middleware.Recoverer], amplifying small-DoS log noise.
// chi's middleware.Recoverer, amplifying small-DoS log noise.
if len(state) < cookieNamePrefixLen {
return "", "", NewHTTPError(http.StatusBadRequest, "invalid state parameter", errOIDCStateTooShort)
}
@@ -552,15 +552,15 @@ func validateOIDCAllowedUsers(
//
// The following tests are always applied:
//
// - [validateOIDCAllowedGroups]
// - validateOIDCAllowedGroups
//
// The following tests are applied if cfg.EmailVerifiedRequired=false
// or claims.email_verified=true:
//
// - [validateOIDCAllowedDomains]
// - [validateOIDCAllowedUsers]
// - validateOIDCAllowedDomains
// - validateOIDCAllowedUsers
//
// NOTE that, contrary to the function name, [validateOIDCAllowedUsers]
// NOTE that, contrary to the function name, validateOIDCAllowedUsers
// only checks the email address -- not the username.
func doOIDCAuthorization(
cfg *types.OIDCConfig,
@@ -658,7 +658,7 @@ func (a *AuthProviderOIDC) createOrUpdateUserFromClaim(
const registerConfirmCSRFCookie = "headscale_register_confirm"
// renderRegistrationConfirmInterstitial captures the resolved OIDC
// identity and node expiry into the cached [types.AuthRequest], sets the CSRF
// identity and node expiry into the cached AuthRequest, sets the CSRF
// cookie, and renders the confirmation page that the user must
// explicitly submit before the registration is finalised.
func (a *AuthProviderOIDC) renderRegistrationConfirmInterstitial(
@@ -698,7 +698,6 @@ func (a *AuthProviderOIDC) renderRegistrationConfirmInterstitial(
CSRF: csrf,
})
//nolint:gosec // G124: Secure set conditionally via req.TLS; HttpOnly + SameSite already set
http.SetCookie(writer, &http.Cookie{
Name: registerConfirmCSRFCookie,
Value: csrf,
@@ -734,7 +733,7 @@ func (a *AuthProviderOIDC) renderRegistrationConfirmInterstitial(
// RegisterConfirmHandler is the POST endpoint behind the OIDC
// registration confirmation interstitial. It validates the CSRF cookie
// against the form-submitted token, finalises the registration via
// [AuthProviderOIDC.handleRegistration], and renders the success page.
// handleRegistration, and renders the success page.
func (a *AuthProviderOIDC) RegisterConfirmHandler(
writer http.ResponseWriter,
req *http.Request,
@@ -824,7 +823,6 @@ func (a *AuthProviderOIDC) RegisterConfirmHandler(
}
// Clear the CSRF cookie now that the registration is final.
//nolint:gosec // G124: Secure set conditionally via req.TLS; HttpOnly + SameSite already set
http.SetCookie(writer, &http.Cookie{
Name: registerConfirmCSRFCookie,
Value: "",
@@ -840,7 +838,7 @@ func (a *AuthProviderOIDC) RegisterConfirmHandler(
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
writer.WriteHeader(http.StatusOK)
// [renderRegistrationSuccessTemplate]'s output only embeds
// renderRegistrationSuccessTemplate's output only embeds
// HTML-escaped values from a server-side template, so the gosec
// XSS warning is a false positive here.
if _, err := writer.Write(content.Bytes()); err != nil { //nolint:noinlineerr,gosec
@@ -920,9 +918,9 @@ func renderAuthSuccessTemplate(
}
// getCookieName generates a unique cookie name based on a cookie value.
// Callers must ensure value has at least [cookieNamePrefixLen] bytes;
// [extractCodeAndStateParamFromRequest] enforces this for the state
// parameter, and [setCSRFCookie] always supplies a 64-byte random value.
// Callers must ensure value has at least cookieNamePrefixLen bytes;
// extractCodeAndStateParamFromRequest enforces this for the state
// parameter, and setCSRFCookie always supplies a 64-byte random value.
func getCookieName(baseName, value string) string {
return fmt.Sprintf("%s_%s", baseName, value[:cookieNamePrefixLen])
}
@@ -933,7 +931,6 @@ func setCSRFCookie(w http.ResponseWriter, r *http.Request, name string) (string,
return val, err
}
//nolint:gosec // G124: Secure set conditionally via r.TLS; HttpOnly + SameSite already set
c := &http.Cookie{
Path: "/oidc/callback",
Name: getCookieName(name, val),
+1 -2
View File
@@ -24,7 +24,6 @@ func newConfirmRequest(t *testing.T, authID types.AuthID, formCSRF, cookieCSRF s
form,
)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
//nolint:gosec // G124: test fixture
req.AddCookie(&http.Cookie{
Name: registerConfirmCSRFCookie,
Value: cookieCSRF,
@@ -68,7 +67,7 @@ func TestRegisterConfirmHandler_RejectsCSRFMismatch(t *testing.T) {
"CSRF cookie/form mismatch must be rejected with 403")
// And the registration must still be pending — the rejected POST
// must not have called [AuthProviderOIDC.handleRegistration].
// must not have called handleRegistration.
cached, ok := app.state.GetAuthCacheEntry(authID)
require.True(t, ok, "rejected POST must not evict the cached registration")
require.NotNil(t, cached.PendingConfirmation(),
+12 -12
View File
@@ -44,13 +44,13 @@ func MatchesFromFilterRules(rules []tailcfg.FilterRule) []Match {
return matches
}
// MatchFromFilterRule derives a [Match] from a [tailcfg.FilterRule]. The
// destination IP set is the union of [tailcfg.FilterRule.DstPorts][].IP
// and [tailcfg.FilterRule.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
// [policy.BuildPeerMap] / [policy.ReduceNodes], hiding the cap target
// from the source unless a companion IP-level rule also exists.
// 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)
@@ -80,11 +80,11 @@ func MatchFromFilterRule(rule tailcfg.FilterRule) Match {
}
}
// MatchFromStrings builds a [Match] from raw source and destination
// 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
// resulting Match is narrower than the input described, but never
// wider. Callers that need strict validation should pre-validate
// their inputs via [util.ParseIPSet].
// their inputs via util.ParseIPSet.
func MatchFromStrings(sources, destinations []string) Match {
srcs := new(netipx.IPSetBuilder)
dests := new(netipx.IPSetBuilder)
@@ -131,7 +131,7 @@ func (m *Match) DestsOverlapsPrefixes(prefixes ...netip.Prefix) bool {
// 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 [util.TheInternet]. A
// (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.
func (m *Match) DestsIsTheInternet() bool {
@@ -140,7 +140,7 @@ func (m *Match) DestsIsTheInternet() bool {
return true
}
// Superset-of-[util.TheInternet] check handles merged filter rules
// Superset-of-TheInternet check handles merged filter rules
// where the internet prefixes are combined with other dests.
theInternet := util.TheInternet()
for _, prefix := range theInternet.Prefixes() {
+3 -3
View File
@@ -68,7 +68,7 @@ type PolicyManager interface {
DebugString() string
}
// NewPolicyManager returns a new [PolicyManager].
// NewPolicyManager returns a new policy manager.
func NewPolicyManager(pol []byte, users []types.User, nodes views.Slice[types.NodeView]) (PolicyManager, error) {
var (
polMan PolicyManager
@@ -83,8 +83,8 @@ func NewPolicyManager(pol []byte, users []types.User, nodes views.Slice[types.No
return polMan, err
}
// PolicyManagersForTest returns all available [PolicyManager] implementations to
// be used in tests to validate them in tests that try to determine that they
// PolicyManagersForTest returns all available PostureManagers to be used
// in tests to validate them in tests that try to determine that they
// behave the same.
func PolicyManagersForTest(pol []byte, users []types.User, nodes views.Slice[types.NodeView]) ([]PolicyManager, error) {
var polMans []PolicyManager
+4 -7
View File
@@ -51,11 +51,6 @@ func ReduceRoutes(
}
// BuildPeerMap builds a map of all peers that can be accessed by each node.
//
// Compared to [ReduceNodes], which builds the list per node, we end up with
// doing the full work for every node (O(n^2)), while this will reduce the
// list as we see relationships while building the map, making it O(n^2/2)
// in the end, but with less work per node.
func BuildPeerMap(
nodes views.Slice[types.NodeView],
matchers []matcher.Match,
@@ -63,6 +58,9 @@ func BuildPeerMap(
ret := make(map[types.NodeID][]types.NodeView, nodes.Len())
// Build the map of all peers according to the matchers.
// Compared to ReduceNodes, which builds the list per node, we end up with doing
// the full work for every node (On^2), while this will reduce the list as we see
// relationships while building the map, making it O(n^2/2) in the end, but with less work per node.
for i := range nodes.Len() {
for j := i + 1; j < nodes.Len(); j++ {
if nodes.At(i).ID() == nodes.At(j).ID() {
@@ -80,8 +78,7 @@ func BuildPeerMap(
}
// ApproveRoutesWithPolicy checks if the node can approve the announced routes
// and returns the new list of approved routes. The [PolicyManager] is consulted
// via [PolicyManager.NodeCanApproveRoute].
// and returns the new list of approved routes.
// The approved routes will include:
// 1. ALL previously approved routes (regardless of whether they're still advertised)
// 2. New routes from announcedRoutes that can be auto-approved by policy
+4 -4
View File
@@ -1,9 +1,9 @@
// 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
// ReduceFilterRules, which filters global rules down to those relevant
// to one node.
//
// A node's [types.NodeView.SubnetRoutes] (approved, non-exit) participate
// in rule matching so subnet routers receive filter rules for
// destinations their subnets cover.
// 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
+16 -18
View File
@@ -15,8 +15,7 @@ import (
//
// IMPORTANT: This function is designed for global filters only. Per-node filters
// (from autogroup:self policies) are already node-specific and should not be passed
// to this function. Use [policy.PolicyManager.FilterForNode] instead, which handles
// both cases.
// 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()
@@ -50,14 +49,13 @@ func ReduceFilterRules(node types.NodeView, rules []tailcfg.FilterRule) []tailcf
}
// If the node has approved subnet routes, preserve
// filter rules targeting those routes.
// [types.NodeView.SubnetRoutes] returns only approved,
// non-exit routes — matching Tailscale SaaS behavior,
// which does not generate filter rules for
// advertised-but-unapproved routes. Exit routes
// (0.0.0.0/0, ::/0) are excluded by
// [types.NodeView.SubnetRoutes] and handled separately
// via AllowedIPs/routing.
// filter rules targeting those routes. SubnetRoutes()
// returns only approved, non-exit routes — matching
// Tailscale SaaS behavior, which does not generate
// filter rules for advertised-but-unapproved routes.
// 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
@@ -97,11 +95,11 @@ func ipSetSubsetOf(candidate, container *netipx.IPSet) bool {
return true
}
// reduceCapGrantRule filters a [tailcfg.CapGrant] rule to only include
// [tailcfg.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
// 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
// narrowed to the node's specific /32 or /128 prefix. Returns nil if
// no [tailcfg.CapGrant] entries are relevant to this node.
// no CapGrant entries are relevant to this node.
func reduceCapGrantRule(
node types.NodeView,
rule tailcfg.FilterRule,
@@ -138,9 +136,9 @@ func reduceCapGrantRule(
// 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. [types.NodeView.SubnetRoutes]
// excludes both unapproved and exit routes, matching Tailscale
// SaaS behavior.
// receives the full CapGrant scope. SubnetRoutes() excludes
// both unapproved and exit routes, matching Tailscale SaaS
// behavior.
for _, dst := range cg.Dsts {
for _, subnetRoute := range subnetRoutes {
if dst.Overlaps(subnetRoute) {
@@ -153,7 +151,7 @@ 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
// subnet routes. Sort + Compact dedups; netip.Prefix is
// comparable so Compact works with ==.
slices.SortFunc(matchingDsts, netip.Prefix.Compare)
matchingDsts = slices.Compact(matchingDsts)
+56 -105
View File
@@ -18,7 +18,7 @@ type grantCategory int
const (
// grantCategoryRegular requires no per-node work. The pre-compiled
// rules are complete and only need [policyutil.ReduceFilterRules].
// rules are complete and only need ReduceFilterRules.
grantCategoryRegular grantCategory = iota
// grantCategorySelf has autogroup:self destinations that must be
@@ -66,57 +66,17 @@ type selfGrantData struct {
}
// viaGrantData holds data needed for per-node via-grant compilation.
// Sources are already resolved into srcIPStrings; destinations are
// pre-resolved into prefixes plus a flag for autogroup:internet, which
// the consumers handle separately because its gate is per-node
// (IsExitNode()) rather than per-prefix overlap.
// Sources are already resolved into srcIPStrings.
type viaGrantData struct {
viaTags []Tag
resolvedDsts []netip.Prefix
hasAutoGroupInternet bool
internetProtocols []ProtocolPort
srcIPStrings []string
}
// resolveViaDestinations splits a via grant's destinations into the
// flat list of IP prefixes they resolve to plus a flag for
// autogroup:internet. Every alias kind goes through [Alias.Resolve] so
// adding a new alias type to the policy parser does not silently
// disappear from the via path. Non-IP alias kinds (tag, user, group,
// wildcard) resolve to /32 host IPs that never overlap with subnet
// route advertisements and therefore contribute nothing here.
func resolveViaDestinations(
pol *Policy,
users types.Users,
nodes views.Slice[types.NodeView],
dsts Aliases,
) ([]netip.Prefix, bool) {
var (
prefixes []netip.Prefix
hasAutoGroupInternet bool
)
for _, d := range dsts {
if ag, ok := d.(*AutoGroup); ok && ag.Is(AutoGroupInternet) {
hasAutoGroupInternet = true
continue
}
ips, err := d.Resolve(pol, users, nodes)
if err != nil || ips == nil {
continue
}
prefixes = append(prefixes, ips.Prefixes()...)
}
return prefixes, hasAutoGroupInternet
viaTags []Tag
destinations Aliases
internetProtocols []ProtocolPort
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
// PolicyManager.mu; readers must hold the lock (or the snapshot
// returned to them).
type userNodeIndex map[uint][]types.NodeView
@@ -136,7 +96,7 @@ func buildUserNodeIndex(
}
// compileNodeAttrs returns the per-node CapMap derived from policy
// nodeAttrs plus the tailnet-wide [Policy.RandomizeClientPort] flag.
// 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
@@ -163,18 +123,18 @@ func (pol *Policy) compileNodeAttrs(
result[id] = capMap
}
// nil [tailcfg.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.
// 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 [types.NodeView.IPs] once per attr
// 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 {
@@ -221,11 +181,10 @@ func (pol *Policy) compileNodeAttrs(
return result, nil
}
// compileGrants resolves all policy grants into [compiledGrant] structs.
// 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
// duplicated work in [Policy.compileFilterRules] and the autogroup:self
// expansion.
// duplicated work in compileFilterRules and compileGrantWithAutogroupSelf.
func (pol *Policy) compileGrants(
users types.Users,
nodes views.Slice[types.NodeView],
@@ -257,7 +216,7 @@ func (pol *Policy) compileGrants(
return compiled
}
// compileOneGrant resolves a single grant into a [compiledGrant].
// compileOneGrant resolves a single grant into a compiledGrant.
// All source resolution happens here. Non-self, non-via destination
// resolution also happens here. Per-node data (self dests, via
// matching) is stored for deferred compilation.
@@ -342,7 +301,7 @@ func (pol *Policy) compileOneGrant(
// compileOneViaGrant resolves sources for a via grant and stores the
// deferred per-node data. The actual via-node matching and route
// intersection happens in [compileViaForNode].
// intersection happens in compileViaForNode.
func (pol *Policy) compileOneViaGrant(
grant Grant,
users types.Users,
@@ -384,17 +343,12 @@ func (pol *Policy) compileOneViaGrant(
hasWildcard := sourcesHaveWildcard(grant.Sources)
hasDangerAll := sourcesHaveDangerAll(grant.Sources)
resolvedDsts, hasAutoGroupInternet := resolveViaDestinations(
pol, users, nodes, grant.Destinations,
)
return &compiledGrant{
category: grantCategoryVia,
via: &viaGrantData{
viaTags: grant.Via,
resolvedDsts: resolvedDsts,
hasAutoGroupInternet: hasAutoGroupInternet,
internetProtocols: grant.InternetProtocols,
viaTags: grant.Via,
destinations: grant.Destinations,
internetProtocols: grant.InternetProtocols,
srcIPStrings: srcIPsWithRoutes(
srcResolved, hasWildcard, hasDangerAll, nodes,
),
@@ -405,8 +359,8 @@ 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 [Alias.Resolve] again.
// compiledGrant.srcIPStrings (among other places) and callers on the
// hot path should prefer reading that over calling Resolve again.
func resolveSources(
pol *Policy,
sources Aliases,
@@ -491,9 +445,8 @@ func buildSrcIPStrings(
}
// compileOtherDests compiles filter rules for non-self, non-via
// destinations. This produces both [tailcfg.FilterRule.DstPorts] rules
// (from [Grant.InternetProtocols]) and [tailcfg.CapGrant] rules (from
// [Grant.App]).
// destinations. This produces both DstPorts rules (from
// InternetProtocols) and CapGrant rules (from App).
func (pol *Policy) compileOtherDests(
users types.Users,
nodes views.Slice[types.NodeView],
@@ -582,7 +535,7 @@ func (pol *Policy) compileOtherDests(
return rules
}
// hasPerNodeGrants reports whether any [compiledGrant] requires
// hasPerNodeGrants reports whether any compiled grant requires
// per-node filter compilation (via grants or autogroup:self).
func hasPerNodeGrants(grants []compiledGrant) bool {
for i := range grants {
@@ -594,10 +547,10 @@ func hasPerNodeGrants(grants []compiledGrant) bool {
return false
}
// globalFilterRules extracts global filter rules from [compiledGrant]s.
// 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.
// 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.
func globalFilterRules(grants []compiledGrant) []tailcfg.FilterRule {
var rules []tailcfg.FilterRule
@@ -806,41 +759,39 @@ func compileViaForNode(
return nil
}
// [types.NodeView.SubnetRoutes] excludes exit routes, so the overlap
// gate below sees only subnet advertisements. autogroup:internet on
// a via-tagged exit advertiser is handled separately because its
// eligibility is per-node ([types.NodeView.IsExitNode]) rather than
// per-prefix overlap.
// Find matching destination prefixes. SubnetRoutes() excludes exit
// routes, so the *Prefix check below sees only subnet advertisements;
// the *AutoGroup AutoGroupInternet branch checks IsExitNode() instead.
nodeSubnetRoutes := node.SubnetRoutes()
var viaDstPrefixes []netip.Prefix
for _, dstPrefix := range cg.via.resolvedDsts {
// Equality would reject any broader or narrower dst relative
// to the advertised route. Containment in either direction
// matches the operator's authorisation: a broader dst restricts
// traffic to the subset the router serves; a narrower dst rides
// on a router covering more than the operator asked for. The
// rule emits the literal dst either way because that is what
// the policy authorised.
if slices.ContainsFunc(nodeSubnetRoutes, dstPrefix.Overlaps) {
viaDstPrefixes = append(viaDstPrefixes, dstPrefix)
for _, dst := range cg.via.destinations {
switch d := dst.(type) {
case *Prefix:
dstPrefix := netip.Prefix(*d)
if slices.Contains(nodeSubnetRoutes, dstPrefix) {
viaDstPrefixes = append(
viaDstPrefixes, dstPrefix,
)
}
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 on a via-tagged exit advertiser becomes a rule
// whose DstPorts enumerate [util.TheInternet]. The matchers derived
// from this rule let [types.NodeView.CanAccess] surface the exit node
// to the grant source via [matcher.Match.DestsIsTheInternet].
// [policyutil.ReduceFilterRules] strips the rule from the wire format
// on non-exit advertisers, preserving SaaS PacketFilter encoding.
if cg.via.hasAutoGroupInternet && node.IsExitNode() {
viaDstPrefixes = append(
viaDstPrefixes,
util.TheInternet().Prefixes()...,
)
}
if len(viaDstPrefixes) == 0 {
return nil
}
+59 -34
View File
@@ -26,23 +26,23 @@ var (
// companionCaps maps certain well-known Tailscale capabilities to
// their companion capability. When a grant includes one of these
// capabilities, Tailscale automatically generates an additional
// [tailcfg.FilterRule] with the companion capability and a nil CapMap value.
// FilterRule with the companion capability and a nil CapMap value.
var companionCaps = map[tailcfg.PeerCapability]tailcfg.PeerCapability{
tailcfg.PeerCapabilityTaildrive: tailcfg.PeerCapabilityTaildriveSharer,
tailcfg.PeerCapabilityRelay: tailcfg.PeerCapabilityRelayTarget,
}
// companionCapGrantRules returns additional [tailcfg.FilterRule]s for any
// companionCapGrantRules returns additional FilterRules for any
// well-known capabilities that have companion caps. Companion rules
// are **reversed**: SrcIPs come from the original destinations and
// CapGrant Dsts come from the original sources. This allows
// [policyutil.ReduceFilterRules] to distribute companion rules to source
// nodes (e.g. drive-sharer goes to the member nodes, not the destination).
// ReduceFilterRules to distribute companion rules to source nodes
// (e.g. drive-sharer goes to the member nodes, not the destination).
// Rules are ordered by the original capability name.
//
// dstIPStrings are the resolved destination IPs as strings (used as
// companion SrcIPs). srcPrefixes are the resolved source IPs as
// [netip.Prefix] (used as companion CapGrant Dsts).
// netip.Prefix (used as companion CapGrant Dsts).
func companionCapGrantRules(
dstIPStrings []string,
srcPrefixes []netip.Prefix,
@@ -87,7 +87,7 @@ func companionCapGrantRules(
// sourcesHaveWildcard returns true if any of the source aliases is
// a wildcard (*). Used to determine whether approved subnet routes
// should be appended to [tailcfg.FilterRule.SrcIPs].
// should be appended to SrcIPs.
func sourcesHaveWildcard(srcs Aliases) bool {
for _, src := range srcs {
if _, ok := src.(Asterix); ok {
@@ -99,8 +99,8 @@ func sourcesHaveWildcard(srcs Aliases) bool {
}
// sourcesHaveDangerAll returns true if any of the source aliases is
// autogroup:danger-all. When present, [tailcfg.FilterRule.SrcIPs] should
// be ["*"] to represent all IP addresses including non-Tailscale addresses.
// autogroup:danger-all. When present, SrcIPs should be ["*"] to
// represent all IP addresses including non-Tailscale addresses.
func sourcesHaveDangerAll(srcs Aliases) bool {
for _, src := range srcs {
if ag, ok := src.(*AutoGroup); ok && ag.Is(AutoGroupDangerAll) {
@@ -111,8 +111,8 @@ func sourcesHaveDangerAll(srcs Aliases) bool {
return false
}
// srcIPsWithRoutes returns the [tailcfg.FilterRule.SrcIPs] string slice,
// appending approved subnet routes when the sources include a wildcard.
// srcIPsWithRoutes returns the SrcIPs string slice, appending
// approved subnet routes when the sources include a wildcard.
// When hasDangerAll is true, returns ["*"] to represent all IPs.
func srcIPsWithRoutes(
resolved ResolvedAddresses,
@@ -132,18 +132,17 @@ func srcIPsWithRoutes(
return ips
}
// compileFilterRules takes a set of nodes and a [Policy] and generates a
// set of Tailscale compatible [tailcfg.FilterRule]s used to allow traffic
// on clients.
// compileFilterRules takes a set of nodes and an ACLPolicy and generates a
// set of Tailscale compatible FilterRules used to allow traffic on clients.
func (pol *Policy) compileFilterRules(
users types.Users,
nodes views.Slice[types.NodeView],
) []tailcfg.FilterRule {
) ([]tailcfg.FilterRule, error) {
if pol == nil || (pol.ACLs == nil && pol.Grants == nil) {
return tailcfg.FilterAllowAll
return tailcfg.FilterAllowAll, nil
}
return globalFilterRules(pol.compileGrants(users, nodes))
return globalFilterRules(pol.compileGrants(users, nodes)), nil
}
func (pol *Policy) destinationsToNetPortRange(
@@ -203,15 +202,15 @@ func (pol *Policy) compileFilterRulesForNode(
users types.Users,
node types.NodeView,
nodes views.Slice[types.NodeView],
) []tailcfg.FilterRule {
) ([]tailcfg.FilterRule, error) {
if pol == nil {
return tailcfg.FilterAllowAll
return tailcfg.FilterAllowAll, nil
}
grants := pol.compileGrants(users, nodes)
userIdx := buildUserNodeIndex(nodes)
return filterRulesForNode(grants, node, userIdx)
return filterRulesForNode(grants, node, userIdx), nil
}
var sshAccept = tailcfg.SSHAction{
@@ -222,11 +221,11 @@ var sshAccept = tailcfg.SSHAction{
AllowRemotePortForwarding: true,
}
// checkPeriodFromRule extracts the check period duration from an [SSH] rule.
// Returns [SSHCheckPeriodDefault] if no checkPeriod is configured,
// 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 [PolicyManager.SSHCheckParams] to resolve the
// real period when the client calls back; the wire format always sends 0.
// 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:
@@ -259,7 +258,6 @@ func sshCheck(baseURL string, _ time.Duration) tailcfg.SSHAction {
}
}
//nolint:gocyclo // SSH compilation walks per-rule branches with intertwined autogroup:self handling
func (pol *Policy) compileSSHPolicy(
baseURL string,
users types.Users,
@@ -479,7 +477,24 @@ func (pol *Policy) compileSSHPolicy(
}, nil
}
// ipSetToPrincipals converts an [netipx.IPSet] into SSH principals, one per address.
// resolvedAddrsToPrincipals converts ResolvedAddresses into SSH principals, one per address.
func resolvedAddrsToPrincipals(addrs ResolvedAddresses) []*tailcfg.SSHPrincipal {
if addrs == nil {
return nil
}
var principals []*tailcfg.SSHPrincipal
for addr := range addrs.Iter() {
principals = append(principals, &tailcfg.SSHPrincipal{
NodeIP: addr.String(),
})
}
return principals
}
// ipSetToPrincipals converts an IPSet into SSH principals, one per address.
func ipSetToPrincipals(ipSet *netipx.IPSet) []*tailcfg.SSHPrincipal {
if ipSet == nil {
return nil
@@ -607,8 +622,21 @@ func groupSourcesByUser(
return userIDs, principalsByUser, taggedPrincipals
}
// filterRuleKey generates a unique key for merging based on [tailcfg.FilterRule.SrcIPs]
// and [tailcfg.FilterRule.IPProto].
func ipSetToPrefixStringList(ips *netipx.IPSet) []string {
var out []string
if ips == nil {
return out
}
for _, pref := range ips.Prefixes() {
out = append(out, pref.String())
}
return out
}
// filterRuleKey generates a unique key for merging based on SrcIPs and IPProto.
func filterRuleKey(rule tailcfg.FilterRule) string {
srcKey := strings.Join(rule.SrcIPs, ",")
@@ -620,13 +648,10 @@ func filterRuleKey(rule tailcfg.FilterRule) string {
return srcKey + "|" + strings.Join(protoStrs, ",")
}
// mergeFilterRules merges rules with identical [tailcfg.FilterRule.SrcIPs] and
// [tailcfg.FilterRule.IPProto] by combining their [tailcfg.FilterRule.DstPorts].
// DstPorts are NOT deduplicated to match Tailscale behavior.
// [tailcfg.CapGrant] rules (which have no [tailcfg.FilterRule.DstPorts]) are
// passed through without merging since [tailcfg.CapGrant] and
// [tailcfg.FilterRule.DstPorts] are mutually exclusive in a
// [tailcfg.FilterRule].
// mergeFilterRules merges rules with identical SrcIPs and IPProto by combining
// their DstPorts. DstPorts are NOT deduplicated to match Tailscale behavior.
// CapGrant rules (which have no DstPorts) are passed through without merging
// since CapGrant and DstPorts are mutually exclusive in a FilterRule.
func mergeFilterRules(rules []tailcfg.FilterRule) []tailcfg.FilterRule {
if len(rules) <= 1 {
return rules
+35 -14
View File
@@ -368,7 +368,7 @@ func TestParsing(t *testing.T) {
return
}
rules := pol.compileFilterRules(
rules, err := pol.compileFilterRules(
users,
types.Nodes{
&types.Node{
@@ -381,6 +381,12 @@ func TestParsing(t *testing.T) {
},
}.ViewSlice())
if (err != nil) != tt.wantErr {
t.Errorf("parsing() error = %v, wantErr %v", err, tt.wantErr)
return
}
if diff := cmp.Diff(tt.want, rules); diff != "" {
t.Errorf("parsing() unexpected result (-want +got):\n%s", diff)
}
@@ -1334,7 +1340,10 @@ func TestCompileFilterRulesForNodeWithAutogroupSelf(t *testing.T) {
// Test compilation for user1's first node
node1 := nodes[0].View()
rules := policy2.compileFilterRulesForNode(users, node1, nodes.ViewSlice())
rules, err := policy2.compileFilterRulesForNode(users, node1, nodes.ViewSlice())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(rules) != 1 {
t.Fatalf("expected 1 rule, got %d", len(rules))
@@ -1492,7 +1501,8 @@ func TestTagUserMutualExclusivity(t *testing.T) {
// matching the production pipeline in filterForNodeLocked.
userNode := nodes[0].View()
compiled := pol.compileFilterRulesForNode(users, userNode, nodes.ViewSlice())
compiled, err := pol.compileFilterRulesForNode(users, userNode, nodes.ViewSlice())
require.NoError(t, err)
userRules := policyutil.ReduceFilterRules(userNode, compiled)
@@ -1514,7 +1524,8 @@ func TestTagUserMutualExclusivity(t *testing.T) {
// Tag:database should receive the tag:server → tag:database rule after reduction.
dbNode := nodes[3].View()
compiled = pol.compileFilterRulesForNode(users, dbNode, nodes.ViewSlice())
compiled, err = pol.compileFilterRulesForNode(users, dbNode, nodes.ViewSlice())
require.NoError(t, err)
dbRules := policyutil.ReduceFilterRules(dbNode, compiled)
@@ -1585,7 +1596,8 @@ func TestUserToTagCrossIdentityGrant(t *testing.T) {
// user1's IP as source.
taggedNode := nodes[2].View()
compiled := pol.compileFilterRulesForNode(users, taggedNode, nodes.ViewSlice())
compiled, err := pol.compileFilterRulesForNode(users, taggedNode, nodes.ViewSlice())
require.NoError(t, err)
rules := policyutil.ReduceFilterRules(taggedNode, compiled)
@@ -1723,7 +1735,8 @@ func TestAutogroupTagged(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
rules := policy.compileFilterRulesForNode(users, tt.sourceNode, nodes.ViewSlice())
rules, err := policy.compileFilterRulesForNode(users, tt.sourceNode, nodes.ViewSlice())
require.NoError(t, err)
// Verify all expected destinations are reachable
for _, expectedDest := range tt.shouldReach {
@@ -1806,7 +1819,8 @@ func TestAutogroupSelfWithSpecificUserSource(t *testing.T) {
// For user1's node: sources should be user1's devices
node1 := nodes[0].View()
rules := policy.compileFilterRulesForNode(users, node1, nodes.ViewSlice())
rules, err := policy.compileFilterRulesForNode(users, node1, nodes.ViewSlice())
require.NoError(t, err)
require.Len(t, rules, 1)
expectedSourceIPs := []string{"100.64.0.1", "100.64.0.2"}
@@ -1836,7 +1850,8 @@ func TestAutogroupSelfWithSpecificUserSource(t *testing.T) {
assert.ElementsMatch(t, expectedDestIPs, actualDestIPs)
node2 := nodes[2].View()
rules2 := policy.compileFilterRulesForNode(users, node2, nodes.ViewSlice())
rules2, err := policy.compileFilterRulesForNode(users, node2, nodes.ViewSlice())
require.NoError(t, err)
assert.Empty(t, rules2, "user2's node should have no rules (user1@ devices can't match user2's self)")
}
@@ -1878,7 +1893,8 @@ func TestAutogroupSelfWithGroupSource(t *testing.T) {
// (group:admins has user1+user2, but autogroup:self filters to same user)
node1 := nodes[0].View()
rules := policy.compileFilterRulesForNode(users, node1, nodes.ViewSlice())
rules, err := policy.compileFilterRulesForNode(users, node1, nodes.ViewSlice())
require.NoError(t, err)
require.Len(t, rules, 1)
expectedSrcIPs := []string{"100.64.0.1", "100.64.0.2"}
@@ -1900,7 +1916,8 @@ func TestAutogroupSelfWithGroupSource(t *testing.T) {
}
node3 := nodes[4].View()
rules3 := policy.compileFilterRulesForNode(users, node3, nodes.ViewSlice())
rules3, err := policy.compileFilterRulesForNode(users, node3, nodes.ViewSlice())
require.NoError(t, err)
assert.Empty(t, rules3, "user3 should have no rules")
}
@@ -2349,7 +2366,8 @@ func TestAutogroupSelfWithNonExistentUserInGroup(t *testing.T) {
// Test superadmin's device: should have rules with tag:common, tag:tech, tag:privileged destinations
// and superadmin's IP should appear in sources (partial resolution of group:superadmin works)
superadminNode := nodes[0].View()
superadminRules := policy.compileFilterRulesForNode(users, superadminNode, nodes.ViewSlice())
superadminRules, err := policy.compileFilterRulesForNode(users, superadminNode, nodes.ViewSlice())
require.NoError(t, err)
assert.True(t, containsIP(superadminRules, "100.64.0.10"), "rules should include tag:common server")
assert.True(t, containsIP(superadminRules, "100.64.0.11"), "rules should include tag:tech server")
assert.True(t, containsIP(superadminRules, "100.64.0.12"), "rules should include tag:privileged server")
@@ -2365,7 +2383,8 @@ func TestAutogroupSelfWithNonExistentUserInGroup(t *testing.T) {
// partial result to be discarded via `continue`. With the fix, superadmin's IPs
// from group:superadmin are retained alongside admin's IPs from group:admin.
adminNode := nodes[1].View()
adminRules := policy.compileFilterRulesForNode(users, adminNode, nodes.ViewSlice())
adminRules, err := policy.compileFilterRulesForNode(users, adminNode, nodes.ViewSlice())
require.NoError(t, err)
// Rule 1 sources: [group:superadmin, group:admin, group:direction]
// Without fix: group:superadmin discarded -> only admin + direction IPs in sources
@@ -2379,7 +2398,8 @@ func TestAutogroupSelfWithNonExistentUserInGroup(t *testing.T) {
// Test direction's device: similar to admin, verifies group:direction sources work
directionNode := nodes[2].View()
directionRules := policy.compileFilterRulesForNode(users, directionNode, nodes.ViewSlice())
directionRules, err := policy.compileFilterRulesForNode(users, directionNode, nodes.ViewSlice())
require.NoError(t, err)
assert.True(t, containsIP(directionRules, "100.64.0.10"),
"direction rules should include tag:common server")
assert.True(t, containsSrcIP(directionRules, "100.64.0.3"),
@@ -3573,7 +3593,8 @@ func TestFilterAllowAllFix(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
rules := tt.pol.compileFilterRules(users, nodes)
rules, err := tt.pol.compileFilterRules(users, nodes)
require.NoError(t, err)
isFilterAllowAll := cmp.Diff(tailcfg.FilterAllowAll, rules) == ""
assert.Equal(t, tt.wantFilterAllow, isFilterAllowAll,
-119
View File
@@ -1,119 +0,0 @@
package v2
import (
"net/netip"
"slices"
"testing"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"tailscale.com/tailcfg"
)
const issue3267AliceEmail = "alice@headscale.net"
// TestIssue3267ViaGrantBroaderDestination locks the SaaS contract for
// a via grant whose destination is a host alias broader than (or
// narrower than) the router's advertised subnet route. Alice's only
// path into the subnet is via tag:subnet-router. The grant destination
// resolves to a /64 (IPv6) or /16 (IPv4), and the router advertises a
// contained /120 / /24. Pre-fix the policy compiler emitted no rule
// and ViaRoutesForPeer left Include empty because the prefix relation
// was checked by slices.Contains (exact equality). SaaS behaviour is
// the authority — see testdata/grant_results/via-grant-v47..v51 for
// the equivalent compatibility regression.
func TestIssue3267ViaGrantBroaderDestination(t *testing.T) {
t.Parallel()
users := types.Users{
{Model: gorm.Model{ID: 1}, Name: "alice", Email: issue3267AliceEmail}, //nolint:goconst
}
cases := []struct {
name string
hostAlias string
dst string // value the hosts alias resolves to
advertised string // narrower prefix the router actually serves
}{
{
name: "ipv6_4via6_64_dst_with_120_advertised",
hostAlias: "example-4via6",
dst: "fd7a:115c:a1e0:b1a::/64",
advertised: "fd7a:115c:a1e0:b1a:0:13:ad2:7300/120",
},
{
name: "ipv4_16_dst_with_24_advertised",
hostAlias: "subnet",
dst: "10.33.0.0/16",
advertised: "10.33.5.0/24",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
aliceLaptop := node("alice-laptop", "100.64.0.10", "fd7a:115c:a1e0::a", users[0])
aliceLaptop.ID = 1
router := node("subnet-router", "100.64.0.11", "fd7a:115c:a1e0::b", users[0])
router.ID = 2
router.Tags = []string{"tag:subnet-router"}
route := netip.MustParsePrefix(tc.advertised)
router.Hostinfo = &tailcfg.Hostinfo{RoutableIPs: []netip.Prefix{route}}
router.ApprovedRoutes = []netip.Prefix{route}
nodes := types.Nodes{aliceLaptop, router}
policy := `{
"tagOwners": {
"tag:subnet-router": ["` + issue3267AliceEmail + `"]
},
"hosts": {
"` + tc.hostAlias + `": "` + tc.dst + `"
},
"grants": [
{
"src": ["` + issue3267AliceEmail + `"],
"dst": ["` + tc.hostAlias + `"],
"via": ["tag:subnet-router"],
"ip": ["icmp:*"]
}
]
}`
pm, err := NewPolicyManager([]byte(policy), users, nodes.ViewSlice())
require.NoError(t, err)
pol, err := unmarshalPolicy([]byte(policy))
require.NoError(t, err)
require.NoError(t, pol.validate())
t.Run("compileFilterRulesForNode_emits_rule_with_grant_dst", func(t *testing.T) {
t.Parallel()
rules := pol.compileFilterRulesForNode(users, router.View(), nodes.ViewSlice())
found := slices.ContainsFunc(rules, func(r tailcfg.FilterRule) bool {
return slices.ContainsFunc(r.DstPorts, func(d tailcfg.NetPortRange) bool {
return d.IP == tc.dst
})
})
require.Truef(t, found,
"router %s must receive a via filter rule whose DstPorts.IP equals the grant dst %q; got rules=%+v",
router.Hostname, tc.dst, rules)
})
t.Run("ViaRoutesForPeer_includes_advertised_prefix", func(t *testing.T) {
t.Parallel()
result := pm.ViaRoutesForPeer(aliceLaptop.View(), router.View())
require.Contains(t, result.Include, route,
"alice viewing tag:subnet-router must Include advertised prefix %s — drives AllowedIPs in state.RoutesForPeer", route)
require.Empty(t, result.Exclude,
"alice viewing tag:subnet-router must not Exclude any prefix — there is no competing via tag")
})
})
}
}
+125 -133
View File
@@ -24,7 +24,7 @@ import (
"tailscale.com/util/multierr"
)
// ErrInvalidTagOwner is returned when a tag owner is not an [Alias] type.
// ErrInvalidTagOwner is returned when a tag owner is not an Alias type.
var ErrInvalidTagOwner = errors.New("tag owner is not an Alias")
type PolicyManager struct {
@@ -86,8 +86,8 @@ type filterAndPolicy struct {
}
// validateUserReferences surfaces ambiguous user@ tokens at policy load so
// duplicate DB rows fail loudly instead of silently dropping rules.
// Missing-user tokens stay tolerant. Empty users → no-op for
// 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 {
@@ -170,7 +170,7 @@ func validateUserReferences(pol *Policy, users types.Users) error {
return multierr.New(errs...)
}
// NewPolicyManager creates a new [PolicyManager] from a policy file and a list of users and nodes.
// 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.
func NewPolicyManager(b []byte, users []types.User, nodes views.Slice[types.NodeView]) (*PolicyManager, error) {
@@ -360,7 +360,7 @@ func (pm *PolicyManager) updateLocked() (bool, error) {
return true, nil
}
// SSHPolicy returns the [tailcfg.SSHPolicy] for node, compiling and
// 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
@@ -531,11 +531,6 @@ func (pm *PolicyManager) Filter() ([]tailcfg.FilterRule, []matcher.Match) {
// For global filters, it uses the global filter matchers for all nodes.
// For autogroup:self policies (empty global filter), it builds per-node
// peer maps using each node's specific filter rules.
//
// Compared to [policy.ReduceNodes], which builds the list per node, we end
// up with doing the full work for every node O(n^2), while this will reduce
// the list as we see relationships while building the map, making it
// O(n^2/2) in the end, but with less work per node.
func (pm *PolicyManager) BuildPeerMap(nodes views.Slice[types.NodeView]) map[types.NodeID][]types.NodeView {
if pm == nil {
return nil
@@ -551,6 +546,9 @@ func (pm *PolicyManager) BuildPeerMap(nodes views.Slice[types.NodeView]) map[typ
ret := make(map[types.NodeID][]types.NodeView, nodes.Len())
// Build the map of all peers according to the matchers.
// Compared to ReduceNodes, which builds the list per node, we end up with doing
// the full work for every node O(n^2), while this will reduce the list as we see
// relationships while building the map, making it O(n^2/2) in the end, but with less work per node.
for i := range nodes.Len() {
for j := i + 1; j < nodes.Len(); j++ {
if nodes.At(i).ID() == nodes.At(j).ID() {
@@ -669,8 +667,8 @@ func (pm *PolicyManager) filterForNodeLocked(
// 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 [PolicyManager.updateLocked] on policy reload,
// node-set change, or tag-state change.
// 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
@@ -684,14 +682,14 @@ func (pm *PolicyManager) FilterForNode(node types.NodeView) ([]tailcfg.FilterRul
// MatchersForNode returns the matchers for peer relationship determination for a specific node.
// These are UNREDUCED matchers - they include all rules where the node could be either source or destination.
// This is different from [PolicyManager.FilterForNode] which returns REDUCED rules for packet filtering.
// This is different from FilterForNode which returns REDUCED rules for packet filtering.
//
// 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 [PolicyManager.BuildPeerMap]'s O(N²) slow path avoids recomputing
// matchers for every pair.
// 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
@@ -834,9 +832,9 @@ func (pm *PolicyManager) nodesHavePolicyAffectingChanges(newNodes views.Slice[ty
// NodeCanHaveTag checks if a node can have the specified tag during client-initiated
// registration or reauth flows (e.g., tailscale up --advertise-tags).
//
// This function is NOT used by the admin API's [state.State.SetNodeTags] - admins can
// set any existing tag on any node by calling [state.State.SetNodeTags] directly,
// which bypasses this authorization check.
// This function is NOT used by the admin API's SetNodeTags - admins can set any
// existing tag on any node by calling State.SetNodeTags directly, which bypasses
// this authorization check.
func (pm *PolicyManager) NodeCanHaveTag(node types.NodeView, tag string) bool {
if pm == nil || pm.pol == nil {
return false
@@ -876,7 +874,7 @@ func (pm *PolicyManager) NodeCanHaveTag(node types.NodeView, tag string) bool {
}
// userMatchesOwner checks if a user matches a tag owner entry.
// This is used as a fallback when the node's IP is not in the [PolicyManager.tagOwnerMap].
// This is used as a fallback when the node's IP is not in the tagOwnerMap.
func (pm *PolicyManager) userMatchesOwner(user types.UserView, owner Owner) bool {
switch o := owner.(type) {
case *Username:
@@ -986,14 +984,11 @@ func (pm *PolicyManager) NodeCanApproveRoute(node types.NodeView, route netip.Pr
// ViaRoutesForPeer computes via grant effects for a viewer-peer pair.
// 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 [types.ViaRouteResult.Include]; otherwise
// into [types.ViaRouteResult.Exclude].
// via tag, those prefixes go into Include; otherwise into Exclude.
//
// Performance note: this holds [PolicyManager.mu] for its full duration. Hot
// callers should memoise by (policy-hash, viewer-id) rather than invoking
// this per-pair.
//
//nolint:gocyclo // three-pass via-grant resolution (match, primary election, regular-overlap)
// 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
@@ -1014,13 +1009,11 @@ func (pm *PolicyManager) ViaRoutesForPeer(viewer, peer types.NodeView) types.Via
grants = append(grants, aclToGrants(acl)...)
}
// Resolve each grant's sources against the viewer once, and each
// grant's destinations into a flat prefix list. The three passes
// below reuse both results instead of re-resolving per pass.
// 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))
resolvedDstPrefixes := make([][]netip.Prefix, len(grants))
grantHasAutoGroupInternet := make([]bool, len(grants))
for i, grant := range grants {
for _, src := range grant.Sources {
@@ -1035,10 +1028,6 @@ func (pm *PolicyManager) ViaRoutesForPeer(viewer, peer types.NodeView) types.Via
break
}
}
resolvedDstPrefixes[i], grantHasAutoGroupInternet[i] = resolveViaDestinations(
pm.pol, pm.users, pm.nodes, grant.Destinations,
)
}
for i, grant := range grants {
@@ -1050,33 +1039,32 @@ func (pm *PolicyManager) ViaRoutesForPeer(viewer, peer types.NodeView) types.Via
continue
}
// Filter rules and [tailcfg.Node.AllowedIPs] are different layers.
// The filter rule carries the dst (the authorisation surface).
// [tailcfg.Node.AllowedIPs] carries the advertised route (the
// routing fact the viewer needs to pick this peer). This loop
// builds the AllowedIPs side, so it emits routes — not dst
// prefixes.
// Collect destination prefixes that the peer actually advertises.
peerSubnetRoutes := peer.SubnetRoutes()
var matchedPrefixes []netip.Prefix
for _, dstPrefix := range resolvedDstPrefixes[i] {
for _, route := range peerSubnetRoutes {
if dstPrefix.Overlaps(route) {
matchedPrefixes = append(matchedPrefixes, route)
for _, dst := range grant.Destinations {
switch d := dst.(type) {
case *Prefix:
dstPrefix := netip.Prefix(*d)
if slices.Contains(peerSubnetRoutes, dstPrefix) {
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()...,
)
}
}
}
// 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 the viewer to exit nodes carrying the
// via tag.
if grantHasAutoGroupInternet[i] && peer.IsExitNode() {
matchedPrefixes = append(matchedPrefixes, peer.ExitRoutes()...)
}
if len(matchedPrefixes) == 0 {
continue
}
@@ -1115,11 +1103,11 @@ func (pm *PolicyManager) ViaRoutesForPeer(viewer, peer types.NodeView) types.Via
// Include. The others move to Exclude. This mirrors HA
// primary election scoped to the via tag group.
//
// Unlike the global [tailcfg.Node.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.
// 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
@@ -1133,35 +1121,40 @@ func (pm *PolicyManager) ViaRoutesForPeer(viewer, peer types.NodeView) types.Via
continue
}
// Elect per matched route, not per dst — a peer can only
// be primary for a prefix it actually advertises, and one
// dst may cover multiple distinct routes.
for _, dstPrefix := range resolvedDstPrefixes[i] {
for _, included := range slices.Clone(result.Include) {
if !dstPrefix.Overlaps(included) {
continue
}
for _, dst := range grant.Destinations {
d, ok := dst.(*Prefix)
if !ok {
continue
}
var viaPrimaryID types.NodeID
dstPrefix := netip.Prefix(*d)
if !slices.Contains(result.Include, dstPrefix) {
continue
}
for _, viaTag := range grant.Via {
for _, node := range pm.nodes.All() {
if node.HasTag(string(viaTag)) &&
slices.Contains(node.SubnetRoutes(), included) {
if viaPrimaryID == 0 || node.ID() < viaPrimaryID {
viaPrimaryID = node.ID()
}
// 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 viaPrimaryID != 0 && peer.ID() != viaPrimaryID {
result.Include = slices.DeleteFunc(result.Include, func(p netip.Prefix) bool {
return p == included
})
if !slices.Contains(result.Exclude, included) {
result.Exclude = append(result.Exclude, included)
}
// 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)
}
}
}
@@ -1171,9 +1164,8 @@ func (pm *PolicyManager) ViaRoutesForPeer(viewer, peer types.NodeView) types.Via
// 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
// [state.State.RoutesForPeer] can apply normal
// [policy.ReduceRoutes] + primary logic.
// (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
@@ -1183,19 +1175,21 @@ func (pm *PolicyManager) ViaRoutesForPeer(viewer, peer types.NodeView) types.Via
continue
}
// A non-via grant covering routes that a via grant included
// defers to global HA primary election. Match by overlap so
// a broader or narrower regular dst still catches the
// routes the via grant added to Include.
for _, dstPrefix := range resolvedDstPrefixes[i] {
for _, p := range result.Include {
if dstPrefix.Overlaps(p) &&
!slices.Contains(result.UsePrimary, p) {
result.UsePrimary = append(result.UsePrimary, p)
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)
}
}
result.Exclude = slices.DeleteFunc(result.Exclude, dstPrefix.Overlaps)
// 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
})
}
}
}
}
@@ -1299,18 +1293,13 @@ func (pm *PolicyManager) invalidateAutogroupSelfCache(oldNodes, newNodes views.S
// Tagged nodes don't participate in autogroup:self (identity is tag-based),
// so we skip them when collecting affected users, except when tag status changes
// (which affects the user's device set).
//
// Ownership is keyed on TypedUserID (the UserID field), not the User
// association view: the NodeStore holds nodes by value with User as a
// *User pointer, and not every write path hydrates that association. A
// non-tagged node always has UserID set, so it is the reliable owner key.
affectedUsers := make(map[types.UserID]struct{})
affectedUsers := make(map[uint]struct{})
// Check for removed nodes (only non-tagged nodes affect autogroup:self)
for nodeID, oldNode := range oldNodeMap {
if _, exists := newNodeMap[nodeID]; !exists {
if !oldNode.IsTagged() {
affectedUsers[oldNode.TypedUserID()] = struct{}{}
affectedUsers[oldNode.User().ID()] = struct{}{}
}
}
}
@@ -1319,7 +1308,7 @@ func (pm *PolicyManager) invalidateAutogroupSelfCache(oldNodes, newNodes views.S
for nodeID, newNode := range newNodeMap {
if _, exists := oldNodeMap[nodeID]; !exists {
if !newNode.IsTagged() {
affectedUsers[newNode.TypedUserID()] = struct{}{}
affectedUsers[newNode.User().ID()] = struct{}{}
}
}
}
@@ -1332,10 +1321,10 @@ func (pm *PolicyManager) invalidateAutogroupSelfCache(oldNodes, newNodes views.S
if oldNode.IsTagged() != newNode.IsTagged() {
if !oldNode.IsTagged() {
// Was untagged, now tagged: user lost a device
affectedUsers[oldNode.TypedUserID()] = struct{}{}
affectedUsers[oldNode.User().ID()] = struct{}{}
} else {
// Was tagged, now untagged: user gained a device
affectedUsers[newNode.TypedUserID()] = struct{}{}
affectedUsers[newNode.User().ID()] = struct{}{}
}
continue
@@ -1347,9 +1336,9 @@ func (pm *PolicyManager) invalidateAutogroupSelfCache(oldNodes, newNodes views.S
}
// Check if user changed (both versions are non-tagged here)
if oldNode.TypedUserID() != newNode.TypedUserID() {
affectedUsers[oldNode.TypedUserID()] = struct{}{}
affectedUsers[newNode.TypedUserID()] = struct{}{}
if oldNode.User().ID() != newNode.User().ID() {
affectedUsers[oldNode.User().ID()] = struct{}{}
affectedUsers[newNode.User().ID()] = struct{}{}
}
// Check if IPs changed (simple check - could be more sophisticated)
@@ -1357,12 +1346,12 @@ func (pm *PolicyManager) invalidateAutogroupSelfCache(oldNodes, newNodes views.S
newIPs := newNode.IPs()
if len(oldIPs) != len(newIPs) {
affectedUsers[newNode.TypedUserID()] = struct{}{}
affectedUsers[newNode.User().ID()] = struct{}{}
} else {
// Check if any IPs are different
for i, oldIP := range oldIPs {
if i >= len(newIPs) || oldIP != newIPs[i] {
affectedUsers[newNode.TypedUserID()] = struct{}{}
affectedUsers[newNode.User().ID()] = struct{}{}
break
}
}
@@ -1375,7 +1364,7 @@ func (pm *PolicyManager) invalidateAutogroupSelfCache(oldNodes, newNodes views.S
// because autogroup:self rules depend on the entire user's device set.
for nodeID := range pm.filterRulesMap {
// Find the user for this cached node
var nodeUserID types.UserID
var nodeUserID uint
found := false
@@ -1389,7 +1378,7 @@ func (pm *PolicyManager) invalidateAutogroupSelfCache(oldNodes, newNodes views.S
break
}
nodeUserID = node.TypedUserID()
nodeUserID = node.User().ID()
found = true
break
@@ -1405,7 +1394,7 @@ func (pm *PolicyManager) invalidateAutogroupSelfCache(oldNodes, newNodes views.S
break
}
nodeUserID = node.TypedUserID()
nodeUserID = node.User().ID()
found = true
break
@@ -1449,7 +1438,7 @@ func (pm *PolicyManager) invalidateNodeCache(newNodes views.Slice[types.NodeView
}
// invalidateGlobalPolicyCache invalidates only nodes whose properties affecting
// [policyutil.ReduceFilterRules] changed. For global policies, each node's filter is independent.
// ReduceFilterRules changed. For global policies, each node's filter is independent.
func (pm *PolicyManager) invalidateGlobalPolicyCache(newNodes views.Slice[types.NodeView]) {
oldNodeMap := make(map[types.NodeID]types.NodeView)
for _, node := range pm.nodes.All() {
@@ -1489,12 +1478,17 @@ func (pm *PolicyManager) invalidateGlobalPolicyCache(newNodes views.Slice[types.
}
}
// flattenTags resolves nested tag-owner references. Cycles
// (tag:a -> tag:b -> tag:a, or tag:a -> tag:a) drop the cycle-causing
// edge and contribute 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. 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.
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
}
@@ -1526,8 +1520,8 @@ func flattenTags(tagOwners TagOwners, tag Tag, visiting map[Tag]bool, chain []Ta
return result, nil
}
// flattenTagOwners flattens all [TagOwners] by resolving nested tags and detecting cycles.
// It will return a new [TagOwners] map where all the [Tag] types have been resolved to their underlying [Owners].
// flattenTagOwners flattens all TagOwners by resolving nested tags and detecting cycles.
// It will return a new TagOwners map where all the Tag types have been resolved to their underlying Owners.
func flattenTagOwners(tagOwners TagOwners) (TagOwners, error) {
ret := make(TagOwners)
@@ -1548,9 +1542,9 @@ func flattenTagOwners(tagOwners TagOwners) (TagOwners, error) {
return ret, nil
}
// resolveTagOwners resolves the [TagOwners] to a map of [Tag] to [netipx.IPSet].
// The resulting map can be used to quickly look up the IPSet for a given [Tag].
// It is intended for internal use in a [PolicyManager].
// resolveTagOwners resolves the TagOwners to a map of Tag to netipx.IPSet.
// The resulting map can be used to quickly look up the IPSet for a given Tag.
// It is intended for internal use in a PolicyManager.
func resolveTagOwners(p *Policy, users types.Users, nodes views.Slice[types.NodeView]) (map[Tag]*netipx.IPSet, error) {
if p == nil {
return make(map[Tag]*netipx.IPSet), nil
@@ -1702,16 +1696,14 @@ func (pm *PolicyManager) NodeCapMaps() map[types.NodeID]tailcfg.NodeCapMap {
}
// NodesWithChangedCapMap returns the IDs of nodes whose nodeAttrs
// CapMap shifted across one or more [PolicyManager.updateLocked] calls
// since the last drain. The buffer drains on return. The mapper calls
// this once per [state.State.ReloadPolicy] to decide which nodes need
// a [change.SelfUpdate].
// 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.
//
// [PolicyManager.refreshNodeAttrsLocked] APPENDS to the buffer; the drain
// returns the union of every change since the previous read. A concurrent
// [PolicyManager.SetUsers]/[PolicyManager.SetNodes] between
// [PolicyManager.SetPolicy] and a drain cannot silently lose the
// policy-reload diff.
// 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
+4 -212
View File
@@ -226,72 +226,6 @@ func TestInvalidateAutogroupSelfCache(t *testing.T) {
}
}
// TestSetNodesAutogroupSelfUnhydratedUser reproduces the panic seen on
// /machine/map when an autogroup:self policy is active and a non-tagged
// node reaches the policy manager with its UserID set but the User
// association left unhydrated (User pointer nil). The NodeStore stores
// nodes by value with User as a *User; not every write path hydrates the
// association, so the autogroup:self cache invalidation must derive the
// owning user from UserID, not from the User view.
func TestSetNodesAutogroupSelfUnhydratedUser(t *testing.T) {
users := types.Users{
{Model: gorm.Model{ID: 1}, Name: "user1", Email: "user1@headscale.net"},
{Model: gorm.Model{ID: 2}, Name: "user2", Email: "user2@headscale.net"},
}
policy := `{
"acls": [
{
"action": "accept",
"src": ["autogroup:member"],
"dst": ["autogroup:self:*"]
}
]
}`
// unhydratedNode mirrors a NodeStore snapshot entry whose UserID is
// set (so it is unambiguously user-owned, not tagged) but whose User
// association was never loaded.
unhydratedNode := func(name, ipv4, ipv6 string, userID uint) *types.Node {
return &types.Node{
Hostname: name,
IPv4: ap(ipv4),
IPv6: ap(ipv6),
UserID: new(userID),
User: nil,
}
}
initialNodes := types.Nodes{
node("user1-node1", "100.64.0.1", "fd7a:115c:a1e0::1", users[0]),
node("user2-node1", "100.64.0.2", "fd7a:115c:a1e0::2", users[1]),
}
for i, n := range initialNodes {
n.ID = types.NodeID(i + 1) //nolint:gosec // safe conversion in test
}
pm, err := NewPolicyManager([]byte(policy), users, initialNodes.ViewSlice())
require.NoError(t, err)
require.False(t, initialNodes[0].IsTagged(), "node must be user-owned for autogroup:self")
// Simulate a node restarting tailscaled: the same node is pushed back
// into the policy manager, but the snapshot version has no hydrated
// User association. This is the exact shape that crashed beta.1.
updatedNodes := types.Nodes{
unhydratedNode("user1-node1", "100.64.0.1", "fd7a:115c:a1e0::1", users[0].ID),
node("user2-node1", "100.64.0.2", "fd7a:115c:a1e0::2", users[1]),
}
for i, n := range updatedNodes {
n.ID = types.NodeID(i + 1) //nolint:gosec // safe conversion in test
}
require.NotPanics(t, func() {
_, err = pm.SetNodes(updatedNodes.ViewSlice())
}, "SetNodes must not panic when a non-tagged node has an unhydrated User")
require.NoError(t, err)
}
// TestInvalidateGlobalPolicyCache tests the cache invalidation logic for global policies.
func TestInvalidateGlobalPolicyCache(t *testing.T) {
mustIPPtr := func(s string) *netip.Addr {
@@ -801,11 +735,10 @@ func TestTagPropagationToPeerMap(t *testing.T) {
require.NoError(t, err)
require.NotEmpty(t, matchersForUser2, "MatchersForNode should return non-empty matchers (at least self-access rule)")
// Test [policy.ReduceNodes] logic with the updated nodes and matchers
// This is what [mapper.MapResponseBuilder.buildTailPeers] does - it takes peers from
// [state.State.ListPeers] (which might include user1) and filters them using
// [policy.ReduceNodes] with the updated matchers
// Inline the [policy.ReduceNodes] logic to avoid import cycle
// Test ReduceNodes logic with the updated nodes and matchers
// This is what buildTailPeers does - it takes peers from ListPeers (which might include user1)
// and filters them using ReduceNodes with the updated matchers
// Inline the ReduceNodes logic to avoid import cycle
user2View := user2Node.View()
user1UpdatedView := user1NodeUpdated.View()
@@ -1880,147 +1813,6 @@ func TestViaRoutesForPeer(t *testing.T) {
"client should NOT be able to access 10.0.0.0/24 via matchers alone; "+
"state.RoutesForPeer adds via routes after ReduceRoutes to fix this")
})
t.Run("broader_dst_includes_narrower_advertised_route", func(t *testing.T) {
t.Parallel()
nodes := types.Nodes{
{
ID: 1,
Hostname: "viewer",
IPv4: ap("100.64.0.1"),
User: new(users[0]),
UserID: new(users[0].ID),
Hostinfo: &tailcfg.Hostinfo{},
},
{
ID: 2,
Hostname: "router",
IPv4: ap("100.64.0.2"),
User: new(users[0]),
UserID: new(users[0].ID),
Tags: []string{"tag:router"},
Hostinfo: &tailcfg.Hostinfo{
RoutableIPs: []netip.Prefix{mp("10.33.5.0/24")},
},
ApprovedRoutes: []netip.Prefix{mp("10.33.5.0/24")},
},
}
pol := `{
"tagOwners": {
"tag:router": ["user1@"]
},
"grants": [{
"src": ["user1@"],
"dst": ["10.0.0.0/8"],
"ip": ["*"],
"via": ["tag:router"]
}]
}`
pm, err := NewPolicyManager([]byte(pol), users, nodes.ViewSlice())
require.NoError(t, err)
result := pm.ViaRoutesForPeer(nodes[0].View(), nodes[1].View())
require.Equal(t, []netip.Prefix{mp("10.33.5.0/24")}, result.Include,
"Include must hold the advertised route /24, not the broader grant dst /8")
require.Empty(t, result.Exclude)
})
t.Run("narrower_dst_includes_advertised_route", func(t *testing.T) {
t.Parallel()
nodes := types.Nodes{
{
ID: 1,
Hostname: "viewer",
IPv4: ap("100.64.0.1"),
User: new(users[0]),
UserID: new(users[0].ID),
Hostinfo: &tailcfg.Hostinfo{},
},
{
ID: 2,
Hostname: "router",
IPv4: ap("100.64.0.2"),
User: new(users[0]),
UserID: new(users[0].ID),
Tags: []string{"tag:router"},
Hostinfo: &tailcfg.Hostinfo{
RoutableIPs: []netip.Prefix{mp("10.33.0.0/16")},
},
ApprovedRoutes: []netip.Prefix{mp("10.33.0.0/16")},
},
}
pol := `{
"tagOwners": {
"tag:router": ["user1@"]
},
"grants": [{
"src": ["user1@"],
"dst": ["10.33.5.0/24"],
"ip": ["*"],
"via": ["tag:router"]
}]
}`
pm, err := NewPolicyManager([]byte(pol), users, nodes.ViewSlice())
require.NoError(t, err)
result := pm.ViaRoutesForPeer(nodes[0].View(), nodes[1].View())
require.Equal(t, []netip.Prefix{mp("10.33.0.0/16")}, result.Include,
"Include must hold the advertised route /16 that covers the narrower grant dst /24")
require.Empty(t, result.Exclude)
})
t.Run("disjoint_dst_emits_nothing", func(t *testing.T) {
t.Parallel()
nodes := types.Nodes{
{
ID: 1,
Hostname: "viewer",
IPv4: ap("100.64.0.1"),
User: new(users[0]),
UserID: new(users[0].ID),
Hostinfo: &tailcfg.Hostinfo{},
},
{
ID: 2,
Hostname: "router",
IPv4: ap("100.64.0.2"),
User: new(users[0]),
UserID: new(users[0].ID),
Tags: []string{"tag:router"},
Hostinfo: &tailcfg.Hostinfo{
RoutableIPs: []netip.Prefix{mp("10.33.0.0/16")},
},
ApprovedRoutes: []netip.Prefix{mp("10.33.0.0/16")},
},
}
pol := `{
"tagOwners": {
"tag:router": ["user1@"]
},
"grants": [{
"src": ["user1@"],
"dst": ["192.168.0.0/16"],
"ip": ["*"],
"via": ["tag:router"]
}]
}`
pm, err := NewPolicyManager([]byte(pol), users, nodes.ViewSlice())
require.NoError(t, err)
result := pm.ViaRoutesForPeer(nodes[0].View(), nodes[1].View())
require.Empty(t, result.Include,
"disjoint dst must produce nothing — the via gate requires advertised-route overlap")
require.Empty(t, result.Exclude)
})
}
// TestBuildPeerMap_AutogroupInternetMakesExitNodeVisible reproduces
+138 -55
View File
@@ -12,17 +12,34 @@ import (
"tailscale.com/types/views"
)
// sshTests assertions evaluate on user-initiated writes; boot reload
// skips them so a stale reference does not block startup. Each entry
// names a src and one or more dst, and uses:
// The sshTests block is Tailscale's parallel of the ACL `tests` block for
// SSH-shaped rules: each entry asserts that a source identity reaching out
// to one or more destination hosts can SSH in as the named login users —
// or, conversely, must be refused. The block runs at the same user-write
// boundary as `tests` (SetPolicy, `headscale policy check`, file-mode
// reload after a change). Boot-time reload skips evaluation so a stored
// policy referencing a now-deleted entity does not block startup.
//
// - accept: every listed user reaches every dst via an accept- or
// check-action rule.
// - deny: no listed user reaches any dst.
// - check: every listed user reaches every dst via a check-action
// rule specifically (accept-only matches fail the assertion).
// Three assertion kinds:
//
// - accept[user]: from src, every dst must be reachable as user via an
// action:accept OR action:check rule. Check counts as reachable for
// the accept assertion because both actions resolve to "the user is
// allowed to start a session" at the wire layer.
// - deny[user]: from src, no dst is reachable as user. A test passes
// when no rule allows the user at all (or every matching rule's
// SSHUsers map blocks the user).
// - check[user]: from src, every dst must be reachable as user via a
// rule whose action is specifically check (HoldAndDelegate is the
// wire signal — see filter.go sshCheck). An accept-only match
// fails the check assertion: SaaS keeps the distinction so policy
// authors can pin sensitive logins to check rules.
// SSHPolicyTestResult is the outcome of a single [SSHPolicyTest].
// SSHPolicyTestResult is the outcome of a single SSHPolicyTest.
//
// Each map is keyed by login user and records the per-dst breakdown so
// the rendered error tells the operator which (src, user, dst) triple
// went the wrong way.
type SSHPolicyTestResult struct {
Src string `json:"src"`
Passed bool `json:"passed"`
@@ -43,6 +60,11 @@ type SSHPolicyTestResults struct {
}
// Errors renders the per-test failure breakdown joined by newlines.
//
// Tailscale SaaS returns only the literal "test(s) failed" body for
// either assertion class. We keep the per-test detail because operators
// invoking SetPolicy from the CLI or file-mode reload have no separate
// audit endpoint, so the rendered body is the only signal they get.
func (r SSHPolicyTestResults) Errors() string {
if r.AllPassed {
return ""
@@ -91,6 +113,8 @@ func (r SSHPolicyTestResults) Errors() string {
return strings.Join(lines, "\n")
}
// sortedUsers returns the keys of m sorted by user name so error
// rendering is deterministic across runs.
func sortedUsers(m map[string][]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
@@ -102,7 +126,9 @@ func sortedUsers(m map[string][]string) []string {
return keys
}
// displayUser shows an empty username as `""` rather than blank.
// displayUser formats a login user for the rendered error. An empty
// string is shown as `""` so the operator can see that the assertion
// referenced an empty username (which is itself a failure case).
func displayUser(u string) string {
if u == "" {
return `""`
@@ -111,8 +137,9 @@ func displayUser(u string) string {
return u
}
// checkFailReason annotates a check-fail with whether the user reached
// the dst via an accept rule or did not reach at all.
// checkFailReason annotates a check-fail line with whether the user
// reached the dst via an accept rule (so the operator knows to flip the
// rule to action:check) or did not reach the dst at all.
func checkFailReason(res SSHPolicyTestResult, user, dst string) string {
if slices.Contains(res.AcceptOK[user], dst) {
return "ALLOWED via accept"
@@ -121,8 +148,10 @@ func checkFailReason(res SSHPolicyTestResult, user, dst string) string {
return "DENIED"
}
// RunSSHTests evaluates the live policy's sshTests block and wraps any
// failure in [errSSHPolicyTestsFailed].
// RunSSHTests evaluates the policy's sshTests block against the live
// users and nodes and returns a wrapped error when any assertion fails.
// Callers that need the per-test breakdown can call runSSHPolicyTests
// directly with their own compile cache.
func (pm *PolicyManager) RunSSHTests() error {
if pm == nil || pm.pol == nil || len(pm.pol.SSHTests) == 0 {
return nil
@@ -141,7 +170,9 @@ func (pm *PolicyManager) RunSSHTests() error {
return fmt.Errorf("%w:\n%s", errSSHPolicyTestsFailed, results.Errors())
}
// evaluateSSHTests runs the block against pol without mutating live state.
// evaluateSSHTests is the user-write sandbox: run sshTests against pol
// + current users/nodes without mutating any live state. It mirrors
// evaluateTests for the ACL block.
func evaluateSSHTests(
pol *Policy,
users []types.User,
@@ -161,8 +192,9 @@ func evaluateSSHTests(
return fmt.Errorf("%w:\n%s", errSSHPolicyTestsFailed, results.Errors())
}
// runSSHPolicyTests evaluates every sshTests entry. The cache is keyed
// by dst [types.NodeID] so repeat destinations only compile once per pass.
// runSSHPolicyTests evaluates every sshTests entry against pol. The
// cache is keyed by destination node ID and reused across entries so a
// 10-entry block hitting 4 dst nodes pays 4 compiles, not 40.
func runSSHPolicyTests(
pol *Policy,
users []types.User,
@@ -186,8 +218,11 @@ func runSSHPolicyTests(
return results
}
// runSSHPolicyTest evaluates one entry: resolve src → resolve dst →
// walk accept/deny/check arrays against each dst's compiled SSH policy.
// runSSHPolicyTest evaluates one SSHPolicyTest entry against pol.
//
// Order of operations: resolve src → resolve dst nodes → reject empty
// assertion blocks → walk accept/deny/check arrays, asking the per-dst
// compiled SSH policy whether the user can reach the dst.
func runSSHPolicyTest(
test SSHPolicyTest,
pol *Policy,
@@ -222,7 +257,10 @@ func runSSHPolicyTest(
return res
}
// An entry with no assertion arrays would silently pass.
// Tailscale SaaS treats an entry with no accept/deny/check arrays
// as "nothing to assert", which is reported as a failure. Catching
// it here keeps the engine output mirroring SaaS for parse-accepted
// inputs.
if len(test.Accept) == 0 && len(test.Deny) == 0 && len(test.Check) == 0 {
res.Passed = false
res.Errors = append(res.Errors,
@@ -240,7 +278,10 @@ func runSSHPolicyTest(
return res
}
// A dst resolving to zero nodes would silently pass.
// SaaS treats a dst alias that resolves to no nodes as a failure
// when the entry has anything to assert. Without this branch, the
// per-assertion loops below run zero iterations and the test
// passes silently — wrong shape, missed regression.
for _, dst := range emptyDsts {
res.Passed = false
res.Errors = append(res.Errors,
@@ -278,6 +319,8 @@ func runSSHPolicyTest(
return res
}
// sshAssertion is the kind of assertion being evaluated for a single
// (src, dst, user) triple.
type sshAssertion int
const (
@@ -286,9 +329,19 @@ const (
assertCheck
)
// evaluateAssertion walks every (srcAddr, dstNode) pair for one user
// and records the outcome. Empty username fails — SSH login users
// cannot be empty even when parse accepted it.
// evaluateAssertion walks every (srcAddr, dstNode) pair for one user and
// records the outcome in res. The semantics:
//
// - accept passes iff every (srcAddr, dstNode) reaches the dst via at
// least one rule whose action is accept or check.
// - deny passes iff no (srcAddr, dstNode) is reachable as user.
// - check passes iff every (srcAddr, dstNode) reaches the dst via at
// least one check rule (HoldAndDelegate set). An accept-only match
// fails the check assertion — SaaS keeps the two categories
// distinct.
//
// Empty username is parse-accepted but reports as a failure here: SSH
// login users cannot be empty, so the assertion can never be satisfied.
func evaluateAssertion(
pol *Policy,
users []types.User,
@@ -314,6 +367,8 @@ dstLoop:
dstLabel := dst.Hostname()
// acceptHit covers "any matching accept-or-check rule";
// checkHit restricts to check-action matches only.
acceptHit := false
checkHit := false
@@ -327,8 +382,9 @@ dstLoop:
checkHit = true
}
// All src IPs must agree; one counter-example fails
// the whole (user, dst) pair.
// accept and deny require ALL src IPs to reach (or all
// to be blocked). A single counter-example fails the
// assertion.
switch kind {
case assertAccept:
if !a {
@@ -376,7 +432,7 @@ dstLoop:
}
}
// appendUserDst appends dst to m[user], allocating m on first use.
// appendUserDst appends dst to m[user], lazily allocating m.
func appendUserDst(m map[string][]string, user, dst string) map[string][]string {
if m == nil {
m = make(map[string][]string)
@@ -387,9 +443,11 @@ func appendUserDst(m map[string][]string, user, dst string) map[string][]string
return m
}
// resolveSSHTestSource returns the src's principal addresses and, for
// user-shaped sources, the user ID (so autogroup:self can scope to it).
// [Tag], [Host], and IP sources return userID 0.
// resolveSSHTestSource resolves the typed src alias into a list of
// netip.Addr (one per principal address the SSH compiler would emit
// for the same source). For user-shaped sources, srcUserID returns the
// resolved user's ID so autogroup:self destinations can scope to the
// same user. Returns ID 0 when the source is a tag, host, or IP.
func resolveSSHTestSource(
src Alias,
pol *Policy,
@@ -427,11 +485,14 @@ func resolveSSHTestSource(
return out, userID, nil
}
// resolveSSHTestDestNodes maps each dst alias to its destination
// [types.NodeView]s. autogroup:self needs special handling: it cannot
// resolve without per-node context, so it walks the node set keyed on
// src's owning user. Other aliases resolve to an [netipx.IPSet] and match
// via [types.NodeView.InIPSet].
// resolveSSHTestDestNodes resolves every dst alias in the test entry
// to its destination NodeViews. autogroup:self requires special
// handling because it cannot resolve in the general (non-per-node)
// context — see AutoGroup.resolve in types.go.
//
// For non-self aliases, the resolved IPSet is matched against each
// node's IPs via InIPSet (the same primitive the SSH compiler uses to
// decide whether a node is a destination of a given rule).
func resolveSSHTestDestNodes(
dsts SSHTestDestinations,
pol *Policy,
@@ -451,8 +512,12 @@ func resolveSSHTestDestNodes(
matched := false
if ag, ok := alias.(*AutoGroup); ok && ag.Is(AutoGroupSelf) {
// autogroup:self resolves to non-tagged nodes owned by
// the same user as src; tagged/IP sources have no user.
// autogroup:self → destinations are the non-tagged
// nodes owned by the same user as src. A tagged or
// IP-only src has no user identity, so the dst set
// is empty and the caller surfaces it as a failure
// (matches SaaS, which treats a no-node dst as a
// failing assertion).
if srcUserID == 0 {
emptyDsts = append(emptyDsts, dstLabel)
@@ -500,6 +565,9 @@ func resolveSSHTestDestNodes(
continue
}
// Compile to an IPSet for the InIPSet primitive. ResolvedAddresses
// already wraps one; expose it via the IPSet builder by walking
// the resolved prefixes.
set, err := prefixesToIPSet(ips.Prefixes())
if err != nil {
return nil, nil, fmt.Errorf("building IPSet for %q: %w", dstLabel, err)
@@ -528,8 +596,10 @@ func resolveSSHTestDestNodes(
return out, emptyDsts, nil
}
// prefixesToIPSet builds the [netipx.IPSet] that [types.NodeView.InIPSet]
// expects on the node side.
// prefixesToIPSet builds a netipx.IPSet from a slice of prefixes. The
// SSH compiler does the same dance via netipx.IPSetBuilder; we mirror
// the shape so InIPSet (the node-side primitive) behaves identically
// for test evaluation and live compilation.
func prefixesToIPSet(prefixes []netip.Prefix) (*netipx.IPSet, error) {
var b netipx.IPSetBuilder
@@ -540,9 +610,10 @@ func prefixesToIPSet(prefixes []netip.Prefix) (*netipx.IPSet, error) {
return b.IPSet()
}
// compiledSSHPolicy returns the per-node compiled [tailcfg.SSHPolicy], caching
// on miss. baseURL is empty because reachability only checks for the
// presence of [tailcfg.SSHAction.HoldAndDelegate], not its value.
// compiledSSHPolicy returns the per-node compiled SSH policy, populating
// cache on miss. baseURL is empty because the engine only needs the
// "is this rule a check rule" signal (HoldAndDelegate non-empty), not
// the actual URL contents.
func compiledSSHPolicy(
pol *Policy,
users []types.User,
@@ -564,10 +635,15 @@ func compiledSSHPolicy(
return sshPol, nil
}
// reachability reports whether srcAddr can log in as user via:
// reachability walks dstPolicy.Rules and reports whether srcAddr is
// allowed to log in as user via:
//
// - any matching rule (acceptHit, satisfies accept assertions)
// - a check-action rule (checkHit, satisfies check assertions)
// - any rule (first return) — satisfies accept assertions
// - a check rule specifically (second return) — satisfies check assertions
//
// A nil policy is treated as "no rule matches", which is the right
// answer for both accept (DENIED) and check (DENIED) and for deny
// (PASS, because the deny assertion inverts).
func reachability(
dstPolicy *tailcfg.SSHPolicy,
srcAddr netip.Addr,
@@ -598,8 +674,8 @@ func reachability(
checkHit = true
}
// Early-out only when both bits are set: a rule satisfying
// accept does not always satisfy check.
// Early-out only when both bits are set; a rule that
// satisfies one assertion may not satisfy the other.
if acceptHit && checkHit {
return acceptHit, checkHit
}
@@ -608,8 +684,9 @@ func reachability(
return acceptHit, checkHit
}
// principalContainsAddr reports whether any principal's [tailcfg.SSHPrincipal.NodeIP]
// matches srcAddr exactly (the SSH compiler emits one principal per source IP).
// principalContainsAddr reports whether any principal has a NodeIP
// matching srcAddr. The SSH compiler emits one principal per source
// IP, so an exact-match comparison is correct.
func principalContainsAddr(
principals []*tailcfg.SSHPrincipal,
srcAddr netip.Addr,
@@ -636,13 +713,19 @@ func principalContainsAddr(
return false
}
// sshUserMapAllows reports whether [SSHUsers] permits user. The [SSHUsers]
// wire shape (see filter.go compileSSHPolicy):
// sshUserMapAllows reports whether SSHUsers permits user. The wire
// shape (see filter.go compileSSHPolicy):
//
// - SSHUsers["root"] == "root" allows root; == "" disallows it.
// - SSHUsers["*"] == "=" is the wildcard fallback for non-root users
// (set when the rule lists autogroup:nonroot).
// - SSHUsers[<literal>] == <literal> for every named user.
// - SSHUsers["root"] == "root" when the rule's users list contains
// "root", and == "" otherwise (the empty mapping means "root NOT
// allowed", per Tailscale's SSH evaluator).
// - SSHUsers["*"] == "=" when the rule's users list contains
// autogroup:nonroot — wildcard fallback for any non-root user.
// - SSHUsers[<literal>] == <literal> for every named SSH user in
// the rule.
//
// An empty user input (which the parse layer accepts but treats as
// a failure case) cannot match any map entry.
func sshUserMapAllows(m map[string]string, user string) bool {
if user == "" {
return false
+37 -10
View File
@@ -1,8 +1,21 @@
// Replay golden HuJSON captures under testdata/sshtest_results/*.hujson:
// the 200 path requires headscale's evaluateSSHTests to pass; the
// non-200 path requires headscale to reject the same input with the
// captured error body as a substring. Divergences are listed in
// knownSSHTesterDivergences with the engine gap each represents.
// Compatibility tests for the policy `sshTests` block, replaying captures
// recorded against a real Tailscale SaaS tailnet. The runner mirrors the
// pattern in policytester_compat_test.go: a single Glob over a testdata
// directory, one t.Run per file. Each capture is one of:
//
// - APIResponseCode != 200 — the policy was rejected by SaaS, the
// captured Message is the body the user saw, and headscale must
// reject the same input with an error string that contains the same
// body (substring match, allowing wrapping like "test(s) failed:\n…").
// - APIResponseCode == 200 — SaaS accepted the policy (its sshTests
// block passed); headscale's evaluateSSHTests must also pass.
//
// Captures live in testdata/sshtest_results/*.hujson. Scenarios in
// knownSSHTesterDivergences are skipped with their tracking note —
// these are real Tailscale ↔ headscale divergences that need engine-level
// fixes in follow-up PRs.
//
// Source format: github.com/juanfont/headscale/hscontrol/types/testcapture
package v2
@@ -15,10 +28,20 @@ import (
"github.com/stretchr/testify/require"
)
// knownSSHTesterDivergences names the engine gap for each capture where
// headscale and upstream disagree.
// knownSSHTesterDivergences tracks scenarios where headscale and SaaS
// disagree on whether a policy is accepted. Each entry should describe
// the engine area a follow-up PR needs to touch.
var knownSSHTesterDivergences = map[string]string{
"sshtest-malformed-dst-bare-ipv6": "bare-IPv6 sshTests dst: upstream parse-accepts then engine-rejects; headscale accepts (IPv4 mirror passes both sides)",
// SaaS parse-accepts a bare IPv6 sshTests dst but engine-rejects
// the same input with "test(s) failed" while the matching IPv4
// scenario engine-passes. The two captures share the same topology
// and the same policy shape, so the asymmetry is in the SaaS
// sshTests evaluator's IPv6 handling, not in any rule the user
// wrote. Headscale's evaluator resolves both literals to the
// tagged node that carries them and the assertion passes — a
// follow-up needs to either reproduce the SaaS-side IPv6 quirk or
// confirm this is a SaaS bug we will not match.
"sshtest-malformed-dst-bare-ipv6": "engine: SaaS rejects bare IPv6 sshTests dst; headscale accepts (IPv4 mirror passes both sides)",
}
func TestSSHTesterCompat(t *testing.T) {
@@ -44,8 +67,12 @@ func TestSSHTesterCompat(t *testing.T) {
t.Skip(reason)
}
// Each capture pins its own topology IPs; build nodes
// from the capture so host-alias dsts resolve.
// Per-capture nodes mean the topology IPs (which a
// policy `hosts` mapping references by literal IP)
// resolve to real nodes in the test fixture. Without
// this the static fixture's IPs do not overlap with
// the captures and host-alias dsts resolve to no
// nodes — that path is now a load-bearing failure.
nodes := buildGrantsNodesFromCapture(users, c)
policyJSON := []byte(c.Input.FullPolicy)
+3 -4
View File
@@ -4,7 +4,7 @@ package v2
// Tailscale-hosted control plane emits where headscale has no
// equivalent concept yet. The compat test in
// tailscale_nodeattrs_compat_test.go builds the self-view CapMap via
// [types.Node.TailNode] -- the same call the mapper makes -- and
// [types.NodeView.TailNode] -- the same call the mapper makes -- and
// strips these from BOTH sides before [cmp.Diff]; every other cap is
// compared in full as it lands on the wire.
//
@@ -30,9 +30,8 @@ import (
// (suggest-exit-node, dns-subdomain-resolve — see
// ipn/ipnlocal/local.go:7534 and node_backend.go:745) are emitted only
// when the peer satisfies the cap's emission condition. This function
// encodes those conditions; the mapper calls it from
// [mapper.MapResponseBuilder.buildTailPeers] and the compat test calls
// it to compute the expected per-peer wire shape.
// encodes those conditions; the mapper calls it from buildTailPeers and
// the compat test calls it to compute the expected per-peer wire shape.
func PeerCapMap(peer types.NodeView, peerSelfCaps tailcfg.NodeCapMap) tailcfg.NodeCapMap {
if len(peerSelfCaps) == 0 {
return nil
@@ -51,6 +51,60 @@ func setupACLCompatUsers() types.Users {
}
}
// setupACLCompatNodes returns the 8 test nodes for ACL compatibility tests.
// Node GivenNames match the anonymized pokémon naming.
func setupACLCompatNodes(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:prod"}, Hostinfo: &tailcfg.Hostinfo{},
},
{
ID: 6, GivenName: "weedle",
IPv4: ptrAddr("100.83.200.69"), IPv6: ptrAddr("fd7a:115c:a1e0::c537:c845"),
Tags: []string{"tag:client"}, Hostinfo: &tailcfg.Hostinfo{},
},
{
ID: 7, GivenName: "squirtle",
IPv4: ptrAddr("100.92.142.61"), IPv6: ptrAddr("fd7a:115c:a1e0::3e37:8e3d"),
Tags: []string{"tag:router"},
Hostinfo: &tailcfg.Hostinfo{
RoutableIPs: []netip.Prefix{netip.MustParsePrefix("10.33.0.0/16")},
},
ApprovedRoutes: []netip.Prefix{netip.MustParsePrefix("10.33.0.0/16")},
},
{
ID: 8, GivenName: "charmander",
IPv4: ptrAddr("100.85.66.106"), IPv6: ptrAddr("fd7a:115c:a1e0::7c37:426a"),
Tags: []string{"tag:exit"}, Hostinfo: &tailcfg.Hostinfo{},
},
}
}
// findNodeByGivenName finds a node by its GivenName field.
func findNodeByGivenName(nodes types.Nodes, name string) *types.Node {
for _, n := range nodes {
@@ -357,11 +411,18 @@ func testACLSuccess(
}
// Compile headscale filter rules for this node
compiledRules := pol.compileFilterRulesForNode(
compiledRules, err := pol.compileFilterRulesForNode(
users,
node.View(),
nodes.ViewSlice(),
)
require.NoError(
t,
err,
"%s/%s: failed to compile filter rules",
tf.TestID,
nodeName,
)
gotRules := policyutil.ReduceFilterRules(
node.View(),
@@ -45,6 +45,218 @@ func setupGrantsCompatUsers() types.Users {
}
}
// setupGrantsCompatNodes returns the 15 test nodes for grants compatibility tests.
// The node configuration matches the Tailscale test environment:
// - 3 user-owned nodes (bulbasaur, ivysaur, venusaur)
// - 12 tagged nodes (beedrill, kakuna, weedle, squirtle, charmander,
// pidgey, pidgeotto, rattata, raticate, spearow, fearow, blastoise)
func setupGrantsCompatNodes(users types.Users) types.Nodes {
nodeBulbasaur := &types.Node{
ID: 1,
GivenName: "bulbasaur",
User: &users[0],
UserID: &users[0].ID,
IPv4: ptrAddr("100.90.199.68"),
IPv6: ptrAddr("fd7a:115c:a1e0::2d01:c747"),
Hostinfo: &tailcfg.Hostinfo{},
}
nodeIvysaur := &types.Node{
ID: 2,
GivenName: "ivysaur",
User: &users[1],
UserID: &users[1].ID,
IPv4: ptrAddr("100.110.121.96"),
IPv6: ptrAddr("fd7a:115c:a1e0::1737:7960"),
Hostinfo: &tailcfg.Hostinfo{},
}
nodeVenusaur := &types.Node{
ID: 3,
GivenName: "venusaur",
User: &users[2],
UserID: &users[2].ID,
IPv4: ptrAddr("100.103.90.82"),
IPv6: ptrAddr("fd7a:115c:a1e0::9e37:5a52"),
Hostinfo: &tailcfg.Hostinfo{},
}
nodeBeedrill := &types.Node{
ID: 4,
GivenName: "beedrill",
IPv4: ptrAddr("100.108.74.26"),
IPv6: ptrAddr("fd7a:115c:a1e0::b901:4a87"),
Tags: []string{"tag:server"},
Hostinfo: &tailcfg.Hostinfo{},
}
nodeKakuna := &types.Node{
ID: 5,
GivenName: "kakuna",
IPv4: ptrAddr("100.103.8.15"),
IPv6: ptrAddr("fd7a:115c:a1e0::5b37:80f"),
Tags: []string{"tag:prod"},
Hostinfo: &tailcfg.Hostinfo{},
}
nodeWeedle := &types.Node{
ID: 6,
GivenName: "weedle",
IPv4: ptrAddr("100.83.200.69"),
IPv6: ptrAddr("fd7a:115c:a1e0::c537:c845"),
Tags: []string{"tag:client"},
Hostinfo: &tailcfg.Hostinfo{},
}
nodeSquirtle := &types.Node{
ID: 7,
GivenName: "squirtle",
IPv4: ptrAddr("100.92.142.61"),
IPv6: ptrAddr("fd7a:115c:a1e0::3e37:8e3d"),
Tags: []string{"tag:router"},
Hostinfo: &tailcfg.Hostinfo{
RoutableIPs: []netip.Prefix{netip.MustParsePrefix("10.33.0.0/16")},
},
ApprovedRoutes: []netip.Prefix{netip.MustParsePrefix("10.33.0.0/16")},
}
nodeCharmander := &types.Node{
ID: 8,
GivenName: "charmander",
IPv4: ptrAddr("100.85.66.106"),
IPv6: ptrAddr("fd7a:115c:a1e0::7c37:426a"),
Tags: []string{"tag:exit"},
Hostinfo: &tailcfg.Hostinfo{
RoutableIPs: []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/0"),
netip.MustParsePrefix("::/0"),
},
},
ApprovedRoutes: []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/0"),
netip.MustParsePrefix("::/0"),
},
}
// --- New nodes for expanded via grant topology ---
nodePidgey := &types.Node{
ID: 9,
GivenName: "pidgey",
IPv4: ptrAddr("100.124.195.93"),
IPv6: ptrAddr("fd7a:115c:a1e0::7837:c35d"),
Tags: []string{"tag:exit-a"},
Hostinfo: &tailcfg.Hostinfo{
RoutableIPs: []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/0"),
netip.MustParsePrefix("::/0"),
},
},
ApprovedRoutes: []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/0"),
netip.MustParsePrefix("::/0"),
},
}
nodePidgeotto := &types.Node{
ID: 10,
GivenName: "pidgeotto",
IPv4: ptrAddr("100.116.18.24"),
IPv6: ptrAddr("fd7a:115c:a1e0::ff37:1218"),
Tags: []string{"tag:exit-b"},
Hostinfo: &tailcfg.Hostinfo{
RoutableIPs: []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/0"),
netip.MustParsePrefix("::/0"),
},
},
ApprovedRoutes: []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/0"),
netip.MustParsePrefix("::/0"),
},
}
nodeRattata := &types.Node{
ID: 11,
GivenName: "rattata",
IPv4: ptrAddr("100.107.162.14"),
IPv6: ptrAddr("fd7a:115c:a1e0::a237:a20e"),
Tags: []string{"tag:group-a"},
Hostinfo: &tailcfg.Hostinfo{},
}
nodeRaticate := &types.Node{
ID: 12,
GivenName: "raticate",
IPv4: ptrAddr("100.77.135.18"),
IPv6: ptrAddr("fd7a:115c:a1e0::4b37:8712"),
Tags: []string{"tag:group-b"},
Hostinfo: &tailcfg.Hostinfo{},
}
nodeSpearow := &types.Node{
ID: 13,
GivenName: "spearow",
IPv4: ptrAddr("100.109.43.124"),
IPv6: ptrAddr("fd7a:115c:a1e0::a537:2b7c"),
Tags: []string{"tag:router-a"},
Hostinfo: &tailcfg.Hostinfo{
RoutableIPs: []netip.Prefix{netip.MustParsePrefix("10.44.0.0/16")},
},
ApprovedRoutes: []netip.Prefix{netip.MustParsePrefix("10.44.0.0/16")},
}
nodeFearow := &types.Node{
ID: 14,
GivenName: "fearow",
IPv4: ptrAddr("100.65.172.123"),
IPv6: ptrAddr("fd7a:115c:a1e0::5a37:ac7c"),
Tags: []string{"tag:router-b"},
Hostinfo: &tailcfg.Hostinfo{
RoutableIPs: []netip.Prefix{netip.MustParsePrefix("10.55.0.0/16")},
},
ApprovedRoutes: []netip.Prefix{netip.MustParsePrefix("10.55.0.0/16")},
}
nodeBlastoise := &types.Node{
ID: 15,
GivenName: "blastoise",
IPv4: ptrAddr("100.105.127.107"),
IPv6: ptrAddr("fd7a:115c:a1e0::9537:7f6b"),
Tags: []string{"tag:exit", "tag:router"},
Hostinfo: &tailcfg.Hostinfo{
RoutableIPs: []netip.Prefix{
netip.MustParsePrefix("10.33.0.0/16"),
netip.MustParsePrefix("0.0.0.0/0"),
netip.MustParsePrefix("::/0"),
},
},
ApprovedRoutes: []netip.Prefix{
netip.MustParsePrefix("10.33.0.0/16"),
netip.MustParsePrefix("0.0.0.0/0"),
netip.MustParsePrefix("::/0"),
},
}
return types.Nodes{
nodeBulbasaur,
nodeIvysaur,
nodeVenusaur,
nodeBeedrill,
nodeKakuna,
nodeWeedle,
nodeSquirtle,
nodeCharmander,
nodePidgey,
nodePidgeotto,
nodeRattata,
nodeRaticate,
nodeSpearow,
nodeFearow,
nodeBlastoise,
}
}
// findGrantsNode finds a node by its GivenName in the grants test environment.
func findGrantsNode(nodes types.Nodes, name string) *types.Node {
for _, n := range nodes {
@@ -284,11 +496,18 @@ func testGrantSuccess(
"golden node %s not found in test setup", nodeName)
// Compile headscale filter rules for this node
gotRules := pol.compileFilterRulesForNode(
gotRules, err := pol.compileFilterRulesForNode(
users,
node.View(),
nodes.ViewSlice(),
)
require.NoError(
t,
err,
"%s/%s: failed to compile filter rules",
tf.TestID,
nodeName,
)
gotRules = policyutil.ReduceFilterRules(node.View(), gotRules)
@@ -236,11 +236,18 @@ func TestRoutesCompat(t *testing.T) {
require.NotNilf(t, node,
"golden node %s not found in topology", nodeName)
compiledRules := pol.compileFilterRulesForNode(
compiledRules, err := pol.compileFilterRulesForNode(
users,
node.View(),
nodes.ViewSlice(),
)
require.NoError(
t,
err,
"%s/%s: failed to compile filter rules",
tf.TestID,
nodeName,
)
gotRules := policyutil.ReduceFilterRules(
node.View(),
@@ -1,9 +1,22 @@
// Replay golden HuJSON captures under testdata/ssh_results/ssh-*.hujson:
// the 200 path compares headscale's compileSSHPolicy output node-by-node
// against the captured SSHRules; the non-200 path requires headscale to
// reject the same input with the captured error body as a substring.
// Divergences are listed in sshSkipReasons (200) and sshRejectSkipReasons
// (non-200) with the engine gap each represents.
// This file implements a data-driven test runner for SSH compatibility tests.
// It loads HuJSON golden files from testdata/ssh_results/ssh-*.hujson, captured
// from a Tailscale-hosted control plane, and compares headscale's SSH policy
// compilation against the captured SSH rules.
//
// Each capture is one of:
// - APIResponseCode == 200 — SaaS accepted the policy; the captured
// per-node SSH rules in tf.Captures[name].SSHRules are the source of
// truth, and headscale's compileSSHPolicy must produce the same shape.
// - APIResponseCode != 200 — SaaS rejected the policy at the API; the
// captured Message is the body the user saw. headscale must reject
// the same input with an error whose text contains that body as a
// substring (mirroring sshtester_compat_test.go).
//
// Tests known to diverge are listed in sshSkipReasons (200 path) or
// sshRejectSkipReasons (!= 200 path) with a TODO explaining the gap.
//
// Test data source: testdata/ssh_results/ssh-*.hujson
// Source format: github.com/juanfont/headscale/hscontrol/types/testcapture
package v2
@@ -22,10 +35,16 @@ import (
"tailscale.com/tailcfg"
)
// setupSSHDataCompatUsers returns three users straddling two email
// domains so that "localpart:*@example.com" resolves to exactly two
// users (odin, freya) and the cross-domain case stays covered through
// thor on @example.org.
// setupSSHDataCompatUsers returns the 3 test users for SSH data-driven
// compatibility tests. Users get norse-god names; nodes get original-151
// pokémon names — matching the anonymized identifiers the capture
// tool writes into the capture files.
//
// odin and freya live on @example.com; thor lives on @example.org so
// that "localpart:*@example.com" resolves to exactly two users
// (matching SaaS output) and the "user on a different email domain"
// case stays covered by scenarios like ssh-d1 that use
// "localpart:*@example.org".
func setupSSHDataCompatUsers() types.Users {
return types.Users{
{
@@ -56,29 +75,49 @@ func loadSSHTestFile(t *testing.T, path string) *testcapture.Capture {
return c
}
// sshSkipReasons documents captures the upstream control plane accepts
// but headscale cannot yet represent. Each entry names the feature gap.
// sshSkipReasons documents APIResponseCode == 200 captures where SaaS
// accepted the policy but headscale either does not yet support the
// shape or rejects it stricter than SaaS does. Each entry should
// describe the gap a follow-up PR needs to close (or justify why
// headscale is intentionally stricter).
var sshSkipReasons = map[string]string{
"ssh-b5": "headscale has no passkey authentication; user:*@passkey wildcard unsupported",
"ssh-d10": "headscale has no passkey authentication; user:*@passkey wildcard unsupported",
// USER_PASSKEY_WILDCARD (2 tests)
//
// headscale does not support passkey authentication and has no
// equivalent for the user:*@passkey wildcard pattern.
"ssh-b5": "user:*@passkey wildcard not supported in headscale",
"ssh-d10": "user:*@passkey wildcard not supported in headscale",
}
// sshRejectSkipReasons documents captures the upstream control plane
// rejects for reasons headscale cannot apply. Each entry names the
// feature gap.
// sshRejectSkipReasons documents APIResponseCode != 200 captures where
// headscale and SaaS legitimately disagree on whether the policy should
// be rejected (or where headscale rejects with different wording).
var sshRejectSkipReasons = map[string]string{
"ssh-b4": "headscale has no associated-tailnet-domains config; user:*@domain / localpart:*@domain are not domain-validated",
"ssh-d1": "headscale has no associated-tailnet-domains config; user:*@domain / localpart:*@domain are not domain-validated",
"ssh-e1": "headscale has no associated-tailnet-domains config; user:*@domain / localpart:*@domain are not domain-validated",
"ssh-e2": "headscale has no associated-tailnet-domains config; user:*@domain / localpart:*@domain are not domain-validated",
"ssh-malformed-user-localpart-multi-glob": "headscale has no associated-tailnet-domains config; user:*@domain / localpart:*@domain are not domain-validated",
// DOMAIN_NOT_ASSOCIATED (5 tests)
//
// SaaS validates that email domains in user:*@domain and
// localpart:*@domain expressions are configured tailnet
// domains. headscale has no concept of "associated tailnet
// domains" — it only has users with email addresses. These
// policies are legitimately rejected by SaaS but not by
// headscale.
"ssh-b4": "domain validation: headscale has no 'associated tailnet domains' concept",
"ssh-d1": "domain validation: headscale has no 'associated tailnet domains' concept",
"ssh-e1": "domain validation: headscale has no 'associated tailnet domains' concept",
"ssh-e2": "domain validation: headscale has no 'associated tailnet domains' concept",
"ssh-malformed-user-localpart-multi-glob": "domain validation: headscale has no 'associated tailnet domains' concept (same gap as ssh-b4/d1/e1/e2)",
}
// TestSSHDataCompat loads every ssh-*.hujson capture, parses the policy
// it pinned, and compiles the same per-node SSH rules to compare against
// the captured shape. Non-200 captures replay the rejection path: the
// recorded error body must appear as a substring of headscale's
// rejection.
// TestSSHDataCompat is a data-driven test that loads all ssh-*.hujson test
// files captured from Tailscale SaaS and compares headscale's SSH policy
// compilation against the real Tailscale behavior.
//
// Each capture file contains:
// - The full policy that was POSTed to the SaaS API (Input.FullPolicy)
// - Expected SSH rules per node (Captures[name].SSHRules)
//
// The test converts Tailscale user email formats to headscale format and runs
// the captured policy through unmarshalPolicy and compileSSHPolicy.
func TestSSHDataCompat(t *testing.T) {
t.Parallel()
@@ -112,15 +151,27 @@ func TestSSHDataCompat(t *testing.T) {
t.Run(tf.TestID, func(t *testing.T) {
t.Parallel()
// Each capture pins its own topology IPs, so nodes are
// rebuilt from the capture rather than a shared fixture.
// Build nodes per-scenario from this file's topology.
// tscap uses clean-slate mode, so each scenario has
// different node IPs.
nodes := buildGrantsNodesFromCapture(users, tf)
// Use the captured full policy as is. Anonymization in
// tscap already rewrites SaaS emails to @example.com.
policyJSON := []byte(tf.Input.FullPolicy)
// Branch on the SaaS response code. Captures with
// APIResponseCode != 200 are policies SaaS rejected at
// the API; headscale must reject the same input. The
// 200 path falls through to the existing per-node SSH
// rule comparison.
if tf.Input.APIResponseCode != 200 {
if reason, ok := sshRejectSkipReasons[tf.TestID]; ok {
t.Skipf("skipping: %s", reason)
t.Skipf(
"TODO: %s — see sshRejectSkipReasons for details",
reason,
)
return
}
@@ -155,8 +206,14 @@ func TestSSHDataCompat(t *testing.T) {
return
}
// APIResponseCode == 200: SaaS accepted; headscale must
// match the captured per-node SSH rules.
if reason, ok := sshSkipReasons[tf.TestID]; ok {
t.Skipf("skipping: %s", reason)
t.Skipf(
"TODO: %s — see sshSkipReasons comments for details",
reason,
)
return
}
+57 -36
View File
@@ -25,9 +25,16 @@ import (
// The tests evaluate against the compiled global filter rules, which fold in
// both `acls` and `grants`, so the `tests` block validates the whole policy.
// errPolicyTestsFailed and errSSHPolicyTestsFailed share the
// "test(s) failed" prefix but stay distinct so callers can use
// [errors.Is] to tell ACL-test and SSH-test failures apart.
// errPolicyTestsFailed wraps the rendered failure body so callers can
// type-assert when they need to react differently to test failures vs. parse
// errors. The Error() prefix is "test(s) failed", the same string Tailscale
// SaaS returns in the api_response_body.message — see
// hscontrol/policy/v2/testdata/policytest_results/.
//
// errSSHPolicyTestsFailed wraps sshTests failures. Tailscale SaaS returns the
// same literal "test(s) failed" body for both ACL tests and SSH tests, but
// the two sentinels are kept as distinct values so callers can use errors.Is
// to tell them apart while still matching the SaaS body byte-for-byte.
var (
errPolicyTestsFailed = errors.New("test(s) failed")
errSSHPolicyTestsFailed = errors.New("test(s) failed")
@@ -53,36 +60,47 @@ type PolicyTest struct {
Deny []string `json:"deny,omitempty"`
}
// SSHPolicyTest is one entry in the policy's `sshTests` block. The
// accept/deny/check arrays carry usernames, not destinations — every
// listed user is asserted against every entry in [SSHPolicyTest.Dst].
// SSHPolicyTest is one entry in the policy's `sshTests` block. Unlike the
// ACL `tests` block, sshTests describe SSH login attempts: a source alias
// connects to each destination host and tries each named login user. The
// accept / deny / check arrays carry usernames, not destinations — every
// listed user is asserted against every entry in dst.
type SSHPolicyTest struct {
// Src is a single source alias (user, group, tag, host, or IP).
// Src is a single source alias (user, group, tag, host, or IP). Same
// shape as PolicyTest.Src — Tailscale only supports one src per entry.
Src Alias `json:"src"`
// Dst lists destinations the test exercises (tag, host, or SSH-
// compatible autogroup). Ports, CIDRs, and autogroup:internet are
// rejected at parse time.
// Dst lists destination host aliases the test exercises. Tags, hosts,
// and the SSH-compatible autogroups are valid; ports, CIDR ranges, and
// autogroup:internet are rejected at parse time.
Dst SSHTestDestinations `json:"dst"`
// Accept lists users that must reach every Dst via an accept- or
// check-action rule.
// Accept lists SSH login users that must be allowed by an action:accept
// or action:check rule when Src connects to each entry in Dst.
Accept []SSHUser `json:"accept,omitempty"`
// Deny lists users that must NOT reach any Dst.
// Deny lists SSH login users that must NOT be allowed by any rule when
// Src connects to each entry in Dst.
Deny []SSHUser `json:"deny,omitempty"`
// Check lists users that must reach every Dst via a check-action
// rule specifically; an accept-action rule does not satisfy this.
// Check lists SSH login users that must reach every dst via an
// action:check rule specifically (the HoldAndDelegate signal on the
// compiled SSH policy). An action:accept rule alone does not satisfy
// a check assertion — SaaS keeps the two categories distinct so
// policy authors can pin sensitive logins to check rules.
Check []SSHUser `json:"check,omitempty"`
}
// SSHTestDestinations is the typed list of destination aliases an
// sshTests entry targets. [validateSSHTestDestination] enforces the
// SSH-specific shape rules (no :port, no CIDR, no autogroup:internet,
// SSHTestDestinations is the list of destination aliases an sshTests entry
// targets. Unmarshalling reuses the same alias parser the rest of the
// policy engine drives so each element lands as a typed Alias; the parse-
// time shape rules in validateSSHTestDestination continue to enforce the
// SSH-specific restrictions (no :port, no CIDR, no autogroup:internet,
// known tag).
type SSHTestDestinations []Alias
// UnmarshalJSON walks the JSON array, dispatching each element through
// AliasEnc so trimming and prefix detection match the rest of the parser.
func (d *SSHTestDestinations) UnmarshalJSON(b []byte) error {
var aliases []AliasEnc
@@ -99,9 +117,13 @@ func (d *SSHTestDestinations) UnmarshalJSON(b []byte) error {
return nil
}
// UnmarshalJSON parses each typed field. An empty src lands as a nil
// [Alias] so validation surfaces [ErrSSHTestEmptySrc] rather than a parser
// failure.
// UnmarshalJSON drives the typed shape of SSHPolicyTest. The wire format
// is unchanged: src is a JSON string parsed through parseAlias; dst is an
// array of strings handled by SSHTestDestinations; accept/deny/check are
// arrays of strings handled per element by SSHUser.UnmarshalJSON. An
// empty src string lands as a nil Alias so the empty-src case stays a
// validation-time error with the SaaS-aligned ErrSSHTestEmptySrc body
// rather than a raw parser failure.
func (t *SSHPolicyTest) UnmarshalJSON(b []byte) error {
var raw struct {
Src string `json:"src"`
@@ -134,7 +156,7 @@ func (t *SSHPolicyTest) UnmarshalJSON(b []byte) error {
return nil
}
// PolicyTestResult is the outcome of a single [PolicyTest].
// PolicyTestResult is the outcome of a single PolicyTest.
type PolicyTestResult struct {
Src string `json:"src"`
Proto Protocol `json:"proto,omitempty"`
@@ -216,7 +238,7 @@ func (pm *PolicyManager) RunTests() error {
}
// evaluateTests runs the `tests` block against a fresh compilation of pol.
// It is the user-write sandbox: the live [PolicyManager] state is left
// It is the user-write sandbox: the live PolicyManager state is left
// untouched, so a failing test rejects the write without side effects.
func evaluateTests(pol *Policy, users []types.User, nodes views.Slice[types.NodeView]) error {
if pol == nil || len(pol.Tests) == 0 {
@@ -262,7 +284,7 @@ func runPolicyTests(pol *Policy, filter []tailcfg.FilterRule, users []types.User
return results
}
// runPolicyTest evaluates one [PolicyTest].
// runPolicyTest evaluates one PolicyTest.
func runPolicyTest(test PolicyTest, pol *Policy, filter []tailcfg.FilterRule, users []types.User, nodes views.Slice[types.NodeView]) PolicyTestResult {
res := PolicyTestResult{
Src: test.Src,
@@ -322,8 +344,8 @@ func runPolicyTest(test PolicyTest, pol *Policy, filter []tailcfg.FilterRule, us
return res
}
// resolveTestSource resolves the Src alias of a [PolicyTest] into a slice of
// [netip.Prefix]. [parseAlias] + [Alias.Resolve] cover every alias type the rest
// resolveTestSource resolves the Src alias of a PolicyTest into a slice of
// netip.Prefix. parseAlias + Alias.Resolve cover every alias type the rest
// of the policy engine supports, so tests inherit alias semantics for free.
func resolveTestSource(src string, pol *Policy, users []types.User, nodes views.Slice[types.NodeView]) ([]netip.Prefix, error) {
alias, err := parseAlias(src)
@@ -377,13 +399,13 @@ func evalReachability(srcPrefixes []netip.Prefix, dst string, proto Protocol, po
return true, nil
}
// parseDestinationAlias is a thin wrapper over [AliasWithPorts.UnmarshalJSON]
// parseDestinationAlias is a thin wrapper over AliasWithPorts.UnmarshalJSON
// so callers can hand it a bare `"host:port"` string without re-implementing
// the parse logic.
func parseDestinationAlias(dst string) (*AliasWithPorts, error) {
var awp AliasWithPorts
// [AliasWithPorts.UnmarshalJSON] expects a quoted JSON string, so wrap.
// AliasWithPorts.UnmarshalJSON expects a quoted JSON string, so wrap.
err := awp.UnmarshalJSON([]byte(`"` + dst + `"`))
if err != nil {
return nil, err
@@ -425,9 +447,9 @@ func srcReachesDst(src netip.Prefix, dstPrefixes []netip.Prefix, ports []tailcfg
}
// ruleMatchesSource reports whether the rule's source list contains src.
// [tailcfg.FilterRule.SrcIPs] may be CIDR, single addresses, IP ranges (`a-b`),
// or `*`; we use [util.ParseIPSet] to cover all of those uniformly. Unparseable
// entries are skipped (the rule compiler emits well-formed strings, so this is
// SrcIPs may be CIDR, single addresses, IP ranges (`a-b`), or `*`; we use
// util.ParseIPSet to cover all of those uniformly. Unparseable entries
// are skipped (the rule compiler emits well-formed strings, so this is
// defence-in-depth, not error handling).
func ruleMatchesSource(rule tailcfg.FilterRule, src netip.Prefix) bool {
for _, raw := range rule.SrcIPs {
@@ -445,10 +467,9 @@ func ruleMatchesSource(rule tailcfg.FilterRule, src netip.Prefix) bool {
}
// ruleMatchesProto reports whether the rule permits any of requestedProtos.
// An unset [tailcfg.FilterRule.IPProto] means "any protocol" and matches
// everything. requestedProtos is the per-test protocol set: a single proto
// for an explicit [PolicyTest.Proto], or the default set when
// [PolicyTest.Proto] is empty.
// An unset rule.IPProto means "any protocol" and matches everything.
// requestedProtos is the per-test protocol set: a single proto for an
// explicit test.Proto, or the default set when test.Proto is empty.
func ruleMatchesProto(rule tailcfg.FilterRule, requestedProtos []int) bool {
if len(rule.IPProto) == 0 {
return true
@@ -480,7 +501,7 @@ func ruleAllowsAnyDest(rule tailcfg.FilterRule, dstPrefixes []netip.Prefix, port
return false
}
// destEntryMatchesPrefixes reports whether the rule's [tailcfg.NetPortRange.IP]
// destEntryMatchesPrefixes reports whether the rule's NetPortRange.IP
// (CIDR, single IP, IP range, or "*") covers any prefix in dstPrefixes.
func destEntryMatchesPrefixes(dp tailcfg.NetPortRange, dstPrefixes []netip.Prefix) bool {
set, err := util.ParseIPSet(dp.IP, nil)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

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