Compare commits

..

6 Commits

Author SHA1 Message Date
Kristoffer Dalby 8b17c26d11 oidc: harden callback CSRF cookies, state reuse, and issuer config
Defence-in-depth fixes to the OIDC login flow.

Set the state/nonce cookie Secure flag from the configured server_url
scheme rather than req.TLS, so the cookies stay Secure behind a
TLS-terminating reverse proxy where the proxy-to-Headscale hop is plain
HTTP. Deriving it from config avoids trusting a spoofable
X-Forwarded-Proto header.

Make the OIDC state single-use: consume it from the cache on the
callback and clear the state/nonce cookies once validated, so a replayed
callback cannot resolve the same session and the cookies do not linger
until expiry.

Bound OIDC discovery to the caller's context so a slow or unreachable
issuer fails startup within the timeout instead of hanging, and validate
the issuer URL and required client_id/client_secret at config load so an
unworkable setup fails fast.
2026-06-24 19:34:03 +00:00
Kristoffer Dalby 3fca03ad61 changelog: note OAuth clients and scopes 2026-06-24 19:34:03 +00:00
Kristoffer Dalby 90bffbebdd hscontrol, integration: test OAuth scope enforcement and the oauth-clients CLI
Add scope-enforcement coverage and integration tests for the
oauth-clients CLI across the test matrix.
2026-06-24 19:34:03 +00:00
Kristoffer Dalby 84bb7c3794 hscontrol, cli: serve the v2 API on the local socket and add the oauth-clients command
Serve the v2 API over the local unix socket for the CLI and add the
oauth-clients command that drives it.
2026-06-24 19:34:03 +00:00
Kristoffer Dalby 87555ce621 api/v2: add OAuth client-credentials auth and scope enforcement 2026-06-24 17:28:31 +00:00
Kristoffer Dalby 3fdc068c8c hscontrol: add the OAuth client and access-token model
Add the OAuth client type, its database storage, the scope grant package,
policy tag-ownership exposure, and the state operations backing the v2
OAuth client-credentials flow.
2026-06-24 17:28:31 +00:00
150 changed files with 1158 additions and 6684 deletions
+2 -2
View File
@@ -35,7 +35,7 @@ jobs:
- 'config-example.yaml'
- uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main
if: steps.changed-files.outputs.files == 'true'
- uses: Mic92/hestia@fb239a2f72d4b6e26eec5425f289dea23b27a527 # v2.0.0
- uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main
if: steps.changed-files.outputs.files == 'true'
- name: Check vendor hash
@@ -82,7 +82,7 @@ jobs:
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main
- uses: Mic92/hestia@fb239a2f72d4b6e26eec5425f289dea23b27a527 # v2.0.0
- uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main
- name: Run go cross compile
env:
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
- 'tools/**'
- uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main
if: steps.changed-files.outputs.files == 'true'
- uses: Mic92/hestia@fb239a2f72d4b6e26eec5425f289dea23b27a527 # v2.0.0
- uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main
if: steps.changed-files.outputs.files == 'true'
- name: Run make generate
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
- 'config-example.yaml'
- uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main
if: steps.changed-files.outputs.files == 'true'
- uses: Mic92/hestia@fb239a2f72d4b6e26eec5425f289dea23b27a527 # v2.0.0
- uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main
if: steps.changed-files.outputs.files == 'true'
- name: Generate and check integration tests
+2 -6
View File
@@ -45,7 +45,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main
- uses: Mic92/hestia@fb239a2f72d4b6e26eec5425f289dea23b27a527 # v2.0.0
- uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main
- name: Set commit timestamp
run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> $GITHUB_ENV
@@ -58,8 +58,6 @@ jobs:
run: |
ko build \
--bare \
--image-annotation=org.opencontainers.image.source=https://github.com/juanfont/headscale \
--image-label=org.opencontainers.image.source=https://github.com/juanfont/headscale \
--platform=linux/amd64,linux/arm64 \
--tags=main-${GITHUB_SHA::7},development \
./cmd/headscale
@@ -72,8 +70,6 @@ jobs:
run: |
ko build \
--bare \
--image-annotation=org.opencontainers.image.source=https://github.com/juanfont/headscale \
--image-label=org.opencontainers.image.source=https://github.com/juanfont/headscale \
--platform=linux/amd64,linux/arm64 \
--tags=main-${GITHUB_SHA::7},development \
./cmd/headscale
@@ -97,7 +93,7 @@ jobs:
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main
- uses: Mic92/hestia@fb239a2f72d4b6e26eec5425f289dea23b27a527 # v2.0.0
- uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main
- name: Build binary
env:
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main
- uses: Mic92/hestia@fb239a2f72d4b6e26eec5425f289dea23b27a527 # v2.0.0
- uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main
- name: Run garbage collection
run: '"${HESTIA_BIN}" gc ${{ inputs.dry-run && ''--dry-run'' || '''' }}'
env:
@@ -78,13 +78,6 @@ jobs:
echo '{"storage-driver":"overlay2"}' | sudo tee /etc/docker/daemon.json
sudo systemctl restart docker
docker version
- name: Load br_netfilter for in-cluster service routing
if: inputs.test == 'TestK8sOperator'
# TestK8sOperator runs k3s in a container; without br_netfilter on the
# host, bridged pod-to-pod traffic skips kube-proxy's ClusterIP DNAT and
# in-cluster DNS (kube-dns) is unreachable. The module cannot be loaded
# from inside the unprivileged-module rancher/k3s image, so load it here.
run: sudo modprobe br_netfilter
- name: Login to Docker Hub
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_CI_USERNAME }}
+4 -4
View File
@@ -24,7 +24,7 @@ jobs:
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main
- uses: Mic92/hestia@fb239a2f72d4b6e26eec5425f289dea23b27a527 # v2.0.0
- uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main
- name: build
run: nix build -L .#checks.x86_64-linux.build
@@ -33,7 +33,7 @@ jobs:
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main
- uses: Mic92/hestia@fb239a2f72d4b6e26eec5425f289dea23b27a527 # v2.0.0
- uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main
- name: gotest
run: nix build -L .#checks.x86_64-linux.gotest
@@ -42,7 +42,7 @@ jobs:
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main
- uses: Mic92/hestia@fb239a2f72d4b6e26eec5425f289dea23b27a527 # v2.0.0
- uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main
- name: golangci-lint
run: nix build -L .#checks.x86_64-linux.golangci-lint
@@ -51,6 +51,6 @@ jobs:
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main
- uses: Mic92/hestia@fb239a2f72d4b6e26eec5425f289dea23b27a527 # v2.0.0
- uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main
- name: formatting
run: nix build -L .#checks.x86_64-linux.formatting
+1 -1
View File
@@ -41,7 +41,7 @@ jobs:
- uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main
if: steps.changed-files.outputs.nix == 'true' || steps.changed-files.outputs.go == 'true'
- uses: Mic92/hestia@fb239a2f72d4b6e26eec5425f289dea23b27a527 # v2.0.0
- uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main
if: steps.changed-files.outputs.nix == 'true' || steps.changed-files.outputs.go == 'true'
- name: Run NixOS module tests
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main
- uses: Mic92/hestia@fb239a2f72d4b6e26eec5425f289dea23b27a527 # v2.0.0
- uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main
- name: Run goreleaser
run: goreleaser release --clean
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main
- uses: Mic92/hestia@fb239a2f72d4b6e26eec5425f289dea23b27a527 # v2.0.0
- uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main
- name: go test ./hscontrol/servertest
env:
+2 -11
View File
@@ -41,7 +41,7 @@ jobs:
- 'Dockerfile.*'
- uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main
if: steps.changed-files.outputs.files == 'true'
- uses: Mic92/hestia@fb239a2f72d4b6e26eec5425f289dea23b27a527 # v2.0.0
- uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main
if: steps.changed-files.outputs.files == 'true'
- name: Build binaries and warm Go cache
if: steps.changed-files.outputs.files == 'true'
@@ -160,7 +160,7 @@ jobs:
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main
- uses: Mic92/hestia@fb239a2f72d4b6e26eec5425f289dea23b27a527 # v2.0.0
- uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main
- name: Force overlay2 storage driver
run: |
sudo mkdir -p /etc/docker
@@ -311,7 +311,6 @@ jobs:
- Test2118DeletingOnlineNodePanics
- TestGrantCapRelay
- TestGrantCapDrive
- TestK8sOperator
- TestEnablingRoutes
- TestHASubnetRouterFailover
- TestSubnetRouteACL
@@ -352,8 +351,6 @@ jobs:
- TestTagsAuthKeyWithTagCannotAddViaCLI
- TestTagsAuthKeyWithTagCannotChangeViaCLI
- TestTagsAuthKeyWithTagAdminOverrideReauthPreserves
- TestTagsReauthDifferentKeyRetagsNode
- TestTagsReauthDifferentKeyRemovesTag
- TestTagsAuthKeyWithTagCLICannotModifyAdminTags
- TestTagsAuthKeyWithoutTagCannotRequestTags
- TestTagsAuthKeyWithoutTagRegisterNoTags
@@ -381,12 +378,6 @@ jobs:
- TestTagsAuthKeyWithoutUserInheritsTags
- TestTagsAuthKeyWithoutUserRejectsAdvertisedTags
- TestTagsAuthKeyConvertToUserViaCLIRegister
- TestTaggedNodeLogoutReloginSingleUseKeyOnline
- TestTaggedNodeLogoutReloginReusableKeyOnline
- TestTagsOIDCReauthAddOwnedTag
- TestTagsReauthEmptyTagsReturnsToUserSurvives
- TestTS2021WebSocketGET
- TestTS2021WASMClientUnderNode
- TestTailscaleRustAxum
uses: ./.github/workflows/integration-test-template.yml
secrets: inherit
-5
View File
@@ -3,15 +3,10 @@ version: "2"
linters:
default: all
disable:
# canonicalheader v1.1.2 panics (nil deref) on a generic method in a
# package that also calls http.Header.Set. Re-enable once golangci-lint
# ships a version that parses Go 1.27 generic methods.
- canonicalheader
- cyclop
- depguard
- dupl
- exhaustruct
- exhaustruct_v5
- funcorder
- funlen
- gochecknoglobals
+1 -9
View File
@@ -2,7 +2,7 @@
version: 2
before:
hooks:
- go mod tidy
- go mod tidy -compat=1.26
- go mod vendor
release:
@@ -102,10 +102,6 @@ kos:
# for tagging and naming the container.
bare: true
base_image: gcr.io/distroless/base-debian13
labels:
org.opencontainers.image.source: https://github.com/juanfont/headscale
annotations:
org.opencontainers.image.source: https://github.com/juanfont/headscale
build: headscale
main: ./cmd/headscale
env:
@@ -135,10 +131,6 @@ kos:
bare: true
base_image: gcr.io/distroless/base-debian13:debug
labels:
org.opencontainers.image.source: https://github.com/juanfont/headscale
annotations:
org.opencontainers.image.source: https://github.com/juanfont/headscale
build: headscale
main: ./cmd/headscale
env:
+2 -2
View File
@@ -66,7 +66,7 @@ similar lines of code is better than a premature abstraction.
## Quick Start
```bash
# Enter the nix dev shell (Go 1.27.0, buf, golangci-lint, prek)
# Enter the nix dev shell (Go 1.26.1, buf, golangci-lint, prek)
nix develop
# Full development workflow: fmt + lint + test + build
@@ -89,7 +89,7 @@ go run ./cmd/hi doctor
go run ./cmd/hi run "TestName"
```
Go 1.27.0 minimum (per `go.mod:3`). `nix develop` pins the exact toolchain
Go 1.26.1 minimum (per `go.mod:3`). `nix develop` pins the exact toolchain
used in CI.
## Pre-Commit with prek
-38
View File
@@ -1,38 +0,0 @@
# AI Policy
Using AI (i.e., LLMs) as tools for coding is welcome. A high bar is held for
all contributions to this project. Moreover, the project maintainers remain
responsible for any code that is published as part of a release. Contributors
are expected to be responsible for any code they publish.
**AI should not be used to generate comments when communicating with
maintainers**. Comments are expected to be written by humans. Comments that are
believed to be written by AI may be hidden without notice.
If you are opening an issue, you should be able to describe the problem in your
own words.
If you are opening a pull request, you are expected to be able to explain the
proposed changes in your own words. This includes the pull request body and
responses to questions. **Do not copy responses from the AI when replying to
questions from maintainers.**
This project requires a human in the loop who understands the work produced by
AI. **Autonomous agents are not allowed to be used for contributing to this
project**. Pull requests that appear in violation of this will be closed,
perhaps without notice.
If you wish to include context from an interaction with AI in your comments, it
must be in a quote block (e.g., using `>`) and disclosed as such. It must be
accompanied by human commentary explaining the relevance and implications of
the context. Do not share long snippets.
AI is useful when communicating as a non-native English speaker. If you are
using AI to edit your comments for this purpose, please take the time to ensure
it reflects your own voice and ideas. If using AI for translation, we recommend
writing in your native language and including the AI translation in a quote
block.
This policy was adapted from [uv's AI policy].
[uv's AI policy]: https://github.com/astral-sh/.github/blob/c5187e200db51bfe11d56e13053d29bd3793fdd8/AI_POLICY.md
+1 -28
View File
@@ -2,7 +2,7 @@
## 0.30.0 (202x-xx-xx)
**Minimum supported Tailscale client version: v1.82.0**
**Minimum supported Tailscale client version: v1.xx.0**
### v1 REST API replaced; gRPC and Protobuf removed
@@ -43,33 +43,6 @@ keys remain all-access.
### Changes
- Expiring or deleting a non-existent pre-auth key now returns an error instead of silently succeeding [#3324](https://github.com/juanfont/headscale/pull/3324)
- Improve systemd service file hardening [#3341](https://github.com/juanfont/headscale/pull/3341)
- Headscale now requires Go 1.27 to build
## 0.29.3 (2026-07-29)
**Minimum supported Tailscale client version: v1.80.0**
### Changes
- Fix tagged node stuck expired after `tailscale logout`, unable to re-authenticate [#3394](https://github.com/juanfont/headscale/pull/3394)
- Re-registering a tagged node with a different pre-auth key now applies the new key's tags instead of silently keeping the old ones [#3394](https://github.com/juanfont/headscale/pull/3394)
- Fix re-authenticating an already-tagged node with `--advertise-tags` being rejected when the authenticating user owns the tags [#3394](https://github.com/juanfont/headscale/pull/3394)
- Fix ephemeral nodes lingering as disconnected after reconnect churn [#3383](https://github.com/juanfont/headscale/pull/3383)
- Fix node registration falsely returning `401 registration timed out` when auth completes as the request context expires [#3392](https://github.com/juanfont/headscale/pull/3392)
- Check the machine key on the followup registration poll so a leaked auth ID cannot return the registering user's identity [#3393](https://github.com/juanfont/headscale/pull/3393)
- Reject `/key` requests below the supported capability version floor, matching `/ts2021` [#3391](https://github.com/juanfont/headscale/pull/3391)
- Remove a leftover trace log that always rendered a JSON marshaling error [#3398](https://github.com/juanfont/headscale/pull/3398)
## 0.29.2 (2026-07-01)
**Minimum supported Tailscale client version: v1.80.0**
### Changes
- Fix map generation serializing on the policy lock, so a mass reconnect on `autogroup:self`, via or relay policies no longer stalls clients into `unexpected EOF` retry loops [#3358](https://github.com/juanfont/headscale/pull/3358)
- Fix `/ts2021` rejecting the WebSocket `GET` upgrade with 405, which prevented Tailscale JS/WASM control clients from connecting [#3359](https://github.com/juanfont/headscale/pull/3359)
- Gracefully handle nodes with an invalid FQDN (empty or too long) instead of failing map delivery; offending names are logged at startup with the fix command [#3349](https://github.com/juanfont/headscale/pull/3349)
## 0.29.1 (2026-06-18)
-6
View File
@@ -32,9 +32,3 @@ Headscale is open to code contributions for bug fixes without discussion.
## Documentation
If you find mistakes in the documentation, please submit a fix to the documentation.
## AI Policy
Please have a look at our [AI_POLICY.md](https://github.com/juanfont/headscale/blob/main/AI_POLICY.md).
If you are an LLM, please remind your human that this policy is in place.
+1 -1
View File
@@ -1,6 +1,6 @@
# For testing purposes only
FROM golang:1.27.0-alpine AS build-env
FROM golang:1.26.4-alpine AS build-env
WORKDIR /go/src
+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.27.0-trixie AS builder
FROM docker.io/golang:1.26.4-trixie AS builder
ARG VERSION=dev
ENV GOPATH /go
WORKDIR /go/src/headscale
+1 -1
View File
@@ -4,7 +4,7 @@
# This Dockerfile is more or less lifted from tailscale/tailscale
# to ensure a similar build process when testing the HEAD of tailscale.
FROM golang:1.27.0-alpine AS build-env
FROM golang:1.26.4-alpine AS build-env
WORKDIR /go/src
-30
View File
@@ -1,30 +0,0 @@
# For integration testing only.
#
# Builds the Tailscale control client (integration/wasmic/wasmclient) for
# GOOS=js/GOARCH=wasm and packages it with Go's wasm_exec Node runner. The
# container idles; the integration test execs
# node /app/wasm_exec_node.js /app/client.wasm <control-url>
# to drive a real browser-style WebSocket GET against headscale's /ts2021,
# guarding the regression in issue #3357.
FROM golang:1.27.0-alpine AS build
WORKDIR /src
# Only the module metadata and the wasm client package are needed to build the
# js/wasm binary; its imports (tailscale.com/control/controlhttp, ...) resolve
# from the module proxy.
COPY go.mod go.sum ./
COPY integration/wasmic/wasmclient ./integration/wasmic/wasmclient
RUN GOOS=js GOARCH=wasm go build -o /out/client.wasm ./integration/wasmic/wasmclient \
&& cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" /out/wasm_exec.js \
&& cp "$(go env GOROOT)/lib/wasm/wasm_exec_node.js" /out/wasm_exec_node.js
FROM node:24-alpine
WORKDIR /app
COPY --from=build /out/ /app/
# Idle; the test execs the client on demand with the headscale control URL.
ENTRYPOINT ["tail", "-f", "/dev/null"]
+1 -1
View File
@@ -45,7 +45,7 @@ build: check-deps $(GO_SOURCES) go.mod go.sum
.PHONY: test
test: check-deps $(GO_SOURCES) go.mod go.sum
@echo "Running Go tests..."
CGO_ENABLED=1 go test -race ./...
go test -race ./...
# Formatting targets
-2
View File
@@ -90,8 +90,6 @@ The maintainers work together on setting the direction for the project. The unde
Please read the [CONTRIBUTING.md](./CONTRIBUTING.md) file.
Have also a look at our [AI_POLICY.md](./AI_POLICY.md).
### Requirements
To contribute to headscale you would need the latest version of [Go](https://golang.org)
+2
View File
@@ -118,6 +118,8 @@ var createUserCmd = &cobra.Command{
RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error {
userName := args[0]
log.Trace().Interface(zf.Client, client).Msg("obtained API client")
request := clientv1.CreateUserJSONRequestBody{Name: &userName}
if displayName, _ := cmd.Flags().GetString("display-name"); displayName != "" {
+1 -1
View File
@@ -55,7 +55,7 @@ changing.
| `--timeout` | `120m` | Total test timeout. Use the built-in flag — never wrap with bash `timeout`. |
| `--postgres` | `false` | Use PostgreSQL instead of SQLite |
| `--failfast` | `true` | Stop on first test failure |
| `--go-version` | auto | Detected from `go.mod` (currently 1.27.0) |
| `--go-version` | auto | Detected from `go.mod` (currently 1.26.1) |
| `--clean-before` | `true` | Clean stale (stopped/exited) containers before starting |
| `--clean-after` | `true` | Clean this run's containers after completion |
| `--keep-on-failure` | `false` | Preserve containers for manual inspection on failure |
+3 -18
View File
@@ -187,29 +187,14 @@ func removeContainerWithRetry(ctx context.Context, cli *client.Client, container
return err == nil
}
// testContainerNamePrefixes are the name prefixes used by containers that the
// integration test harness creates (headscale, tailscale, DERP, and k3s).
var testContainerNamePrefixes = []string{"hs-", "ts-", "derp-", "k3s-"}
// matchesTestContainerPrefix reports whether name belongs to an integration
// test container, ignoring any leading "/" that Docker prefixes names with.
func matchesTestContainerPrefix(name string) bool {
name = strings.TrimPrefix(name, "/")
for _, prefix := range testContainerNamePrefixes {
if strings.HasPrefix(name, prefix) {
return true
}
}
return false
}
// isTestContainerName reports whether any of the container names belong to an
// integration test container.
func isTestContainerName(names []string) bool {
for _, name := range names {
if strings.Contains(name, "headscale-test-suite") ||
matchesTestContainerPrefix(name) {
strings.Contains(name, "hs-") ||
strings.Contains(name, "ts-") ||
strings.Contains(name, "derp-") {
return true
}
}
+1 -1
View File
@@ -735,7 +735,7 @@ func getCurrentTestContainers(containers []container.Summary, testContainerID st
for _, cont := range containers {
for _, name := range cont.Names {
containerName := strings.TrimPrefix(name, "/")
if matchesTestContainerPrefix(containerName) {
if strings.HasPrefix(containerName, "hs-") || strings.HasPrefix(containerName, "ts-") {
// Check if container has matching run ID label
if cont.Labels != nil && cont.Labels["hi.run-id"] == runID {
testRunContainers = append(testRunContainers, testContainer{
-41
View File
@@ -10,7 +10,6 @@ import (
"strings"
"github.com/juanfont/headscale/integration/dockertestutil"
"github.com/juanfont/headscale/integration/k3sic"
)
const (
@@ -22,7 +21,6 @@ const (
nameDockerContext = "Docker Context"
nameDockerSocket = "Docker Socket"
nameGolangImage = "Golang Image"
nameK3sImage = "K3s Image"
nameGoInstall = "Go Installation"
)
@@ -68,7 +66,6 @@ func runDoctorCheck(ctx context.Context) error {
results = append(results, checkDockerSocket(ctx))
results = append(results, checkDockerHubCredentials())
results = append(results, checkGolangImage(ctx))
results = append(results, checkK3sImage(ctx))
}
// Check 3: Go installation
@@ -245,44 +242,6 @@ func checkGolangImage(ctx context.Context) DoctorResult {
return pass(nameGolangImage, fmt.Sprintf("Golang image %s is now available", imageName))
}
// checkK3sImage verifies the ghcr k3s image used by TestK8sOperator is available
// locally or can be pulled. The image is pinned (see [k3sic.K3sImage]).
func checkK3sImage(ctx context.Context) DoctorResult {
cli, err := createDockerClient(ctx)
if err != nil {
return fail(nameK3sImage, "Cannot create Docker client for image check")
}
defer cli.Close()
imageName := k3sic.K3sImage
available, err := checkImageAvailableLocally(ctx, cli, imageName)
if err != nil {
return fail(
nameK3sImage,
fmt.Sprintf("Cannot check k3s image %s: %v", imageName, err),
"Check Docker daemon status",
"Try: docker images | grep k3s",
)
}
if available {
return pass(nameK3sImage, fmt.Sprintf("K3s image %s is available locally", imageName))
}
err = ensureImageAvailable(ctx, cli, imageName, false)
if err != nil {
return warn(
nameK3sImage,
fmt.Sprintf("K3s image %s not available locally and could not pull: %v", imageName, err),
"Only TestK8sOperator needs this image; other tests are unaffected",
"Try: docker pull "+imageName,
)
}
return pass(nameK3sImage, fmt.Sprintf("K3s image %s is now available", imageName))
}
// checkGoInstallation verifies Go is installed and working.
func checkGoInstallation(ctx context.Context) DoctorResult {
_, err := exec.LookPath("go")
+2 -2
View File
@@ -71,7 +71,7 @@ func detectGoVersion() string {
if err != nil {
content, err = os.ReadFile(filepath.Join("..", "..", "go.mod"))
if err != nil {
return "1.27.0"
return "1.26.1"
}
}
@@ -83,5 +83,5 @@ func detectGoVersion() string {
}
}
return "1.27.0"
return "1.26.1"
}
+1 -2
View File
@@ -6,7 +6,6 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"log"
"slices"
"strings"
@@ -259,7 +258,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
if !errors.Is(err, io.EOF) && verbose {
if err.Error() != "EOF" && verbose {
log.Printf("Failed to decode stats for container %s: %v", containerID[:12], err)
}
+9 -9
View File
@@ -230,12 +230,12 @@ database:
# ssl: false
### TLS configuration
# See: https://headscale.net/stable/ref/tls/
## Let's Encrypt / ACME
# Headscale supports automatically requesting and setting up
#
## Let's encrypt / ACME
#
# headscale supports automatically requesting and setting up
# TLS for a domain with Let's Encrypt.
#
# URL to ACME directory
acme_url: https://acme-v02.api.letsencrypt.org/directory
@@ -245,13 +245,15 @@ acme_email: ""
# Domain name to request a TLS certificate for:
tls_letsencrypt_hostname: ""
# Path to store certificates and metadata needed by letsencrypt
# Path to store certificates and metadata needed by
# letsencrypt
# For production:
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/
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:
# :http = port 80
@@ -277,7 +279,6 @@ policy:
# The mode can be "file" or "database" that defines
# where the policies are stored and read from.
mode: file
# If the mode is set to "file", the path to a HuJSON file containing policies.
path: ""
@@ -459,7 +460,6 @@ taildrop:
# choice.
auto_update:
enabled: false
# Advanced performance tuning parameters.
# The defaults are carefully chosen and should rarely need adjustment.
# Only modify these if you have identified a specific performance issue.
+13 -29
View File
@@ -7,32 +7,16 @@
This page collects third-party tools, client libraries, and scripts related to headscale.
- [headscale-operator](https://github.com/infradohq/headscale-operator)
- ![GitHub stars](https://img.shields.io/github/stars/infradohq/headscale-operator?style=flat)
![GitHub last commit](https://img.shields.io/github/last-commit/infradohq/headscale-operator)
- Headscale Kubernetes Operator
- [tailscale-manager](https://github.com/singlestore-labs/tailscale-manager)
- ![GitHub stars](https://img.shields.io/github/stars/singlestore-labs/tailscale-manager?style=flat)
![GitHub last commit](https://img.shields.io/github/last-commit/singlestore-labs/tailscale-manager)
- Dynamically manage Tailscale route advertisements
- [headscalebacktosqlite](https://github.com/bigbozza/headscalebacktosqlite)
- ![GitHub stars](https://img.shields.io/github/stars/bigbozza/headscalebacktosqlite?style=flat)
![GitHub last commit](https://img.shields.io/github/last-commit/bigbozza/headscalebacktosqlite)
- Migrate headscale from PostgreSQL back to SQLite
- [headscale-pf](https://github.com/YouSysAdmin/headscale-pf)
- ![GitHub stars](https://img.shields.io/github/stars/YouSysAdmin/headscale-pf?style=flat)
![GitHub last commit](https://img.shields.io/github/last-commit/YouSysAdmin/headscale-pf)
- Populates user groups based on user groups in Jumpcloud or Authentik
- [headscale-client-go](https://github.com/hibare/headscale-client-go)
- ![GitHub stars](https://img.shields.io/github/stars/hibare/headscale-client-go?style=flat)
![GitHub last commit](https://img.shields.io/github/last-commit/hibare/headscale-client-go)
- A Go client implementation for the Headscale HTTP API.
- [headscale-zabbix](https://github.com/dblanque/headscale-zabbix)
- ![GitHub stars](https://img.shields.io/github/stars/dblanque/headscale-zabbix?style=flat)
![GitHub last commit](https://img.shields.io/github/last-commit/dblanque/headscale-zabbix)
- A Zabbix Monitoring Template for the Headscale Service.
- [tailscale-exporter](https://github.com/adinhodovic/tailscale-exporter)
- ![GitHub stars](https://img.shields.io/github/stars/adinhodovic/tailscale-exporter?style=flat)
![GitHub last commit](https://img.shields.io/github/last-commit/adinhodovic/tailscale-exporter)
- A Prometheus exporter for Headscale that provides network-level metrics using
the Headscale API.
- [headscale-operator](https://github.com/infradohq/headscale-operator) - Headscale Kubernetes Operator
- [tailscale-manager](https://github.com/singlestore-labs/tailscale-manager) - Dynamically manage Tailscale route
advertisements
- [headscalebacktosqlite](https://github.com/bigbozza/headscalebacktosqlite) - Migrate headscale from PostgreSQL back to
SQLite
- [headscale-pf](https://github.com/YouSysAdmin/headscale-pf) - Populates user groups based on user groups in Jumpcloud
or Authentik
- [headscale-client-go](https://github.com/hibare/headscale-client-go) - A Go client implementation for the Headscale
HTTP API.
- [headscale-zabbix](https://github.com/dblanque/headscale-zabbix) - A Zabbix Monitoring Template for the Headscale
Service.
- [tailscale-exporter](https://github.com/adinhodovic/tailscale-exporter) - A Prometheus exporter for Headscale that
provides network-level metrics using the Headscale API.
+18 -52
View File
@@ -7,57 +7,23 @@
Headscale doesn't provide a built-in web interface but users may pick one from the available options.
- [headscale-ui](https://github.com/gurucomputing/headscale-ui)
- ![GitHub stars](https://img.shields.io/github/stars/gurucomputing/headscale-ui?style=flat)
![GitHub last commit](https://img.shields.io/github/last-commit/gurucomputing/headscale-ui)
- A web frontend for the headscale Tailscale-compatible coordination server
- [HeadscaleUi](https://github.com/simcu/headscale-ui)
- ![GitHub stars](https://img.shields.io/github/stars/simcu/headscale-ui?style=flat)
![GitHub last commit](https://img.shields.io/github/last-commit/simcu/headscale-ui)
- A static headscale admin ui, no backend environment required
- [Headplane](https://github.com/tale/headplane)
- ![GitHub stars](https://img.shields.io/github/stars/tale/headplane?style=flat)
![GitHub last commit](https://img.shields.io/github/last-commit/tale/headplane)
- An advanced Tailscale inspired frontend for headscale
- [headscale-admin](https://github.com/GoodiesHQ/headscale-admin)
- ![GitHub stars](https://img.shields.io/github/stars/GoodiesHQ/headscale-admin?style=flat)
![GitHub last commit](https://img.shields.io/github/last-commit/GoodiesHQ/headscale-admin)
- Headscale-Admin is meant to be a simple, modern web interface for headscale
- [ouroboros](https://github.com/yellowsink/ouroboros)
- ![GitHub stars](https://img.shields.io/github/stars/yellowsink/ouroboros?style=flat)
![GitHub last commit](https://img.shields.io/github/last-commit/yellowsink/ouroboros)
- Ouroboros is designed for users to manage their own devices, rather than for
admins
- [unraid-headscale-admin](https://github.com/ich777/unraid-headscale-admin)
- ![GitHub stars](https://img.shields.io/github/stars/ich777/unraid-headscale-admin?style=flat)
![GitHub last commit](https://img.shields.io/github/last-commit/ich777/unraid-headscale-admin)
- A simple headscale admin UI for Unraid, it offers Local (`docker exec`) and
API Mode
- [headscale-console](https://github.com/rickli-cloud/headscale-console)
- ![GitHub stars](https://img.shields.io/github/stars/rickli-cloud/headscale-console?style=flat)
![GitHub last commit](https://img.shields.io/github/last-commit/rickli-cloud/headscale-console)
- WebAssembly-based client supporting SSH, VNC and RDP with optional
self-service capabilities
- [headscale-piying](https://github.com/wszgrcy/headscale-piying)
- ![GitHub stars](https://img.shields.io/github/stars/wszgrcy/headscale-piying?style=flat)
![GitHub last commit](https://img.shields.io/github/last-commit/wszgrcy/headscale-piying)
- headscale web ui, support visual ACL configuration
- [HeadControl](https://github.com/ahmadzip/HeadControl)
- ![GitHub stars](https://img.shields.io/github/stars/ahmadzip/HeadControl?style=flat)
![GitHub last commit](https://img.shields.io/github/last-commit/ahmadzip/HeadControl)
- Minimal Headscale admin dashboard, built with Go and HTMX
- [Headscale Manager](https://github.com/hkdone/headscalemanager)
- ![GitHub stars](https://img.shields.io/github/stars/hkdone/headscalemanager?style=flat)
![GitHub last commit](https://img.shields.io/github/last-commit/hkdone/headscalemanager)
- Headscale UI for Android
- [Headscale UI](https://github.com/MunMunMiao/headscale-ui)
- ![GitHub stars](https://img.shields.io/github/stars/MunMunMiao/headscale-ui?style=flat)
![GitHub last commit](https://img.shields.io/github/last-commit/MunMunMiao/headscale-ui)
- Headscale UI online and Self-hosting
- [Headscale Panel](https://github.com/headscale-panel/panel)
- ![GitHub stars](https://img.shields.io/github/stars/headscale-panel/panel?style=flat)
![GitHub last commit](https://img.shields.io/github/last-commit/headscale-panel/panel)
- A modern Headscale management panel with a clean, network-operations-focused
UI
- [headscale-ui](https://github.com/gurucomputing/headscale-ui) - A web frontend for the headscale Tailscale-compatible
coordination server
- [HeadscaleUi](https://github.com/simcu/headscale-ui) - A static headscale admin ui, no backend environment required
- [Headplane](https://github.com/tale/headplane) - An advanced Tailscale inspired frontend for headscale
- [headscale-admin](https://github.com/GoodiesHQ/headscale-admin) - Headscale-Admin is meant to be a simple, modern web
interface for headscale
- [ouroboros](https://github.com/yellowsink/ouroboros) - Ouroboros is designed for users to manage their own devices,
rather than for admins
- [unraid-headscale-admin](https://github.com/ich777/unraid-headscale-admin) - A simple headscale admin UI for Unraid,
it offers Local (`docker exec`) and API Mode
- [headscale-console](https://github.com/rickli-cloud/headscale-console) - WebAssembly-based client supporting SSH, VNC
and RDP with optional self-service capabilities
- [headscale-piying](https://github.com/wszgrcy/headscale-piying) - headscale web ui, support visual ACL configuration
- [HeadControl](https://github.com/ahmadzip/HeadControl) - Minimal Headscale admin dashboard, built with Go and HTMX
- [Headscale Manager](https://github.com/hkdone/headscalemanager) - Headscale UI for Android
- [Headscale UI](https://github.com/MunMunMiao/headscale-ui) - Headscale UI online and Self-hosting
- [Headscale Panel](https://github.com/headscale-panel/panel) - A modern Headscale management panel with a clean,
network-operations-focused UI
You can ask for support on our [Discord server](https://discord.gg/c84AZQhmpx) in the "web-interfaces" channel.
+1 -1
View File
@@ -43,7 +43,7 @@ cp headscale /usr/local/sbin
```shell
# Install prerequisites
# 1. go v1.27+: see the go directive in go.mod for the exact minimum
# 1. go v1.20+: headscale newer than 0.21 needs go 1.20+ to compile
# 2. gmake: Makefile in the headscale repo is written in GNU make syntax
git clone https://github.com/juanfont/headscale.git
+4 -4
View File
@@ -53,14 +53,14 @@ Install the official Tailscale tvOS client from the [App Store](https://apps.app
!!! danger
**Don't** sign in to the Tailscale App before the headscale URL is configured!
**Don't** open the Tailscale App after installation!
### Configuring the headscale URL
- Open Tailscale
- Click the button `Install VPN configuration` and confirm the appearing popup by clicking the `Allow` button, but don't sign in yet
- Open Settings (the Apple tvOS settings) > Apps > Tailscale
- Under `ALTERNATE COORDINATION SERVER URL`, select `URL`
- Enter the URL of your headscale instance (e.g `https://headscale.example.com`) and press `OK`
- Return to the tvOS Home screen and open Tailscale
- Return to the tvOS Home screen
- Open Tailscale
- Click the button `Install VPN configuration` and confirm the appearing popup by clicking the `Allow` button
- Scan the QR code and follow the login procedure
Generated
+7 -7
View File
@@ -9,11 +9,11 @@
"treefmt-nix": "treefmt-nix"
},
"locked": {
"lastModified": 1783015821,
"narHash": "sha256-vmzYTZxIAy3OYXwkRL5GQYk8fNnh8TLWcxQRIO3ywOU=",
"lastModified": 1781951072,
"narHash": "sha256-hA9u6hB4QzpReP8hudxqiaa4vLhoRvtXi6YmUgJRGEs=",
"owner": "kradalby",
"repo": "flake-checks",
"rev": "3c821706eb0bd07f515f9a7726b650ccae927433",
"rev": "3d2882efec5cf10f8b5a8a035d93ba6ba9f21717",
"type": "github"
},
"original": {
@@ -60,16 +60,16 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1787394516,
"narHash": "sha256-pRGOQSClnXNI2iLUG6DYpsGvYcuw0drOutVZFTJNw90=",
"lastModified": 1781153106,
"narHash": "sha256-yzsroLCcuRG4KdGMxWt0eXKOrRSgQT8/xjYngeq9ujU=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "c8f90650c15282fa8656a041bfbbd2403997a9a7",
"rev": "9ee75f111a06d7ab2b2f729698a8eff53d54e070",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"ref": "staging-next-26.05",
"repo": "nixpkgs",
"type": "github"
}
+61 -29
View File
@@ -2,7 +2,12 @@
description = "headscale - Open Source Tailscale Control server";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
# Pinned to staging-next-26.05 for Go 1.26.4 (security fix GO-2026-5037/5039):
# nixpkgs-unstable still ships 1.26.3 — the bump is merged to nixpkgs staging
# but the large-rebuild staging->unstable pipeline lags. The 26.05 line is
# otherwise current (dev tools match unstable). Switch back to nixpkgs-unstable
# once it ships go_1_26 >= 1.26.4.
nixpkgs.url = "github:NixOS/nixpkgs/staging-next-26.05";
flake-utils.url = "github:numtide/flake-utils";
# Reusable Go flake checks (build/test/lint/format); CI runs them via
# `nix build .#checks.<system>.<name>` instead of bespoke per-tool steps.
@@ -31,9 +36,8 @@
overlays.default = _: prev:
let
pkgs = nixpkgs.legacyPackages.${prev.stdenv.hostPlatform.system};
# Tracks the newest Go in nixpkgs (currently 1.27) so a Go release
# bump is a flake.lock update, not a flake.nix edit.
buildGo = pkgs.buildGoLatestModule;
# Go 1.26 builder; resolves to Go 1.26.4 from the pinned nixpkgs.
buildGo = pkgs.buildGo126Module;
vendorHash = (builtins.fromJSON (builtins.readFile ./flakehashes.json)).vendor.sri;
in
{
@@ -67,6 +71,48 @@
subPackages = [ "cmd/hi" ];
};
# Build golangci-lint with stock Go 1.26 (upstream uses hardcoded Go
# version); it does not build against the pinned 1.26.4.
golangci-lint = buildGo rec {
pname = "golangci-lint";
version = "2.12.2";
src = pkgs.fetchFromGitHub {
owner = "golangci";
repo = "golangci-lint";
rev = "v${version}";
hash = "sha256-qR7fp1x2S+EwEAcplRHTvA3jWwLr/XSiYKSZtAwkrNU=";
};
vendorHash = "sha256-AG5wtLwWLz55bdp1oi3cW+9O3yj1W1P7MV9zxym7Pb4=";
subPackages = [ "cmd/golangci-lint" ];
nativeBuildInputs = [ pkgs.installShellFiles ];
ldflags = [
"-s"
"-w"
"-X main.version=${version}"
"-X main.commit=v${version}"
"-X main.date=1970-01-01T00:00:00Z"
];
postInstall = ''
for shell in bash zsh fish; do
HOME=$TMPDIR $out/bin/golangci-lint completion $shell > golangci-lint.$shell
installShellCompletion golangci-lint.$shell
done
'';
meta = {
description = "Fast linters runner for Go";
homepage = "https://golangci-lint.run/";
changelog = "https://github.com/golangci/golangci-lint/blob/v${version}/CHANGELOG.md";
mainProgram = "golangci-lint";
};
};
gotestsum = prev.gotestsum.override {
buildGoModule = buildGo;
};
@@ -79,20 +125,8 @@
buildGoModule = buildGo;
};
golines = prev.golines.override {
buildGoModule = buildGo;
};
# goimports and friends: they parse Go with the parser of the Go
# they were built with, so an older one rejects new syntax.
gotools = prev.gotools.override {
buildGoModule = buildGo;
# goimports is wrapped with this go on PATH for module lookups.
go = pkgs.go_latest;
};
golangci-lint-langserver = prev.golangci-lint-langserver.override {
buildGoModule = buildGo;
gopls = prev.gopls.override {
buildGoLatestModule = buildGo;
};
};
}
@@ -103,7 +137,7 @@
overlays = [ self.overlays.default ];
inherit system;
};
buildDeps = with pkgs; [ git go_latest gnumake ];
buildDeps = with pkgs; [ git go_1_26 gnumake ];
devDeps = with pkgs;
buildDeps
++ [
@@ -118,8 +152,6 @@
gotests
gofumpt
gopls
gotools
ksh
ko
yq-go
@@ -130,17 +162,17 @@
# roundtrip tests (TestAPIv2). Binaries: tofu, tscli.
opentofu
tscli
python3Packages.mdformat
python3Packages.mdformat-footnote
python3Packages.mdformat-frontmatter
python3Packages.mdformat-mkdocs
python314Packages.mdformat
python314Packages.mdformat-footnote
python314Packages.mdformat-frontmatter
python314Packages.mdformat-mkdocs
prek
# 'dot' is needed for pprof graphs
# go tool pprof -http=: <source>
graphviz
]
++ lib.optionals pkgs.stdenv.hostPlatform.isLinux [ traceroute ];
++ lib.optionals pkgs.stdenv.isLinux [ traceroute ];
# Add entry to build a docker image with headscale
# caveat: only works on Linux
@@ -166,7 +198,7 @@
pname = "headscale";
version = headscaleVersion;
vendorHash = (builtins.fromJSON (builtins.readFile ./flakehashes.json)).vendor.sri;
goPkg = pkgs.go_latest;
goPkg = pkgs.go_1_26;
# //go:embed targets and test-read files outside the default whitelist.
embedDirs = [ ./hscontrol/assets ./hscontrol/db/schema.sql ./config-example.yaml ];
extraSrc = [
@@ -224,7 +256,7 @@
(pkgs.writeShellScriptBin
"go-mod-update-all"
''
cat go.mod | ${pkgs.ripgrep}/bin/rg "\t" | ${pkgs.ripgrep}/bin/rg -v '^\s*//' | ${pkgs.ripgrep}/bin/rg -v indirect | ${pkgs.gawk}/bin/awk '{print $1}' | ${pkgs.findutils}/bin/xargs go get -u
cat go.mod | ${pkgs.ripgrep}/bin/rg "\t" | ${pkgs.ripgrep}/bin/rg -v indirect | ${pkgs.gawk}/bin/awk '{print $1}' | ${pkgs.findutils}/bin/xargs go get -u
go mod tidy
'')
];
@@ -255,6 +287,6 @@
}
# The Go build/test checks are gated to Linux: parts of the tree are
# Linux-specific and the pure unit subset is validated by CI.
// pkgs.lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux goChecks;
// pkgs.lib.optionalAttrs pkgs.stdenv.isLinux goChecks;
});
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"vendor": {
"goModSum": "sha256-KJAFccbKRC32xxF7gMKO7C4tqokz1sQAbKQpw3jiqe4=",
"sri": "sha256-sJtPLelpL4TYjo9mgXkeLlKumgNrR6YjV+BuAlbmkn4="
"goModSum": "sha256-SJml8RXGmb2p0g1nOsHn86FA1hwgd5ZffLSkUj5zek8=",
"sri": "sha256-pjGNuVtgFFzWNq/2cK7a4iyF13AfcHz098nk92a9Ido="
}
}
+63 -65
View File
@@ -1,6 +1,6 @@
module github.com/juanfont/headscale
go 1.27.0
go 1.26.4
require (
github.com/arl/statsviz v0.8.0
@@ -18,7 +18,8 @@ require (
github.com/go-chi/chi/v5 v5.3.0
github.com/go-chi/metrics v0.1.1
github.com/go-gormigrate/gormigrate/v2 v2.1.6
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68
github.com/go-json-experiment/json v0.0.0-20260601182631-00ed12fed2a6
github.com/gofrs/uuid/v5 v5.4.0
github.com/google/go-cmp v0.7.0
github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/jagottsicher/termcolor v1.0.2
@@ -27,7 +28,7 @@ require (
github.com/ory/dockertest/v3 v3.12.0
github.com/pkg/profile v1.7.0
github.com/prometheus/client_golang v1.23.2
github.com/prometheus/common v0.69.0
github.com/prometheus/common v0.68.1
github.com/pterm/pterm v0.12.83
github.com/puzpuzpuz/xsync/v4 v4.5.0
github.com/realclientip/realclientip-go v1.0.0
@@ -42,39 +43,52 @@ require (
github.com/tailscale/tailsql v0.0.0-20260522170732-77aec5aabc76
github.com/tcnksm/go-latest v0.0.0-20170313132115-e3007ae9052e
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba
golang.org/x/crypto v0.54.0
golang.org/x/crypto v0.53.0
golang.org/x/exp v0.0.0-20260603202125-055de637280b
golang.org/x/net v0.57.1-0.20260729233039-99c3b0a8f463
golang.org/x/net v0.56.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sync v0.22.0
golang.org/x/sync v0.21.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
// NOTE: modernc sqlite has a fragile dependency
// chain and it is important that they are updated
// in lockstep to ensure that they do not break
// some architectures and similar at runtime:
// https://github.com/juanfont/headscale/issues/2188
//
// Fragile libc dependency:
// https://pkg.go.dev/modernc.org/sqlite#hdr-Fragile_modernc_org_libc_dependency
// https://gitlab.com/cznic/sqlite/-/issues/177
//
// To upgrade, determine the new SQLite version to
// be used, and consult the `go.mod` file:
// https://gitlab.com/cznic/sqlite/-/blob/master/go.mod
// to find
// the appropriate `libc` version, then upgrade them
// together, e.g:
// go get modernc.org/libc@v1.55.3 modernc.org/sqlite@v1.33.1
modernc.org/sqlite v1.52.0
pgregory.net/rapid v1.3.0
tailscale.com v1.103.0-pre.0.20260819232550-0e84b4a3a0a0
tailscale.com v1.101.0-pre
tailscale.com/client/tailscale/v2 v2.9.0
zombiezen.com/go/postgrestest v1.0.1
)
// NOTE: modernc sqlite has a fragile dependency
// chain and it is important that they are updated
// in lockstep to ensure that they do not break
// some architectures and similar at runtime:
// https://github.com/juanfont/headscale/issues/2188
//
// Fragile libc dependency:
// https://pkg.go.dev/modernc.org/sqlite#hdr-Fragile_modernc_org_libc_dependency
// https://gitlab.com/cznic/sqlite/-/issues/177
//
// To upgrade, determine the new SQLite version to
// be used, and consult the `go.mod` file:
// https://gitlab.com/cznic/sqlite/-/blob/master/go.mod
// to find
// the appropriate `libc` version, then upgrade them
// 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/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.52.0
)
// NOTE: gvisor must be updated in lockstep with
// tailscale.com. The version used here should match
// the version required by the tailscale.com dependency.
// To find the correct version, check tailscale.com's
// go.mod file for the gvisor.dev/gvisor version:
// https://github.com/tailscale/tailscale/blob/main/go.mod
require gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8 // indirect
require (
atomicgo.dev/cursor v0.2.0 // indirect
atomicgo.dev/keyboard v0.2.10 // indirect
@@ -90,24 +104,23 @@ require (
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e // indirect
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aws/aws-sdk-go-v2 v1.42.1 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.17 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.16 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.1 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.7 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.7 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect
github.com/aws/aws-sdk-go-v2/service/ssm v1.45.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect
github.com/aws/smithy-go v1.27.3 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
github.com/aws/smithy-go v1.24.0 // indirect
github.com/axiomhq/hyperloglog v0.2.6 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bradfitz/monogok v0.0.0-20260630033929-b1eef977b41f // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
@@ -119,7 +132,6 @@ require (
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
github.com/diskfs/go-diskfs v1.9.3 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/cli v29.5.3+incompatible // indirect
github.com/docker/go-connections v0.7.0 // indirect
@@ -164,11 +176,11 @@ require (
github.com/jsimonetti/rtnetlink v1.4.2 // indirect
github.com/kamstrup/intmap v0.5.2 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/klauspost/compress v1.19.1 // indirect
github.com/klauspost/compress v1.18.6 // indirect
github.com/lib/pq v1.12.3 // indirect
github.com/lithammer/fuzzysearch v1.1.8 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.23 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mattn/go-runewidth v0.0.24 // indirect
github.com/mdlayher/netlink v1.8.0 // indirect
github.com/mdlayher/socket v0.5.1 // indirect
@@ -207,7 +219,7 @@ require (
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect
github.com/tailscale/setec v0.0.0-20260310221408-dcd97e42f251 // indirect
github.com/tailscale/web-client-prebuilt v0.0.0-20251127225136-f19339b67368 // indirect
github.com/tailscale/wireguard-go v0.0.0-20260813211458-aa425f19bcbf // indirect
github.com/tailscale/wireguard-go v0.0.0-20260527010701-b48af7099cad // 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
@@ -225,33 +237,19 @@ require (
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect
golang.org/x/exp/typeparams v0.0.0-20260603202125-055de637280b // indirect
golang.org/x/image v0.41.0 // indirect
golang.org/x/mod v0.37.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/term v0.45.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/mod v0.36.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/term v0.44.0 // indirect
golang.org/x/text v0.38.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.47.0 // indirect
golang.org/x/tools v0.45.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/api v0.0.0-20260610212136-7ab31c22f7ad // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/grpc v1.81.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
// NOTE: gvisor must be updated in lockstep with
// tailscale.com. The version used here should match
// the version required by the tailscale.com dependency.
// To find the correct version, check tailscale.com's
// go.mod file for the gvisor.dev/gvisor version:
// https://github.com/tailscale/tailscale/blob/main/go.mod
gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8 // indirect
k8s.io/client-go v0.35.3 // indirect
// NOTE: keep in lockstep with modernc.org/sqlite; see the
// upgrade note in the direct require block above.
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // 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
)
+82 -96
View File
@@ -18,8 +18,8 @@ fyne.io/systray v1.11.1-0.20250812065214-4856ac3adc3c h1:km4PIleGtbbF1oxmFQuO93C
fyne.io/systray v1.11.1-0.20250812065214-4856ac3adc3c/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/Kodeworks/golang-image-ico v0.0.0-20141118225523-73f0f4cfade9 h1:1ltqoej5GtaWF8jaiA49HwsZD459jqm9YFz9ZtMFpQA=
github.com/Kodeworks/golang-image-ico v0.0.0-20141118225523-73f0f4cfade9/go.mod h1:7uhhqiBaR4CpN0k9rMjOtjpcfGd6DG2m04zQxKnWQ0I=
github.com/MarvinJWendt/testza v0.5.2 h1:53KDo64C1z/h/d/stCYCPY69bt/OSwjq5KpFNwi+zB4=
@@ -33,8 +33,6 @@ github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A=
github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/anchore/go-lzo v0.1.0 h1:NgAacnzqPeGH49Ky19QKLBZEuFRqtTG9cdaucc3Vncs=
github.com/anchore/go-lzo v0.1.0/go.mod h1:3kLx0bve2oN1iDwgM1U5zGku1Tfbdb0No5qp1eL1fIk=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
@@ -43,51 +41,51 @@ github.com/arl/statsviz v0.8.0 h1:O6GjjVxEDxcByAucOSl29HaGYLXsuwA3ujJw8H9E7/U=
github.com/arl/statsviz v0.8.0/go.mod h1:XlrbiT7xYT03xaW9JMMfD8KFUhBOESJwfyNJu83PbB0=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aws/aws-sdk-go-v2 v1.42.1 h1:9eOTgu1z/dVtYpNZ3/8/XbbaX0x/BqE3HUzAzs6K0ek=
github.com/aws/aws-sdk-go-v2 v1.42.1/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU=
github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE=
github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU=
github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 h1:xM/Is9cKMHa8Jj8zkvWhvrFkZsXJV9E+BB4g0HW0duQ=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30/go.mod h1:WueJeNDZvK1fMYEWJIkcivBfEzUkTpBhzlrUKKY8EuA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 h1:jn46zC9LdsVR/ZpMIJqMqb8hHv31BlLx3ulVqNspUOk=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30/go.mod h1:1hTMsAgbdS/AtUi4bw8+gUuh1pceo+eXRLfpSuSQj3M=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 h1:mbRIur/BiHK6SKPjoBIXSE/hJ6g6JGRLuxQy1jGjlN4=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13/go.mod h1:ITg9em2KbJx1s0y4aqRX5OYWG6HBZ5TVR//OdpEZ2CQ=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 h1:/Z5jmNrKsSD7EmDjzAPsm/3L9IuOkzaynklJZ1qX7S4=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30/go.mod h1:lEzEZnOosE7zi8Z6royW1cFJTD9fpab4Ul1SBrllewk=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ=
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3qwvLWsNkF4RPToo=
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI=
github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU=
github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4=
github.com/aws/aws-sdk-go-v2/config v1.32.7 h1:vxUyWGUwmkQ2g19n7JY/9YL8MfAIl7bTesIUykECXmY=
github.com/aws/aws-sdk-go-v2/config v1.32.7/go.mod h1:2/Qm5vKUU/r7Y+zUk/Ptt2MDAEKAfUtKc1+3U1Mo3oY=
github.com/aws/aws-sdk-go-v2/credentials v1.19.7 h1:tHK47VqqtJxOymRrNtUXN5SP/zUTvZKeLx4tH6PGQc8=
github.com/aws/aws-sdk-go-v2/credentials v1.19.7/go.mod h1:qOZk8sPDrxhf+4Wf4oT2urYJrYt3RejHSzgAquYeppw=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 h1:CjMzUs78RDDv4ROu3JnJn/Ig1r6ZD7/T2DXLLRpejic=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16/go.mod h1:uVW4OLBqbJXSHJYA9svT9BluSvvwbzLQ2Crf6UPzR3c=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 h1:DIBqIrJ7hv+e4CmIk2z3pyKT+3B6qVMgRsawHiR3qso=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7/go.mod h1:vLm00xmBke75UmpNvOcZQ/Q30ZFjbczeLFqGx5urmGo=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 h1:NSbvS17MlI2lurYgXnCOLvCFX38sBW4eiVER7+kkgsU=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16/go.mod h1:SwT8Tmqd4sA6G1qaGdzWCJN99bUmPGHfRwwq3G5Qb+A=
github.com/aws/aws-sdk-go-v2/service/s3 v1.93.2 h1:U3ygWUhCpiSPYSHOrRhb3gOl9T5Y3kB8k5Vjs//57bE=
github.com/aws/aws-sdk-go-v2/service/s3 v1.93.2/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M=
github.com/aws/aws-sdk-go-v2/service/ssm v1.45.0 h1:IOdss+igJDFdic9w3WKwxGCmHqUxydvIhJOm9LJ32Dk=
github.com/aws/aws-sdk-go-v2/service/ssm v1.45.0/go.mod h1:Q7XIWsMo0JcMpI/6TGD6XXcXcV1DbTj6e9BKNntIMIM=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg=
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk=
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio=
github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY=
github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 h1:v6EiMvhEYBoHABfbGB4alOYmCIrcgyPPiBE1wZAEbqk=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 h1:gd84Omyu9JLriJVCbGApcLzVR3XtmC4ZDPcAI6Ftvds=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ=
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/axiomhq/hyperloglog v0.2.6 h1:sRhvvF3RIXWQgAXaTphLp4yJiX4S0IN3MWTaAgZoRJw=
github.com/axiomhq/hyperloglog v0.2.6/go.mod h1:YjX/dQqCR/7QYX0g8mu8UZAjpIenz1FKM71UEsjFoTo=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
github.com/bradfitz/monogok v0.0.0-20260630033929-b1eef977b41f h1:voy0korWbg2e1gsJpBZ8/OBhVL8evXeUwbCZcA1PWv8=
github.com/bradfitz/monogok v0.0.0-20260630033929-b1eef977b41f/go.mod h1:TG1HbU9fRVDnNgXncVkKz9GdvjIvqquXjH6QZSEVmY4=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
@@ -105,8 +103,8 @@ github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5P
github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
github.com/cilium/ebpf v0.22.0 h1:v2ktp0roffpMOj2MMf3idtCQZOsAoC4BJbAJN+ke2bY=
github.com/cilium/ebpf v0.22.0/go.mod h1:CDzZbe2hC5JjlDC+CY3KFCzlYwN4gbxppYM+Z10bQt4=
github.com/cilium/ebpf v0.17.3 h1:FnP4r16PWYSE4ux6zN+//jMcW4nMVRvuTLVTvCjyyjg=
github.com/cilium/ebpf v0.17.3/go.mod h1:G5EDHij8yiLzaqn0WjyfJHvRa+3aDlReIaLVRMvOyJk=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
@@ -150,8 +148,6 @@ github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33 h1:ucRHb6/lvW/+mT
github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw=
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yezR0y7jJ5pceLHthLaYf4bA5T14B6q39S4q2Q=
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A=
github.com/diskfs/go-diskfs v1.9.3 h1:cLciNCeZ4QAXVxyPJDr1ZJ9N9CCG3rQlQ/z/Cs/cNDM=
github.com/diskfs/go-diskfs v1.9.3/go.mod h1:TePJORO83Adh5pb2SqsxAwaP0fofFxKLkxctiS/9OQc=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
@@ -166,8 +162,6 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57 h1:x5yxNrq8XffV/OoNUeFPM6hxHVi5OTspSTBxr/9pemg=
github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57/go.mod h1:GLo/8fDswSAniFG+BFIaiSPcK610jyzgEhWYPQwuQdw=
github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw=
github.com/felixge/fgprof v0.9.5 h1:8+vR6yu2vvSKn08urWyEuxx75NWPEvybbkBirEpsbVY=
github.com/felixge/fgprof v0.9.5/go.mod h1:yKl+ERSa++RYOs32d8K6WEXCB4uXdLls4ZaZPpayhMM=
@@ -199,8 +193,8 @@ github.com/go-jose/go-jose/v3 v3.0.5 h1:BLLJWbC4nMZOfuPVxoZIxeYsn6Nl2r1fITaJ78UQ
github.com/go-jose/go-jose/v3 v3.0.5/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-20260623181947-01eb4420fa68 h1:KZaTBSyshWX3MP5jukJcNSuXDQTO+rNpt0J564dX/eg=
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/go-json-experiment/json v0.0.0-20260601182631-00ed12fed2a6 h1:nxP4pPoyqOAgX8lYDFCfl3DyKeXErCvSvhcyzwGV9CE=
github.com/go-json-experiment/json v0.0.0-20260601182631-00ed12fed2a6/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=
@@ -210,8 +204,6 @@ github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go4org/hashtriemap v0.0.0-20251130024219-545ba229f689 h1:0psnKZ+N2IP43/SZC8SKx6OpFJwLmQb9m9QyV9BC2f8=
@@ -223,6 +215,8 @@ github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6Wezm
github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/gofrs/uuid/v5 v5.4.0 h1:EfbpCTjqMuGyq5ZJwxqzn3Cbr2d0rUZU7v5ycAk/e/0=
github.com/gofrs/uuid/v5 v5.4.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
@@ -303,8 +297,8 @@ github.com/kamstrup/intmap v0.5.2 h1:qnwBm1mh4XAnW9W9Ue9tZtTff8pS6+s6iKF6JRIV2Dk
github.com/kamstrup/intmap v0.5.2/go.mod h1:gWUVWHKzWj8xpJVFf5GC0O26bWmv3GqdnIX/LMT6Aq4=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ=
@@ -326,8 +320,8 @@ github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ=
github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw=
@@ -386,8 +380,8 @@ github.com/peterbourgon/ff/v3 v3.4.0/go.mod h1:zjJVUhx+twciwfDl0zBcFzl4dW8axCRyX
github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM=
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pires/go-proxyproto v0.9.2 h1:H1UdHn695zUVVmB0lQ354lOWHOy6TZSpzBl3tgN0s1U=
github.com/pires/go-proxyproto v0.9.2/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@@ -396,8 +390,6 @@ github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA=
github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo=
github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo=
github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk=
github.com/pkg/xattr v0.4.12 h1:rRTkSyFNTRElv6pkA3zpjHpQ90p/OdHQC1GmGh1aTjM=
github.com/pkg/xattr v0.4.12/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU=
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=
@@ -405,8 +397,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h
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=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.69.0 h1:OA85nJQS/T/MaYh/Q2CcgDKSGWqNIgrBDvDH85CuiNk=
github.com/prometheus/common v0.69.0/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y=
github.com/prometheus/common v0.68.1 h1:omjRRl4QP4komogpXuhfeOiisQg7xdy8VM1UY+pStaY=
github.com/prometheus/common v0.68.1/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/pterm/pterm v0.12.83 h1:ie+YmGmA727VuhxBlyGr74Ks+7McV6kT99IB8EU80aA=
@@ -456,18 +448,16 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/studio-b12/gowebdav v0.13.0 h1:OcwSg6IQHOFNdYHn3bPOHwSE8looG8N56Y5xTT1asqQ=
github.com/studio-b12/gowebdav v0.13.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d h1:JcGKBZAL7ePLwOhUdN8qGQZlP5GueEiIZwY7R62pejE=
github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4=
github.com/tailscale/gliderssh v0.3.4-0.20260716005906-1a0f895faf28 h1:Azz5ILxxVsHN/KjIu3wkJPAmmtiijucZw4Ax5Ye8n+s=
github.com/tailscale/gliderssh v0.3.4-0.20260716005906-1a0f895faf28/go.mod h1:wn16Km1EZOX4UEAyaZa3dBwfFGOJ7neck40NcwosJUw=
github.com/tailscale/gliderssh v0.3.4-0.20260330083525-c1389c70ff89 h1:glgVc1ZYMjwN1Q/ITWeuSQyl029uayagaR2sjsifehc=
github.com/tailscale/gliderssh v0.3.4-0.20260330083525-c1389c70ff89/go.mod h1:wn16Km1EZOX4UEAyaZa3dBwfFGOJ7neck40NcwosJUw=
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4=
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg=
github.com/tailscale/golang-x-crypto v0.0.0-20260720153645-2ba0bf7866ed h1:uyvHhX1FQada0vVk8CSHa4tJT96EEAkTypaYz8Tq5Nc=
github.com/tailscale/golang-x-crypto v0.0.0-20260720153645-2ba0bf7866ed/go.mod h1:NC3xRCu4UR+m4n6ix8b6oLLbHa820Y0StbOQEdWTDo0=
github.com/tailscale/golang-x-crypto v0.0.0-20250404221719-a5573b049869 h1:SRL6irQkKGQKKLzvQP/ke/2ZuB7Py5+XuqtOgSj+iMM=
github.com/tailscale/golang-x-crypto v0.0.0-20250404221719-a5573b049869/go.mod h1:ikbF+YT089eInTp9f2vmvy4+ZVnW5hzX1q2WknxSprQ=
github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd h1:Rf9uhF1+VJ7ZHqxrG8pJ6YacmHvVCmByDmGbAWCc/gA=
github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd/go.mod h1:EbW0wDK/qEUYI0A5bqq0C2kF8JTQwWONmGDBbzsxxHo=
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU=
@@ -484,8 +474,8 @@ github.com/tailscale/web-client-prebuilt v0.0.0-20251127225136-f19339b67368 h1:0
github.com/tailscale/web-client-prebuilt v0.0.0-20251127225136-f19339b67368/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ=
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M=
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y=
github.com/tailscale/wireguard-go v0.0.0-20260813211458-aa425f19bcbf h1:TQTeMPbY+rI4jMtp2p1kPTzBlv8yloP4fy6nb5l+D38=
github.com/tailscale/wireguard-go v0.0.0-20260813211458-aa425f19bcbf/go.mod h1:6SerzcvHWQchKO2BfNdmquA77CHSECZuFl+D9fp4RnI=
github.com/tailscale/wireguard-go v0.0.0-20260527010701-b48af7099cad h1:Ky26FR5yZ5IKEB0xtm5A8xSTb06ImY7kxBFrvgOmJSg=
github.com/tailscale/wireguard-go v0.0.0-20260527010701-b48af7099cad/go.mod h1:6SerzcvHWQchKO2BfNdmquA77CHSECZuFl+D9fp4RnI=
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=
@@ -500,8 +490,6 @@ github.com/u-root/u-root v0.14.0 h1:Ka4T10EEML7dQ5XDvO9c3MBN8z4nuSnGjcd1jmU2ivg=
github.com/u-root/u-root v0.14.0/go.mod h1:hAyZorapJe4qzbLWlAkmSVCJGbfoU9Pu4jpJ1WMluqE=
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM=
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA=
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
@@ -549,8 +537,8 @@ go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/W
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/exp v0.0.0-20260603202125-055de637280b h1:v1uXiEBHo8QA0LiGCo7UgHMzHT4Kdfpl2zmtH5vaP1Q=
golang.org/x/exp v0.0.0-20260603202125-055de637280b/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
golang.org/x/exp/typeparams v0.0.0-20260603202125-055de637280b h1:E7MAoHE/7prIY6tu29UATfH3hVHv6IWqOchjE48pTAU=
@@ -559,23 +547,23 @@ golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
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.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
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.57.1-0.20260729233039-99c3b0a8f463 h1:56+vNxUDjzdwAJjglL/DMnp31nd8UeytlPRLbZfz55Q=
golang.org/x/net v0.57.1-0.20260729233039-99c3b0a8f463/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
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=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -589,36 +577,34 @@ 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.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.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-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
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.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
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.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
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.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
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/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=
golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 h1:cqHQ3AycTHvM2R7ikgyX57D+XvtcSnGylsLkOVhta/w=
golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE=
golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI=
google.golang.org/genproto/googleapis/api v0.0.0-20260610212136-7ab31c22f7ad h1:3iLyITS/sySRwbUKoC7ogfj2Yr1Cjs0pfaRKj5U5HEw=
@@ -646,12 +632,12 @@ gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8 h1:Zy8IV/+FMLxy6j6p87vk/vQGKcdnbprwjTxc8UiUtsA=
gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q=
honnef.co/go/tools v0.8.0-rc.1 h1:wqMm2kjcEXMOr+6yau+pdKqJKe6l2N1aKPkpini+Kzk=
honnef.co/go/tools v0.8.0-rc.1/go.mod h1:XA+OnlRA9EDh/ukGvXMNSZNKGwFQJ+5dER0ioUkOxks=
honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU=
honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc=
howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM=
howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
k8s.io/client-go v0.35.3 h1:s1lZbpN4uI6IxeTM2cpdtrwHcSOBML1ODNTCCfsP1pg=
k8s.io/client-go v0.35.3/go.mod h1:RzoXkc0mzpWIDvBrRnD+VlfXP+lRzqQjCmKtiwZ8Q9c=
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=
@@ -686,8 +672,8 @@ 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.103.0-pre.0.20260819232550-0e84b4a3a0a0 h1:nIHx5ElZ6YmM4ExbfLygId3BmQwbV7PuI00nuSuDYSY=
tailscale.com v1.103.0-pre.0.20260819232550-0e84b4a3a0a0/go.mod h1:dTIBg+L0r0+A3BGQpR6I1WF87NMLZDS8L05BqNvSnN0=
tailscale.com v1.101.0-pre h1:q1eBWxryj7Lz5fMvi7npSbN/fJ3q6/crvbbfMkx89F8=
tailscale.com v1.101.0-pre/go.mod h1:DQ9YBy85DpNlSyeU2XRIWzbAu3RsGp/frv+Khg57meE=
tailscale.com/client/tailscale/v2 v2.9.0 h1:zBZIIeIYXL42qvvile7d29O2DKSr3AfNc2gzd1JCf2o=
tailscale.com/client/tailscale/v2 v2.9.0/go.mod h1:FGjvGT3ThHelqo0gfdK3IN3k1dwNbRzYbQh2XO3C47U=
zombiezen.com/go/postgrestest v1.0.1 h1:aXoADQAJmZDU3+xilYVut0pHhgc0sF8ZspPW9gFNwP4=
+2 -1
View File
@@ -9,6 +9,7 @@ import (
"github.com/danielgtaylor/huma/v2"
"github.com/juanfont/headscale/hscontrol/types"
"gorm.io/gorm"
)
func init() {
@@ -216,7 +217,7 @@ func listUsersFiltered(b Backend, in *listUsersInput) ([]types.User, error) {
return b.State.ListAllUsers()
}
return b.State.ListUsersWithFilter(&types.User{ID: uint(id)})
return b.State.ListUsersWithFilter(&types.User{Model: gorm.Model{ID: uint(id)}})
default:
return b.State.ListAllUsers()
}
+2 -6
View File
@@ -8,6 +8,7 @@ import (
"io"
"net"
"net/http"
_ "net/http/pprof" // nolint
"os"
"os/signal"
"path/filepath"
@@ -446,7 +447,7 @@ func (h *Headscale) createRouter(apiV1Mux, apiV2Mux http.Handler) *chi.Mux {
Host: false,
Proto: true,
Skip: func(r *http.Request) bool {
return r.Method == http.MethodOptions
return r.Method != http.MethodOptions
},
}))
r.Use(middleware.RequestID)
@@ -459,11 +460,6 @@ func (h *Headscale) createRouter(apiV1Mux, apiV2Mux http.Handler) *chi.Mux {
r.Use(middleware.Recoverer)
r.Use(securityHeaders)
// TS2021 accepts both the native client's HTTP POST upgrade and the
// browser/WASM client's WebSocket GET upgrade; NoiseUpgradeHandler
// dispatches on the Upgrade header, not the method. Registering GET as
// well keeps the router from rejecting the WebSocket handshake with 405.
r.Get(ts2021UpgradePath, h.NoiseUpgradeHandler)
r.Post(ts2021UpgradePath, h.NoiseUpgradeHandler)
r.Get("/robots.txt", h.RobotsHandler)
+9 -45
View File
@@ -233,20 +233,6 @@ func (h *Headscale) handleLogout(
Msg("Node is not ephemeral, setting expiry instead of deleting")
}
// Tagged nodes have key expiry permanently disabled (they are owned by
// their tags, not a user, and never expire - KB 1068). Logging one out has
// no expiry semantics, so do not stamp an expiry on it: doing so leaves the
// node IsExpired() forever and it can never re-authenticate (#3371). The
// admin path `headscale nodes expire` remains free to set a deliberate
// expiry via SetNodeExpiry; only the logout path is guarded here.
if node.IsTagged() {
log.Debug().
EmbedObject(node).
Msg("Tagged node logout: not stamping expiry (tagged nodes never expire)")
return nodeToRegisterResponse(node), nil
}
// Update the internal state with the nodes new expiry, meaning it is
// logged out.
//
@@ -315,40 +301,18 @@ func (h *Headscale) waitForFollowup(
}
if reg, ok := h.state.GetAuthCacheEntry(followupReg); ok {
var verdict types.AuthVerdict
select {
// Prefer a completed registration even if the context has also
// expired. When both are ready, a plain select picks at random and
// would discard a successful registration as a spurious timeout
// (issue #3385).
case verdict = <-reg.WaitForAuth():
default:
select {
case <-ctx.Done():
return nil, NewHTTPError(http.StatusUnauthorized, "registration timed out", ctx.Err())
case verdict = <-reg.WaitForAuth():
}
}
case <-ctx.Done():
return nil, NewHTTPError(http.StatusUnauthorized, "registration timed out", err)
case verdict := <-reg.WaitForAuth():
if verdict.Accept() {
if !verdict.Node.Valid() {
// registration is expired in the cache, instruct the client to try a new registration
return h.reqToNewRegisterResponse(req, machineKey)
}
if verdict.Accept() {
if !verdict.Node.Valid() {
// registration is expired in the cache, instruct the client to try a new registration
return h.reqToNewRegisterResponse(req, machineKey)
return nodeToRegisterResponse(verdict.Node), nil
}
// The followup poll is only authenticated by the auth ID in the
// URL, so fail closed unless the Noise session asking for the
// result was started with the same machine key that opened the
// registration. [State.HandleNodeFromAuthPath] resolves the node
// from the cached [types.RegistrationData.MachineKey], so the two
// match on the normal path. [Headscale.handleRegister] and
// [Headscale.handleLogout] apply the same check.
err := machineKeyMismatch(verdict.Node, machineKey)
if err != nil {
return nil, err
}
return nodeToRegisterResponse(verdict.Node), nil
}
}
-389
View File
@@ -1337,392 +1337,3 @@ func TestReregistrationZeroExpiryStaysNil(t *testing.T) {
assert.False(t, node2.Expiry().Valid(),
"re-registration with zero client expiry and no default should leave expiry nil, not pointer to zero time")
}
// tsLogoutSentinelExpiry is the past expiry a real tailscale client sends on
// `tailscale logout`: time.Unix(123, 0) (controlclient/direct.go). The issue
// #3371 trace shows it verbatim as `expiry=123`. Using it here (rather than a
// generic time.Now().Add(-1h)) keeps the reproduction faithful to the wire
// behaviour: handleRegister must classify this as a logout, and handleLogout
// must clamp it to now.
func tsLogoutSentinelExpiry() time.Time {
return time.Unix(123, 0)
}
// TestIssue3371_TaggedNodeLogoutReloginSingleUseKey reproduces
// https://github.com/juanfont/headscale/issues/3371 through the real
// register/logout HTTP-handler path (handleRegister -> handleLogout ->
// handleRegister), not by poking SetNodeExpiry directly.
//
// Root cause (a): `tailscale logout` sends a past expiry; handleLogout stamps
// it on the node via SetNodeExpiry with no IsTagged guard, so a tagged node —
// which must have key-expiry disabled — becomes Expired.
//
// Root cause (b): on the next `tailscale up --auth-key <fresh key>`,
// HandleNodeFromPreAuthKey sees an expired node, takes the expired-node
// validation path, consumes the fresh single-use key on the in-place
// re-registration, yet leaves the node expired (the expiry-refresh block is
// gated `!node.IsTagged()`). The response carries NodeKeyExpired=true, so the
// client rotates its node key and retries with the now-spent key, which is
// rejected with "authkey already used" forever.
//
// Faithful to the artifacts: the client rotates its NodeKey on relogin, the
// logout carries BOTH a past expiry AND an auth key, and a BRAND NEW key is
// presented for the relogin (the trace shows tag:tag2 keys burned while the
// node kept tag:tag1).
func TestIssue3371_TaggedNodeLogoutReloginSingleUseKey(t *testing.T) {
app := createTestApp(t)
user := app.state.CreateUserForTest("tag-logout-user")
tags := []string{"tag:tag1"}
// `headscale preauthkeys create --tags tag:tag1` (single-use).
pak, err := app.state.CreatePreAuthKey(user.TypedID(), false, false, nil, tags)
require.NoError(t, err)
machineKey := key.NewMachine()
nodeKey := key.NewNode()
// `tailscale up --auth-key $KEY1`: initial join.
regReq := tailcfg.RegisterRequest{
Auth: &tailcfg.RegisterResponseAuth{AuthKey: pak.Key},
NodeKey: nodeKey.Public(),
Hostinfo: &tailcfg.Hostinfo{Hostname: "headscale-debug"},
}
resp, err := app.handleRegister(context.Background(), regReq, machineKey.Public())
require.NoError(t, err)
require.True(t, resp.MachineAuthorized)
require.False(t, resp.NodeKeyExpired)
node, found := app.state.GetNodeByNodeKey(nodeKey.Public())
require.True(t, found)
require.True(t, node.IsTagged(), "precondition: node is tagged")
require.False(t, node.Expiry().Valid(), "precondition: tagged node has expiry disabled")
// `tailscale logout`: client sends a past-expiry register with the auth key
// still attached (handleRegister must treat past expiry as logout regardless
// of Auth). Reuse the same node key: logout does not rotate it.
logoutReq := tailcfg.RegisterRequest{
Auth: &tailcfg.RegisterResponseAuth{AuthKey: pak.Key},
NodeKey: nodeKey.Public(),
Expiry: tsLogoutSentinelExpiry(),
}
_, err = app.handleRegister(context.Background(), logoutReq, machineKey.Public())
require.NoError(t, err)
// A tagged node must NOT be expired by logout — tagged nodes never expire.
// This is root cause (a); it fails before the fix.
nodeAfterLogout, found := app.state.GetNodeByNodeKey(nodeKey.Public())
require.True(t, found)
assert.False(t, nodeAfterLogout.IsExpired(),
"issue #3371 root cause (a): logout must not expire a tagged node")
assert.False(t, nodeAfterLogout.Expiry().Valid(),
"issue #3371 root cause (a): tagged node must keep key-expiry disabled after logout")
// `tailscale up --auth-key $KEY2`: a BRAND NEW single-use key, and the client
// rotates its node key (as the real client does on relogin).
pak2, err := app.state.CreatePreAuthKey(user.TypedID(), false, false, nil, tags)
require.NoError(t, err)
nodeKey2 := key.NewNode()
reloginReq := tailcfg.RegisterRequest{
Auth: &tailcfg.RegisterResponseAuth{AuthKey: pak2.Key},
NodeKey: nodeKey2.Public(),
Hostinfo: &tailcfg.Hostinfo{Hostname: "headscale-debug"},
}
reloginResp, err := app.handleRegister(context.Background(), reloginReq, machineKey.Public())
require.NoError(t, err,
"issue #3371: a fresh valid key must re-authenticate the tagged node after logout")
require.NotNil(t, reloginResp)
// The whole point: the node comes back online, not stuck expired.
assert.False(t, reloginResp.NodeKeyExpired,
"issue #3371: relogin response must not report the node key as expired")
assert.True(t, reloginResp.MachineAuthorized)
relogged, found := app.state.GetNodeByNodeKey(nodeKey2.Public())
require.True(t, found)
assert.True(t, relogged.IsTagged(), "node stays tagged after relogin")
assert.False(t, relogged.IsExpired(),
"issue #3371: tagged node must be online (not expired) after relogin")
assert.False(t, relogged.Expiry().Valid(),
"issue #3371: tagged node must have key-expiry disabled after relogin")
assert.Equal(t, node.ID(), relogged.ID(), "must re-use the same node, not duplicate")
assert.Equal(t, 1, app.state.ListNodes().Len(), "machine maps to exactly one node")
}
// TestIssue3371_TaggedNodeLogoutReloginReusableKey is the reusable-key variant
// from the issue ("tailscale up then hangs indefinitely instead of erroring").
// With a reusable key the relogin does not hit "authkey already used", but the
// node still stays expired without the fix — so the client never observes a
// non-expired node and hangs. The observable failure here is the persisted
// expired state after relogin.
func TestIssue3371_TaggedNodeLogoutReloginReusableKey(t *testing.T) {
app := createTestApp(t)
user := app.state.CreateUserForTest("tag-logout-reusable")
tags := []string{"tag:tag1"}
// `headscale preauthkeys create --reusable --tags tag:tag1`.
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: "reusable-tagged"},
}
_, err = app.handleRegister(context.Background(), regReq, machineKey.Public())
require.NoError(t, err)
node, found := app.state.GetNodeByNodeKey(nodeKey.Public())
require.True(t, found)
require.True(t, node.IsTagged())
require.False(t, node.Expiry().Valid())
// Logout.
logoutReq := tailcfg.RegisterRequest{
Auth: &tailcfg.RegisterResponseAuth{AuthKey: pak.Key},
NodeKey: nodeKey.Public(),
Expiry: tsLogoutSentinelExpiry(),
}
_, err = app.handleRegister(context.Background(), logoutReq, machineKey.Public())
require.NoError(t, err)
// Relogin with the same reusable key, rotating the node key.
nodeKey2 := key.NewNode()
reloginReq := tailcfg.RegisterRequest{
Auth: &tailcfg.RegisterResponseAuth{AuthKey: pak.Key},
NodeKey: nodeKey2.Public(),
Hostinfo: &tailcfg.Hostinfo{Hostname: "reusable-tagged"},
}
reloginResp, err := app.handleRegister(context.Background(), reloginReq, machineKey.Public())
require.NoError(t, err)
require.NotNil(t, reloginResp)
assert.False(t, reloginResp.NodeKeyExpired,
"issue #3371: reusable-key relogin must not report node key expired")
relogged, found := app.state.GetNodeByNodeKey(nodeKey2.Public())
require.True(t, found)
assert.True(t, relogged.IsTagged())
assert.False(t, relogged.IsExpired(),
"issue #3371: tagged node must be online (not expired) after reusable-key relogin")
assert.False(t, relogged.Expiry().Valid(),
"issue #3371: tagged node must have key-expiry disabled after reusable-key relogin")
}
// TestIssue3371_TaggedNodeLogoutDoesNotSetExpiry pins the deepest root cause
// (a) in isolation: `tailscale logout` on a tagged node must not stamp an
// expiry at all. This is the assertion PR #3372 does not make — it leaves
// handleLogout expiring tagged nodes and only unwinds the damage on the next
// registration. Keeping this separate from the relogin tests means a
// regression that re-introduces logout-sets-expiry is caught even if the
// re-registration cleanup masks it.
func TestIssue3371_TaggedNodeLogoutDoesNotSetExpiry(t *testing.T) {
app := createTestApp(t)
user := app.state.CreateUserForTest("tag-logout-noexpiry")
tags := []string{"tag:tag1"}
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: "noexpiry-tagged"},
}
_, err = app.handleRegister(context.Background(), regReq, machineKey.Public())
require.NoError(t, err)
logoutReq := tailcfg.RegisterRequest{
Auth: &tailcfg.RegisterResponseAuth{AuthKey: pak.Key},
NodeKey: nodeKey.Public(),
Expiry: tsLogoutSentinelExpiry(),
}
_, err = app.handleRegister(context.Background(), logoutReq, machineKey.Public())
require.NoError(t, err)
nodeAfterLogout, found := app.state.GetNodeByNodeKey(nodeKey.Public())
require.True(t, found)
assert.True(t, nodeAfterLogout.IsTagged(), "node stays tagged through logout")
assert.False(t, nodeAfterLogout.Expiry().Valid(),
"issue #3371 root cause (a): logout must not set an expiry on a tagged node")
assert.False(t, nodeAfterLogout.IsExpired(),
"issue #3371 root cause (a): tagged node must not be expired by logout")
// The database column must be NULL, not a clamped 'now' timestamp — a
// persisted expiry survives restart and re-triggers the lockout.
var dbNode types.Node
require.NoError(t,
app.state.DB().DB.First(&dbNode, nodeAfterLogout.ID().Uint64()).Error)
assert.Nil(t, dbNode.Expiry,
"issue #3371 root cause (a): tagged node's DB expiry must remain NULL after logout")
}
// TestIssue3371_UserOwnedNodeLogoutStillExpires is the guard rail: the fix for
// tagged nodes must not change logout for ordinary user-owned nodes. A
// user-owned node that logs out MUST still be expired (that is what logout
// means for it).
func TestIssue3371_UserOwnedNodeLogoutStillExpires(t *testing.T) {
app := createTestApp(t)
user := app.state.CreateUserForTest("user-logout")
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: "user-node"},
Expiry: time.Now().Add(24 * time.Hour),
}
_, err = app.handleRegister(context.Background(), regReq, machineKey.Public())
require.NoError(t, err)
node, found := app.state.GetNodeByNodeKey(nodeKey.Public())
require.True(t, found)
require.False(t, node.IsTagged(), "precondition: user-owned node")
logoutReq := tailcfg.RegisterRequest{
Auth: &tailcfg.RegisterResponseAuth{AuthKey: pak.Key},
NodeKey: nodeKey.Public(),
Expiry: tsLogoutSentinelExpiry(),
}
logoutResp, err := app.handleRegister(context.Background(), logoutReq, machineKey.Public())
require.NoError(t, err)
require.NotNil(t, logoutResp)
nodeAfterLogout, found := app.state.GetNodeByNodeKey(nodeKey.Public())
require.True(t, found)
assert.True(t, nodeAfterLogout.IsExpired(),
"user-owned node must still be expired by logout (fix must not regress this)")
assert.True(t, logoutResp.NodeKeyExpired,
"logout response for a user-owned node must report the key expired")
}
// TestIssue3371_TaggedNodeFutureExpirySurvivesRelogin is the discriminator
// guard rail for the fix. A tagged node may carry a DELIBERATE future expiry
// set by an admin (`headscale nodes expire`); TestTaggedNodeCanHaveKeyExpiry
// establishes that is legal. The #3371 fix clears only a STALE PAST expiry (the
// logout stamp) on re-registration — it must NOT wipe a future expiry. This
// test locks that boundary: without care, a "tagged => clear expiry" fix would
// silently destroy the admin's setting.
//
// Passes before the fix (re-registration currently never touches a tagged
// node's expiry) and must keep passing after.
func TestIssue3371_TaggedNodeFutureExpirySurvivesRelogin(t *testing.T) {
app := createTestApp(t)
user := app.state.CreateUserForTest("tag-future-expiry")
tags := []string{"tag:tag1"}
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: "future-expiry-tagged"},
}
_, err = app.handleRegister(context.Background(), regReq, machineKey.Public())
require.NoError(t, err)
node, found := app.state.GetNodeByNodeKey(nodeKey.Public())
require.True(t, found)
require.True(t, node.IsTagged())
// Admin sets a deliberate future expiry (`headscale nodes expire`).
future := time.Now().Add(24 * time.Hour)
_, _, err = app.state.SetNodeExpiry(node.ID(), &future)
require.NoError(t, err)
withFuture, found := app.state.GetNodeByNodeKey(nodeKey.Public())
require.True(t, found)
require.True(t, withFuture.Expiry().Valid(), "precondition: future expiry set")
require.False(t, withFuture.IsExpired(), "precondition: future expiry is not expired")
// Node re-registers (rotating its node key). The future expiry must survive.
nodeKey2 := key.NewNode()
reregReq := tailcfg.RegisterRequest{
Auth: &tailcfg.RegisterResponseAuth{AuthKey: pak.Key},
NodeKey: nodeKey2.Public(),
Hostinfo: &tailcfg.Hostinfo{Hostname: "future-expiry-tagged"},
}
_, err = app.handleRegister(context.Background(), reregReq, machineKey.Public())
require.NoError(t, err)
after, found := app.state.GetNodeByNodeKey(nodeKey2.Public())
require.True(t, found)
require.True(t, after.IsTagged(), "node stays tagged")
assert.True(t, after.Expiry().Valid(),
"deliberate future expiry must survive re-registration (not cleared by #3371 fix)")
assert.WithinDuration(t, future, after.Expiry().Get(), 5*time.Second,
"the surviving expiry must be the admin-set future value, unchanged")
}
// TestIssue3371_EphemeralTaggedNodeLogoutDeletes is a regression guard for the
// ephemeral+tagged combination. A tagged pre-auth key can also be ephemeral.
// handleLogout deletes ephemeral nodes (before any expiry stamp), so the #3371
// fix (which suppresses the expiry stamp for tagged nodes) must not divert an
// ephemeral tagged node away from deletion.
//
// Passes before the fix and must keep passing after.
func TestIssue3371_EphemeralTaggedNodeLogoutDeletes(t *testing.T) {
app := createTestApp(t)
user := app.state.CreateUserForTest("tag-ephemeral")
tags := []string{"tag:tag1"}
// Ephemeral + tagged key.
pak, err := app.state.CreatePreAuthKey(user.TypedID(), true, true, 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: "ephemeral-tagged"},
}
_, err = app.handleRegister(context.Background(), regReq, machineKey.Public())
require.NoError(t, err)
node, found := app.state.GetNodeByNodeKey(nodeKey.Public())
require.True(t, found)
require.True(t, node.IsTagged(), "precondition: node is tagged")
require.True(t, node.IsEphemeral(), "precondition: node is ephemeral")
// Logout: an ephemeral node is deleted, not expired.
logoutReq := tailcfg.RegisterRequest{
Auth: &tailcfg.RegisterResponseAuth{AuthKey: pak.Key},
NodeKey: nodeKey.Public(),
Expiry: tsLogoutSentinelExpiry(),
}
_, err = app.handleRegister(context.Background(), logoutReq, machineKey.Public())
require.NoError(t, err)
require.EventuallyWithT(t, func(c *assert.CollectT) {
_, stillThere := app.state.GetNodeByNodeKey(nodeKey.Public())
assert.False(c, stillThere,
"ephemeral tagged node must be deleted on logout, not expired")
}, 2*time.Second, 50*time.Millisecond, "waiting for ephemeral node deletion")
}
+6 -155
View File
@@ -4,7 +4,6 @@ import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"testing"
@@ -693,11 +692,6 @@ func TestAuthenticationFlows(t *testing.T) {
user := app.state.CreateUserForTest("followup-user")
node := app.state.CreateNodeForTest(user, "followup-success-node")
// [State.HandleNodeFromAuthPath] resolves the node from the
// machine key cached when the registration was opened, so on
// the real path the node carries the polling session's
// machine key. CreateNodeForTest picks a random one.
node.MachineKey = machineKey1.Public()
nodeToRegister.FinishAuth(types.AuthVerdict{Node: node.View()})
}()
@@ -2710,11 +2704,15 @@ func runInteractiveWorkflowTest(t *testing.T, tt struct {
// extractRegistrationIDFromAuthURL extracts the registration ID from an AuthURL.
func extractRegistrationIDFromAuthURL(authURL string) (types.AuthID, error) {
// AuthURL format: "http://localhost/register/abc123"
_, idStr, found := strings.CutLast(authURL, "/register/")
if !found {
const registerPrefix = "/register/"
idx := strings.LastIndex(authURL, registerPrefix)
if idx == -1 {
return "", fmt.Errorf("invalid AuthURL format: %s", authURL) //nolint:err113
}
idStr := authURL[idx+len(registerPrefix):]
return types.AuthIDFromString(idStr)
}
@@ -4103,150 +4101,3 @@ func TestHandleNodeFromAuthPath_OldUserNil_NoPanic(t *testing.T) {
assert.NotEqual(t, types.NodeID(99002), node.ID(), "new node, not orphan")
assert.Equal(t, userB.ID, node.UserID().Get(), "new node belongs to userB")
}
// TestWaitForFollowupMachineKeyMismatch covers the followup poll in
// [Headscale.waitForFollowup]. That poll is authenticated only by the auth ID
// embedded in the followup URL, so without a machine-key check anyone who
// learns an ID gets the registering user's User/Login back in the
// [tailcfg.RegisterResponse].
//
// [Headscale.handleRegister] and [Headscale.handleLogout] already fail closed
// here; see the "existing_node_machine_key_mismatch" case in
// [TestAuthenticationFlows] for the equivalent assertion on that path.
//
// The nodes are given the registering session's machine key because that is
// what production produces: [State.HandleNodeFromAuthPath] resolves the node
// from the machine key cached in [types.RegistrationData] when the
// registration was opened.
func TestWaitForFollowupMachineKeyMismatch(t *testing.T) {
app := createTestApp(t)
victimMachineKey := key.NewMachine()
attackerMachineKey := key.NewMachine()
// Park a completed registration in the auth cache, as a node that is
// already polling for its verdict would see it.
newPendingFollowup := func(hostname string) string {
authID := types.MustAuthID()
regEntry := types.NewRegisterAuthRequest(&types.RegistrationData{
MachineKey: victimMachineKey.Public(),
NodeKey: key.NewNode().Public(),
Hostname: hostname,
})
app.state.SetAuthCacheEntry(authID, regEntry)
user := app.state.CreateUserForTest(hostname + "-user")
node := app.state.CreateNodeForTest(user, hostname)
node.MachineKey = victimMachineKey.Public()
// CreateNodeForTest only sets UserID, but nodeToRegisterResponse reads
// the owner, and the owner's identity is exactly what must not leak.
node.User = user
regEntry.FinishAuth(types.AuthVerdict{Node: node.View()})
return fmt.Sprintf("http://localhost:8080/register/%s", authID)
}
followup := func(url string, machineKey key.MachinePublic) (*tailcfg.RegisterResponse, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return app.handleRegister(ctx, tailcfg.RegisterRequest{
Followup: url,
NodeKey: key.NewNode().Public(),
}, machineKey)
}
t.Run("mismatched machine key is rejected", func(t *testing.T) {
resp, err := followup(newPendingFollowup("followup-mismatch"), attackerMachineKey.Public())
require.Error(t, err, "followup with a foreign machine key must not succeed")
assert.Nil(t, resp, "no registration details should be returned")
var httpErr HTTPError
require.ErrorAs(t, err, &httpErr)
assert.Equal(t, http.StatusUnauthorized, httpErr.Code)
})
// Positive control. Without it a regression that stops the poll from
// finding the cache entry at all would still pass the case above, because
// waitForFollowup falls back to handing out a fresh AuthURL.
t.Run("matching machine key still completes", func(t *testing.T) {
resp, err := followup(newPendingFollowup("followup-match"), victimMachineKey.Public())
require.NoError(t, err)
require.NotNil(t, resp)
assert.True(t, resp.MachineAuthorized)
assert.NotEmpty(t, resp.User.DisplayName, "the owner's identity is returned on the legitimate path")
})
}
// TestFollowupWaitPrefersCompletedAuthOverExpiredContext reproduces
// https://github.com/juanfont/headscale/issues/3385.
//
// Root cause: [Headscale.waitForFollowup] selects on ctx.Done() and the auth
// verdict channel with equal priority. When the registration has ALREADY
// completed (verdict buffered) but the request context has ALSO expired, Go's
// select picks a ready case at random, so roughly half the time it returns
// "registration timed out" and discards a successful registration.
//
// The v0.28.0 hscontrol test suite hit this because the followup context
// timeout was only 100ms while the setup goroutine (create user + node in
// SQLite) frequently took longer on slower/constrained builders (ppc64le,
// Alpine CI). Both channels ended up ready at once and the flake surfaced as
// TestAuthenticationFlows/followup_registration_success failing with
// "http error[401]: registration timed out".
//
// The fix must give the completed-auth case priority over context
// cancellation. This test forces both cases ready on every iteration; it must
// never report a timeout.
func TestFollowupWaitPrefersCompletedAuthOverExpiredContext(t *testing.T) {
app := createTestApp(t)
machineKey := key.NewMachine().Public()
nodeKey := key.NewNode().Public()
const iterations = 300
timeouts, authorized := 0, 0
for i := range iterations {
regID, err := types.NewAuthID()
require.NoError(t, err)
authReq := types.NewRegisterAuthRequest(&types.RegistrationData{
Hostname: "followup-race-node",
})
app.state.SetAuthCacheEntry(regID, authReq)
// Registration completes BEFORE we wait: verdict is buffered.
user := app.state.CreateUserForTest(fmt.Sprintf("followup-race-user-%d", i))
node := app.state.CreateNodeForTest(user, "followup-race-node")
// waitForFollowup fails closed unless the node carries the polling
// session's machine key; production sets this via the cached
// RegistrationData. CreateNodeForTest picks a random one.
node.MachineKey = machineKey
authReq.FinishAuth(types.AuthVerdict{Node: node.View()})
// Context is expired BEFORE we wait: both select cases are ready.
ctx, cancel := context.WithCancel(context.Background())
cancel()
req := tailcfg.RegisterRequest{
Followup: fmt.Sprintf("http://localhost:8080/register/%s", regID),
NodeKey: nodeKey,
}
resp, err := app.waitForFollowup(ctx, req, machineKey)
switch {
case err != nil:
timeouts++
case resp != nil && resp.MachineAuthorized:
authorized++
}
}
assert.Zero(t, timeouts,
"waitForFollowup must never report a timeout when auth has already completed; got %d/%d timeouts",
timeouts, iterations)
assert.Equal(t, iterations, authorized, "every completed registration must be returned as authorized")
}
+2 -4
View File
@@ -5,10 +5,10 @@ package capver
import (
"maps"
"slices"
"sort"
"strings"
"tailscale.com/tailcfg"
"tailscale.com/util/cmpver"
"tailscale.com/util/set"
)
@@ -74,9 +74,7 @@ func TailscaleLatestMajorMinor(n int, stripV bool) []string {
}
majorSl := majors.Slice()
// cmpver orders versions numerically, so v1.100 sorts after v1.98 rather than
// lexically before it.
slices.SortFunc(majorSl, cmpver.Compare)
sort.Strings(majorSl)
if n > len(majorSl) {
return majorSl
+39 -41
View File
@@ -5,45 +5,44 @@ package capver
import "tailscale.com/tailcfg"
var tailscaleToCapVer = map[string]tailcfg.CapabilityVersion{
"v1.24": 32,
"v1.26": 32,
"v1.28": 32,
"v1.30": 41,
"v1.32": 46,
"v1.34": 51,
"v1.36": 56,
"v1.38": 58,
"v1.40": 61,
"v1.42": 62,
"v1.44": 63,
"v1.46": 65,
"v1.48": 68,
"v1.50": 74,
"v1.52": 79,
"v1.54": 79,
"v1.56": 82,
"v1.58": 85,
"v1.60": 87,
"v1.62": 88,
"v1.64": 90,
"v1.66": 95,
"v1.68": 97,
"v1.70": 102,
"v1.72": 104,
"v1.74": 106,
"v1.76": 106,
"v1.78": 109,
"v1.80": 113,
"v1.82": 115,
"v1.84": 116,
"v1.86": 123,
"v1.88": 125,
"v1.90": 130,
"v1.92": 131,
"v1.94": 131,
"v1.96": 133,
"v1.98": 138,
"v1.102": 142,
"v1.24": 32,
"v1.26": 32,
"v1.28": 32,
"v1.30": 41,
"v1.32": 46,
"v1.34": 51,
"v1.36": 56,
"v1.38": 58,
"v1.40": 61,
"v1.42": 62,
"v1.44": 63,
"v1.46": 65,
"v1.48": 68,
"v1.50": 74,
"v1.52": 79,
"v1.54": 79,
"v1.56": 82,
"v1.58": 85,
"v1.60": 87,
"v1.62": 88,
"v1.64": 90,
"v1.66": 95,
"v1.68": 97,
"v1.70": 102,
"v1.72": 104,
"v1.74": 106,
"v1.76": 106,
"v1.78": 109,
"v1.80": 113,
"v1.82": 115,
"v1.84": 116,
"v1.86": 123,
"v1.88": 125,
"v1.90": 130,
"v1.92": 131,
"v1.94": 131,
"v1.96": 133,
"v1.98": 138,
}
var capVerToTailscaleVer = map[tailcfg.CapabilityVersion]string{
@@ -80,7 +79,6 @@ var capVerToTailscaleVer = map[tailcfg.CapabilityVersion]string{
131: "v1.92",
133: "v1.96",
138: "v1.98",
142: "v1.102",
}
// SupportedMajorMinorVersions is the number of major.minor Tailscale versions supported.
@@ -88,4 +86,4 @@ const SupportedMajorMinorVersions = 10
// MinSupportedCapabilityVersion represents the minimum capability version
// supported by this Headscale instance (latest 10 minor versions)
const MinSupportedCapabilityVersion tailcfg.CapabilityVersion = 115
const MinSupportedCapabilityVersion tailcfg.CapabilityVersion = 113
+4 -4
View File
@@ -9,9 +9,10 @@ var tailscaleLatestMajorMinorTests = []struct {
stripV bool
expected []string
}{
{3, false, []string{"v1.96", "v1.98", "v1.102"}},
{2, true, []string{"1.98", "1.102"}},
{3, false, []string{"v1.94", "v1.96", "v1.98"}},
{2, true, []string{"1.96", "1.98"}},
{10, true, []string{
"1.80",
"1.82",
"1.84",
"1.86",
@@ -21,7 +22,6 @@ var tailscaleLatestMajorMinorTests = []struct {
"1.94",
"1.96",
"1.98",
"1.102",
}},
{0, false, nil},
}
@@ -30,7 +30,7 @@ var capVerMinimumTailscaleVersionTests = []struct {
input tailcfg.CapabilityVersion
expected string
}{
{115, "v1.82"},
{113, "v1.80"},
{32, "v1.24"},
{41, "v1.30"},
{46, "v1.32"},
-27
View File
@@ -900,33 +900,6 @@ WHERE user_id IS NULL
},
Rollback: func(db *gorm.DB) error { return nil },
},
{
// Clear stale key expiry on tagged nodes. A tagged node is
// owned by its tags and never expires (KB 1068), but a buggy
// handleLogout stamped a past expiry on it, leaving it
// permanently Expired and unable to re-authenticate. The
// buggy writer is fixed, so this only repairs rows written
// before the upgrade; a fixed server cannot recreate them.
// Match the tagged-node predicate the earlier
// clear-tagged-node-user-id migration uses (a nil tags slice
// marshals to 'null', so exclude it).
// Fixes: https://github.com/juanfont/headscale/issues/3371
ID: "202607241200-clear-tagged-node-expiry",
Migrate: func(tx *gorm.DB) error {
err := tx.Exec(`
UPDATE nodes
SET expiry = NULL
WHERE tags IS NOT NULL AND tags != '[]' AND tags != '' AND tags != 'null'
AND expiry IS NOT NULL;
`).Error
if err != nil {
return fmt.Errorf("clearing expiry on tagged nodes: %w", err)
}
return nil
},
Rollback: func(db *gorm.DB) error { return nil },
},
},
)
+8 -66
View File
@@ -295,64 +295,6 @@ func TestSQLiteMigrationAndDataValidation(t *testing.T) {
assert.Equal(t, uint(1), *node4.UserID, "node4 should still belong to user1")
},
},
// Test for the clear-tagged-node-expiry migration
// (202607241200-clear-tagged-node-expiry). A buggy handleLogout stamped
// a key expiry on tagged nodes, which never expire (KB 1068), leaving
// them permanently Expired. The migration clears expiry on tagged rows
// only, preserving user-owned nodes' expiry.
// Fixes: https://github.com/juanfont/headscale/issues/3371
{
dbPath: "testdata/sqlite/clear_tagged_node_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: tagged with a stale PAST expiry (the bug). Cleared.
node1 := byHostname["node1"]
require.NotNil(t, node1, "node1 should exist")
assert.True(t, node1.IsTagged(), "node1 should be tagged")
assert.Nil(t, node1.Expiry, "node1 (tagged) stale expiry should be cleared")
assert.False(t, node1.IsExpired(), "node1 must not be reported expired")
// Node 2: tagged with a FUTURE expiry. Tagged nodes never expire,
// so this is cleared too.
node2 := byHostname["node2"]
require.NotNil(t, node2, "node2 should exist")
assert.True(t, node2.IsTagged(), "node2 should be tagged")
assert.Nil(t, node2.Expiry, "node2 (tagged) expiry should be cleared")
// Node 3: tagged, expiry already NULL. Stays NULL.
node3 := byHostname["node3"]
require.NotNil(t, node3, "node3 should exist")
assert.True(t, node3.IsTagged(), "node3 should be tagged")
assert.Nil(t, node3.Expiry, "node3 (tagged) NULL expiry should be preserved")
// Node 4: untagged (tags='null') with a PAST expiry. PRESERVED —
// the migration must not touch user-owned nodes.
node4 := byHostname["node4"]
require.NotNil(t, node4, "node4 should exist")
assert.False(t, node4.IsTagged(), "node4 (tags='null') should be untagged")
require.NotNil(t, node4.Expiry, "node4 (user-owned) expiry must be preserved")
assert.Equal(t, 2020, node4.Expiry.UTC().Year(), "node4 past expiry preserved")
// Node 5: untagged (tags='[]') with a FUTURE expiry. PRESERVED.
node5 := byHostname["node5"]
require.NotNil(t, node5, "node5 should exist")
assert.False(t, node5.IsTagged(), "node5 (tags='[]') should be untagged")
require.NotNil(t, node5.Expiry, "node5 (user-owned) expiry must be preserved")
assert.Equal(t, 2099, node5.Expiry.UTC().Year(), "node5 future expiry preserved")
},
},
}
for _, tt := range tests {
@@ -415,8 +357,8 @@ func TestConstraints(t *testing.T) {
name: "no-oidc-duplicate-username-and-id",
run: func(t *testing.T, db *gorm.DB) { //nolint:thelper
user := types.User{
ID: 1,
Name: "user1",
Model: gorm.Model{ID: 1},
Name: "user1",
}
user.ProviderIdentifier = sql.NullString{String: "http://test.com/user1", Valid: true}
@@ -424,8 +366,8 @@ func TestConstraints(t *testing.T) {
require.NoError(t, err)
user = types.User{
ID: 2,
Name: "user1",
Model: gorm.Model{ID: 2},
Name: "user1",
}
user.ProviderIdentifier = sql.NullString{String: "http://test.com/user1", Valid: true}
@@ -437,8 +379,8 @@ func TestConstraints(t *testing.T) {
name: "no-oidc-duplicate-id",
run: func(t *testing.T, db *gorm.DB) { //nolint:thelper
user := types.User{
ID: 1,
Name: "user1",
Model: gorm.Model{ID: 1},
Name: "user1",
}
user.ProviderIdentifier = sql.NullString{String: "http://test.com/user1", Valid: true}
@@ -446,8 +388,8 @@ func TestConstraints(t *testing.T) {
require.NoError(t, err)
user = types.User{
ID: 2,
Name: "user1.1",
Model: gorm.Model{ID: 2},
Name: "user1.1",
}
user.ProviderIdentifier = sql.NullString{String: "http://test.com/user1", Valid: true}
-10
View File
@@ -498,16 +498,6 @@ func (e *EphemeralGarbageCollector) Cancel(nodeID types.NodeID) {
}
}
// IsScheduled reports whether a deletion timer is currently armed for nodeID.
func (e *EphemeralGarbageCollector) IsScheduled(nodeID types.NodeID) bool {
e.mu.Lock()
defer e.mu.Unlock()
_, ok := e.toBeDeleted[nodeID]
return ok
}
// Start starts the garbage collector.
func (e *EphemeralGarbageCollector) Start() {
for {
@@ -1,85 +0,0 @@
-- Test SQL dump for the clear-tagged-node-expiry migration
-- (202607241200-clear-tagged-node-expiry)
--
-- A buggy handleLogout stamped a past key expiry on tagged nodes. Tagged
-- nodes are owned by their tags and never expire (KB 1068), so such a row is
-- reported as permanently Expired and can never re-authenticate. The migration
-- clears expiry on tagged rows while leaving user-owned nodes untouched.
-- Fixes: https://github.com/juanfont/headscale/issues/3371
PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION;
-- Migrations table: entries applied up to (but not including) the fix. The
-- intervening expiry migrations (clear-zero-time) also run against this dump;
-- their predicates (expiry < 1900) do not match the post-2000 dates below, so
-- they leave these rows for the new migration to handle.
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: TAGGED, user_id NULL, stale PAST expiry (the #3371 bug). After migration: expiry NULL.
INSERT INTO nodes VALUES(1,'mkey:a0ab77456320823945ae0331823e3c0d516fae9585bd42698dfa1ac3d7679e01','nodekey:7c84167ab68f494942de14deb83587fd841843de2bac105b6c670048c1605501','discokey:53075b3c6cad3b62a2a29caea61beeb93f66b8c75cb89dac465236a5bbf57701','[]','{}','100.64.0.1','fd7a:115c:a1e0::1','node1','node1',NULL,'authkey','["tag:foo"]',NULL,'2024-01-01 00:00:00+00:00','2020-06-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: TAGGED, user_id NULL, FUTURE expiry. Tagged nodes never expire, so cleared to NULL too.
INSERT INTO nodes VALUES(2,'mkey:a0ab77456320823945ae0331823e3c0d516fae9585bd42698dfa1ac3d7679e02','nodekey:7c84167ab68f494942de14deb83587fd841843de2bac105b6c670048c1605502','discokey:53075b3c6cad3b62a2a29caea61beeb93f66b8c75cb89dac465236a5bbf57702','[]','{}','100.64.0.2','fd7a:115c:a1e0::2','node2','node2',NULL,'authkey','["tag:foo"]',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 3: TAGGED, user_id NULL, expiry already NULL. After migration: still NULL.
INSERT INTO nodes VALUES(3,'mkey:a0ab77456320823945ae0331823e3c0d516fae9585bd42698dfa1ac3d7679e03','nodekey:7c84167ab68f494942de14deb83587fd841843de2bac105b6c670048c1605503','discokey:53075b3c6cad3b62a2a29caea61beeb93f66b8c75cb89dac465236a5bbf57703','[]','{}','100.64.0.3','fd7a:115c:a1e0::3','node3','node3',NULL,'authkey','["tag:foo"]',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 4: UNTAGGED user-owned (tags='null'), PAST expiry. Must be PRESERVED (guard against
-- the #3323-class over-match: a nil tags slice marshals to the literal 'null').
INSERT INTO nodes VALUES(4,'mkey:a0ab77456320823945ae0331823e3c0d516fae9585bd42698dfa1ac3d7679e04','nodekey:7c84167ab68f494942de14deb83587fd841843de2bac105b6c670048c1605504','discokey:53075b3c6cad3b62a2a29caea61beeb93f66b8c75cb89dac465236a5bbf57704','[]','{}','100.64.0.4','fd7a:115c:a1e0::4','node4','node4',1,'cli','null',NULL,'2024-01-01 00:00:00+00:00','2020-06-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: UNTAGGED user-owned (tags='[]'), FUTURE expiry. Must be PRESERVED.
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','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);
-- 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;
+1 -1
View File
@@ -72,7 +72,7 @@ func (TextSerialiser) Scan(ctx context.Context, field *schema.Field, dst reflect
ret := f.Call(args)
if !ret[0].IsNil() {
if err, ok := reflect.TypeAssert[error](ret[0]); ok {
if err, ok := ret[0].Interface().(error); ok {
return decodingError(field.Name, err)
}
}
+2 -2
View File
@@ -74,7 +74,7 @@ func loadDERPMapFromURL(addr url.URL) (*tailcfg.DERPMap, error) {
func mergeDERPMaps(derpMaps []*tailcfg.DERPMap) *tailcfg.DERPMap {
result := tailcfg.DERPMap{
OmitDefaultRegions: false,
Regions: map[tailcfg.DERPRegionID]*tailcfg.DERPRegion{},
Regions: map[int]*tailcfg.DERPRegion{},
}
for _, derpMap := range derpMaps {
@@ -129,7 +129,7 @@ func shuffleDERPMap(dm *tailcfg.DERPMap) {
// Collect region IDs and sort them to ensure deterministic iteration order.
// Map iteration order is non-deterministic in Go, which would cause the
// shuffle to be non-deterministic even with a fixed seed.
ids := make([]tailcfg.DERPRegionID, 0, len(dm.Regions))
ids := make([]int, 0, len(dm.Regions))
for id := range dm.Regions {
ids = append(ids, id)
}
+1 -1
View File
@@ -12,7 +12,7 @@ import (
// cannot mutate a shared or previously served map.
func TestMergeDERPMapsClonesRegions(t *testing.T) {
src := &tailcfg.DERPMap{
Regions: map[tailcfg.DERPRegionID]*tailcfg.DERPRegion{
Regions: map[int]*tailcfg.DERPRegion{
1: {RegionID: 1, Nodes: []*tailcfg.DERPNode{{Name: "a"}, {Name: "b"}}},
},
}
+16 -16
View File
@@ -19,7 +19,7 @@ func TestShuffleDERPMapDeterministic(t *testing.T) {
name: "single region with 4 nodes",
baseDomain: "test1.example.com",
derpMap: &tailcfg.DERPMap{
Regions: map[tailcfg.DERPRegionID]*tailcfg.DERPRegion{
Regions: map[int]*tailcfg.DERPRegion{
1: {
RegionID: 1,
RegionCode: "nyc",
@@ -34,7 +34,7 @@ func TestShuffleDERPMapDeterministic(t *testing.T) {
},
},
expected: &tailcfg.DERPMap{
Regions: map[tailcfg.DERPRegionID]*tailcfg.DERPRegion{
Regions: map[int]*tailcfg.DERPRegion{
1: {
RegionID: 1,
RegionCode: "nyc",
@@ -53,7 +53,7 @@ func TestShuffleDERPMapDeterministic(t *testing.T) {
name: "multiple regions with nodes",
baseDomain: "test2.example.com",
derpMap: &tailcfg.DERPMap{
Regions: map[tailcfg.DERPRegionID]*tailcfg.DERPRegion{
Regions: map[int]*tailcfg.DERPRegion{
10: {
RegionID: 10,
RegionCode: "sea",
@@ -77,7 +77,7 @@ func TestShuffleDERPMapDeterministic(t *testing.T) {
},
},
expected: &tailcfg.DERPMap{
Regions: map[tailcfg.DERPRegionID]*tailcfg.DERPRegion{
Regions: map[int]*tailcfg.DERPRegion{
10: {
RegionID: 10,
RegionCode: "sea",
@@ -105,7 +105,7 @@ func TestShuffleDERPMapDeterministic(t *testing.T) {
name: "large region with many nodes",
baseDomain: "test3.example.com",
derpMap: &tailcfg.DERPMap{
Regions: map[tailcfg.DERPRegionID]*tailcfg.DERPRegion{
Regions: map[int]*tailcfg.DERPRegion{
4: {
RegionID: 4,
RegionCode: "fra",
@@ -120,7 +120,7 @@ func TestShuffleDERPMapDeterministic(t *testing.T) {
},
},
expected: &tailcfg.DERPMap{
Regions: map[tailcfg.DERPRegionID]*tailcfg.DERPRegion{
Regions: map[int]*tailcfg.DERPRegion{
4: {
RegionID: 4,
RegionCode: "fra",
@@ -139,7 +139,7 @@ func TestShuffleDERPMapDeterministic(t *testing.T) {
name: "same region different base domain",
baseDomain: "different.example.com",
derpMap: &tailcfg.DERPMap{
Regions: map[tailcfg.DERPRegionID]*tailcfg.DERPRegion{
Regions: map[int]*tailcfg.DERPRegion{
4: {
RegionID: 4,
RegionCode: "fra",
@@ -154,7 +154,7 @@ func TestShuffleDERPMapDeterministic(t *testing.T) {
},
},
expected: &tailcfg.DERPMap{
Regions: map[tailcfg.DERPRegionID]*tailcfg.DERPRegion{
Regions: map[int]*tailcfg.DERPRegion{
4: {
RegionID: 4,
RegionCode: "fra",
@@ -173,7 +173,7 @@ func TestShuffleDERPMapDeterministic(t *testing.T) {
name: "same dataset with another base domain",
baseDomain: "another.example.com",
derpMap: &tailcfg.DERPMap{
Regions: map[tailcfg.DERPRegionID]*tailcfg.DERPRegion{
Regions: map[int]*tailcfg.DERPRegion{
4: {
RegionID: 4,
RegionCode: "fra",
@@ -188,7 +188,7 @@ func TestShuffleDERPMapDeterministic(t *testing.T) {
},
},
expected: &tailcfg.DERPMap{
Regions: map[tailcfg.DERPRegionID]*tailcfg.DERPRegion{
Regions: map[int]*tailcfg.DERPRegion{
4: {
RegionID: 4,
RegionCode: "fra",
@@ -207,7 +207,7 @@ func TestShuffleDERPMapDeterministic(t *testing.T) {
name: "same dataset with yet another base domain",
baseDomain: "yetanother.example.com",
derpMap: &tailcfg.DERPMap{
Regions: map[tailcfg.DERPRegionID]*tailcfg.DERPRegion{
Regions: map[int]*tailcfg.DERPRegion{
4: {
RegionID: 4,
RegionCode: "fra",
@@ -222,7 +222,7 @@ func TestShuffleDERPMapDeterministic(t *testing.T) {
},
},
expected: &tailcfg.DERPMap{
Regions: map[tailcfg.DERPRegionID]*tailcfg.DERPRegion{
Regions: map[int]*tailcfg.DERPRegion{
4: {
RegionID: 4,
RegionCode: "fra",
@@ -269,13 +269,13 @@ func TestShuffleDERPMapEdgeCases(t *testing.T) {
{
name: "empty derp map",
derpMap: &tailcfg.DERPMap{
Regions: map[tailcfg.DERPRegionID]*tailcfg.DERPRegion{},
Regions: map[int]*tailcfg.DERPRegion{},
},
},
{
name: "region with no nodes",
derpMap: &tailcfg.DERPMap{
Regions: map[tailcfg.DERPRegionID]*tailcfg.DERPRegion{
Regions: map[int]*tailcfg.DERPRegion{
1: {
RegionID: 1,
RegionCode: "empty",
@@ -288,7 +288,7 @@ func TestShuffleDERPMapEdgeCases(t *testing.T) {
{
name: "region with single node",
derpMap: &tailcfg.DERPMap{
Regions: map[tailcfg.DERPRegionID]*tailcfg.DERPRegion{
Regions: map[int]*tailcfg.DERPRegion{
1: {
RegionID: 1,
RegionCode: "single",
@@ -314,7 +314,7 @@ func TestShuffleDERPMapWithoutBaseDomain(t *testing.T) {
resetDerpRandomForTesting()
derpMap := &tailcfg.DERPMap{
Regions: map[tailcfg.DERPRegionID]*tailcfg.DERPRegion{
Regions: map[int]*tailcfg.DERPRegion{
1: {
RegionID: 1,
RegionCode: "test",
+2 -1
View File
@@ -112,9 +112,10 @@ func (d *DERPServer) GenerateRegion() (tailcfg.DERPRegion, error) {
RegionID: d.cfg.ServerRegionID,
RegionCode: d.cfg.ServerRegionCode,
RegionName: d.cfg.ServerRegionName,
Avoid: false,
Nodes: []*tailcfg.DERPNode{
{
Name: d.cfg.ServerRegionID.String(),
Name: strconv.Itoa(d.cfg.ServerRegionID),
RegionID: d.cfg.ServerRegionID,
HostName: host,
DERPPort: port,
+24 -20
View File
@@ -19,6 +19,17 @@ import (
)
const (
// NoiseCapabilityVersion is used by Tailscale clients to indicate
// their codebase version. Tailscale clients can communicate over TS2021
// from CapabilityVersion 28, but we only have good support for it
// since https://github.com/tailscale/tailscale/pull/4323 (Noise in any HTTPS port).
//
// Related to this change, there is https://github.com/tailscale/tailscale/pull/5379,
// where CapabilityVersion 39 is introduced to indicate #4323 was merged.
//
// See also https://github.com/tailscale/tailscale/blob/main/tailcfg/tailcfg.go
NoiseCapabilityVersion = 39
reservedResponseHeaderSize = 4
)
@@ -188,28 +199,21 @@ func (h *Headscale) KeyHandler(
return
}
// Only disclose the Noise public key to clients this server can
// actually complete a handshake with. Gating on the same floor the
// Noise handshake enforces (capver.MinSupportedCapabilityVersion, see
// isSupportedVersion in noise.go) keeps /key consistent with /ts2021:
// versions the handshake would reject get a clear rejection here
// instead of a key that only serves as a version-boundary oracle.
// See https://github.com/juanfont/headscale/issues/3380.
if !isSupportedVersion(capVer) {
httpError(writer, NewHTTPError(http.StatusBadRequest, "unsupported client version", unsupportedClientError(capVer)))
// TS2021 (Tailscale v2 protocol) requires to have a different key
if capVer >= NoiseCapabilityVersion {
resp := tailcfg.OverTLSPublicKeyResponse{
PublicKey: h.noisePrivateKey.Public(),
}
writer.Header().Set("Content-Type", "application/json")
err := json.NewEncoder(writer).Encode(resp)
if err != nil {
log.Error().Err(err).Msg("failed to encode public key response")
}
return
}
resp := tailcfg.OverTLSPublicKeyResponse{
PublicKey: h.noisePrivateKey.Public(),
}
writer.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(writer).Encode(resp)
if err != nil {
log.Error().Err(err).Msg("failed to encode public key response")
}
}
func (h *Headscale) HealthHandler(
+2 -49
View File
@@ -5,14 +5,12 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"net/netip"
"strings"
"testing"
"github.com/juanfont/headscale/hscontrol/capver"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -118,56 +116,11 @@ func TestVerifyHandler_SuccessSetsJSONContentType(t *testing.T) {
"successful /verify response must advertise application/json")
}
// TestKeyHandler_UnsupportedCapVerDoesNotLeakKey reproduces
// https://github.com/juanfont/headscale/issues/3380. The /key handler
// must gate key disclosure on the same floor the Noise handshake
// enforces (capver.MinSupportedCapabilityVersion). A capability version
// below that floor can never complete a handshake, so it must be
// rejected rather than handed the server's Noise public key, which would
// otherwise serve only as a fingerprint / version-boundary oracle.
func TestKeyHandler_UnsupportedCapVerDoesNotLeakKey(t *testing.T) {
t.Parallel()
noise := key.NewMachine()
h := &Headscale{noisePrivateKey: &noise}
unsupported := capver.MinSupportedCapabilityVersion - 1
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodGet,
fmt.Sprintf("/key?v=%d", unsupported),
nil,
)
h.KeyHandler(rec, req)
assert.Equal(t, http.StatusBadRequest, rec.Code,
"a client below the supported floor must be rejected")
assert.NotContains(t, rec.Body.String(), noise.Public().String(),
"must not disclose Noise public key to a client below the supported floor")
// A supported client still receives the key.
recOK := httptest.NewRecorder()
reqOK := httptest.NewRequestWithContext(
context.Background(),
http.MethodGet,
fmt.Sprintf("/key?v=%d", capver.MinSupportedCapabilityVersion),
nil,
)
h.KeyHandler(recOK, reqOK)
assert.Equal(t, http.StatusOK, recOK.Code)
assert.Contains(t, recOK.Body.String(), noise.Public().String(),
"a supported client must receive the Noise public key")
}
// errorAsHTTPError is a small local helper that unwraps an [HTTPError]
// from an error chain.
func errorAsHTTPError(err error) (HTTPError, bool) {
if h, ok := errors.AsType[HTTPError](err); ok {
var h HTTPError
if errors.As(err, &h) {
return h, true
}
+10 -2
View File
@@ -403,7 +403,9 @@ func (b *Batcher) Start() {
return
}
b.wg.Go(b.doWork)
b.wg.Add(1)
go b.doWork()
}
func (b *Batcher) Close() {
@@ -437,8 +439,12 @@ func (b *Batcher) Close() {
}
func (b *Batcher) doWork() {
defer b.wg.Done()
for i := range b.workers {
b.wg.Go(func() { b.worker(i + 1) })
b.wg.Add(1)
go b.worker(i + 1)
}
// Create a cleanup ticker for removing truly disconnected nodes
@@ -461,6 +467,8 @@ func (b *Batcher) doWork() {
}
func (b *Batcher) worker(workerID int) {
defer b.wg.Done()
wlog := log.With().Int(zf.WorkerID, workerID).Logger()
for {
+7 -4
View File
@@ -142,16 +142,19 @@ func runConcurrently(t *testing.T, n int, fn func(i int)) int {
)
for i := range n {
wg.Go(func() {
wg.Add(1)
go func(idx int) {
defer wg.Done()
defer func() {
if r := recover(); r != nil {
panics.Add(1)
t.Logf("panic in goroutine %d: %v", i, r)
t.Logf("panic in goroutine %d: %v", idx, r)
}
}()
fn(i)
})
fn(idx)
}(i)
}
wg.Wait()
+2 -2
View File
@@ -188,7 +188,7 @@ func setupBatcherWithTestData(
DERP: types.DERPConfig{
ServerEnabled: false,
DERPMap: &tailcfg.DERPMap{
Regions: map[tailcfg.DERPRegionID]*tailcfg.DERPRegion{
Regions: map[int]*tailcfg.DERPRegion{
999: {
RegionID: 999,
},
@@ -348,7 +348,7 @@ func assertDERPMapResponse(t *testing.T, resp *tailcfg.MapResponse) {
assert.NotNil(t, resp.DERPMap, "DERPMap should not be nil in response")
assert.Len(t, resp.DERPMap.Regions, 1, "Expected exactly one DERP region in response")
assert.Equal(t, tailcfg.DERPRegionID(999), resp.DERPMap.Regions[999].RegionID, "Expected DERP region ID to be 999")
assert.Equal(t, 999, resp.DERPMap.Regions[999].RegionID, "Expected DERP region ID to be 999")
}
func assertOnlineMapResponse(t *testing.T, resp *tailcfg.MapResponse, expected bool) {
+44 -1
View File
@@ -950,9 +950,52 @@ func TestMultiChannelSend_ConcurrentRemoveAndSend(t *testing.T) {
}
// ============================================================================
// Regression test for H3 (lifecycle)
// Regression tests for H1 (timer leak) and H3 (lifecycle)
// ============================================================================
// TestConnectionEntry_SendFastPath_TimerStopped is a regression guard for H1.
// Before the fix, connectionEntry.send used time.After(50ms) which leaked a
// timer into the runtime heap on every call even when the channel send
// succeeded immediately. The fix switched to time.NewTimer + defer Stop().
//
// This test sends many messages on a buffered (non-blocking) channel and
// checks that the number of live goroutines stays bounded, which would
// grow without bound under the old time.After approach at high call rates.
func TestConnectionEntry_SendFastPath_TimerStopped(t *testing.T) {
const sends = 5000
ch := make(chan *tailcfg.MapResponse, sends)
entry := &connectionEntry{
id: "timer-leak-test",
c: ch,
version: 100,
created: time.Now(),
}
resp := testMapResponse()
for range sends {
err := entry.send(resp)
require.NoError(t, err)
}
// Drain the channel so we aren't holding references.
for range sends {
<-ch
}
// Force a GC + timer cleanup pass.
runtime.GC()
// If timers were leaking we'd see a goroutine count much higher
// than baseline. With 5000 leaked timers the count would be
// noticeably elevated. We just check it's reasonable.
numGR := runtime.NumGoroutine()
assert.Less(t, numGR, 200,
"goroutine count after %d fast-path sends should be bounded; got %d (possible timer leak)", sends, numGR)
}
// TestBatcher_CloseWaitsForWorkers is a regression guard for H3.
// Before the fix, Close() would tear down node connections while workers
// were potentially still running, risking sends on closed channels.
+2 -24
View File
@@ -9,8 +9,6 @@ import (
"github.com/juanfont/headscale/hscontrol/policy"
policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/util/zlog/zf"
"github.com/rs/zerolog/log"
"tailscale.com/tailcfg"
"tailscale.com/types/views"
"tailscale.com/util/multierr"
@@ -154,15 +152,7 @@ func (b *MapResponseBuilder) WithSSHPolicy() *MapResponseBuilder {
sshPolicy, err := b.mapper.state.SSHPolicy(node)
if err != nil {
// SSH policy is optional for a node to function. Rather than fail the
// whole map (leaving the node unable to connect), log and continue
// without it; the node still receives a usable netmap.
log.Warn().Caller().
Err(err).
Uint64(zf.NodeID, node.ID().Uint64()).
Str(zf.NodeHostname, node.Hostname()).
Msg("building map response: skipping SSH policy for node; node will receive a map without SSH rules")
b.addError(err)
return b
}
@@ -294,19 +284,7 @@ func (b *MapResponseBuilder) buildTailPeers(peers views.Slice[types.NodeView]) (
return b.mapper.state.RoutesForPeer(node, peer, matchers)
}, b.mapper.cfg, allCapMaps[peer.ID()])
if err != nil {
// One peer with invalid data (e.g. an empty or over-long
// GivenName that fails GetFQDN) must not blank out the map for
// every node that can see it. Drop the offending peer, log it
// with the identity an operator needs to fix it, and keep
// building from the remaining valid peers.
log.Warn().Caller().
Err(err).
Uint64(zf.NodeID, peer.ID().Uint64()).
Str(zf.NodeHostname, peer.Hostname()).
Uint64("map.viewer.node.id", b.nodeID.Uint64()).
Msgf("dropping peer %d from map response: invalid node data; fix with `headscale nodes rename %d <name>`", peer.ID(), peer.ID())
continue
return nil, err
}
// [tailcfg.Node.CapMap] on a peer carries the small set of
-189
View File
@@ -1,189 +0,0 @@
//go:build !race
// This is a timing-sensitive performance regression test; the race detector's
// ~10x slowdown makes its wall-clock assertion meaningless, so it is excluded
// from -race builds. The concurrency correctness of the policy lock change it
// guards is covered under -race by TestPolicyManagerConcurrentReads in
// hscontrol/policy/v2.
package mapper
import (
"net/netip"
"runtime"
"slices"
"sync"
"testing"
"time"
"github.com/juanfont/headscale/hscontrol/db"
"github.com/juanfont/headscale/hscontrol/derp"
"github.com/juanfont/headscale/hscontrol/state"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"tailscale.com/tailcfg"
)
// setupStormBatcher builds a real state+batcher with production-default
// NodeStore batching so the reconnect-storm contention is realistic. It mirrors
// setupBatcherWithTestData but lets the test control BatcherWorkers and the
// policy.
func setupStormBatcher(tb testing.TB, nodeCount, workers int, policy string) (*TestData, func()) {
tb.Helper()
tmpDir := tb.TempDir()
prefixV4 := netip.MustParsePrefix("100.64.0.0/10")
prefixV6 := netip.MustParsePrefix("fd7a:115c:a1e0::/48")
cfg := &types.Config{
Database: types.DatabaseConfig{
Type: types.DatabaseSqlite,
Sqlite: types.SqliteConfig{Path: tmpDir + "/headscale_test.db"},
},
PrefixV4: &prefixV4,
PrefixV6: &prefixV6,
IPAllocation: types.IPAllocationStrategySequential,
BaseDomain: "headscale.test",
Policy: types.PolicyConfig{Mode: types.PolicyModeDB},
DERP: types.DERPConfig{
ServerEnabled: false,
DERPMap: &tailcfg.DERPMap{
Regions: map[tailcfg.DERPRegionID]*tailcfg.DERPRegion{999: {RegionID: 999}},
},
},
Tuning: types.Tuning{
BatchChangeDelay: 10 * time.Millisecond,
BatcherWorkers: workers,
// Production defaults: coalesce writes so the storm is not
// exaggerated by an unrealistically small NodeStore batch.
NodeStoreBatchSize: 100,
NodeStoreBatchTimeout: 500 * time.Millisecond,
},
}
database, err := db.NewHeadscaleDatabase(cfg)
require.NoError(tb, err)
users := database.CreateUsersForTest(1, "testuser")
dbNodes := database.CreateRegisteredNodesForTest(users[0], nodeCount, "node")
allNodes := make([]node, 0, nodeCount)
for i := range dbNodes {
allNodes = append(allNodes, node{
n: dbNodes[i],
ch: make(chan *tailcfg.MapResponse, normalBufferSize),
})
}
st, err := state.NewState(cfg)
require.NoError(tb, err)
derpMap, err := derp.GetDERPMap(cfg.DERP)
require.NoError(tb, err)
st.SetDERPMap(derpMap)
_, err = st.SetPolicy([]byte(policy))
require.NoError(tb, err)
batcher := wrapBatcherForTest(NewBatcherAndMapper(cfg, st), st)
batcher.Start()
td := &TestData{
Database: database,
Users: users,
Nodes: allNodes,
State: st,
Config: cfg,
Batcher: batcher,
}
return td, func() {
batcher.Close()
st.Close()
database.Close()
}
}
// TestInitialMapNotStarvedByReconnectStorm reproduces juanfont/headscale#3346.
//
// When every node redials at once (e.g. after a server upgrade restart), each
// connection writes the NodeStore (UpdateNodeFromMapRequest + Connect) and the
// batcher generates its initial map. All of that reads the policy through the
// PolicyManager. Before the fix the PolicyManager guarded every read with a
// single exclusive mutex, so the NodeStore writer's O(n^2) BuildPeerMap and
// every node's FilterForNode serialised against each other. On a per-node
// filter policy (autogroup:self, via, relay grants) each hold is expensive, so
// under the storm time-to-initial-map grew without bound.
//
// On the production server in #3346 this drove the batcher's per-node
// total.duration from ~4s to ~76s; tailscale clients aborted the map POST
// first and reported
//
// PollNetMap: Post ".../machine/map": unexpected EOF
//
// then redialled, feeding the storm so it never converged. An allow-all policy
// does NOT reproduce this — BuildPeerMap is cheap there; the per-node filter
// path is what makes it expensive, matching a real deployment's ACLs.
//
// The fix makes PolicyManager reads take a shared RLock so map generation runs
// concurrently. AddNode blocks until the initial map is generated and handed to
// the node channel, so its wall-clock duration is the time-to-initial-map the
// client experiences. Without the fix this test's slowest node takes ~10s+ at
// this scale (lock-bound, and more workers do not help); with it, generation
// parallelises across workers and stays well within a client's patience.
func TestInitialMapNotStarvedByReconnectStorm(t *testing.T) {
if testing.Short() {
t.Skip("timing-sensitive storm regression; skipped in -short")
}
const (
nodeCount = 300
// A per-node-filter policy: forces BuildPeerMap and FilterForNode onto
// the slow path that recompiles filter rules per node, the same shape
// as a real ACL using autogroup:self / via / relay grants.
perNodeFilterPolicy = `{"acls":[{"action":"accept","src":["autogroup:member"],"dst":["autogroup:self:*"]}]}`
// Deliberately roomy so it passes on CI's few-core runners, where the
// single-writer BuildPeerMap sets the floor (~10s) whatever the reads
// do. It still trips on a hang or a return to the ~76s serialised
// behaviour; the fine-grained concurrency is verified separately by
// TestPolicyManagerConcurrentReads under -race.
maxAcceptableLatency = 30 * time.Second
)
// Use the real available parallelism, as production does.
workers := runtime.NumCPU()
td, cleanup := setupStormBatcher(t, nodeCount, workers, perNodeFilterPolicy)
defer cleanup()
latencies := make([]time.Duration, nodeCount)
var wg sync.WaitGroup
for i := range td.Nodes {
wg.Go(func() {
n := &td.Nodes[i]
start := time.Now()
err := td.Batcher.AddNode(n.n.ID, n.ch, tailcfg.CapabilityVersion(100), nil)
latencies[i] = time.Since(start)
assert.NoError(t, err) //nolint:testifylint // assert (not require) is correct off the test goroutine
})
}
wg.Wait()
slices.Sort(latencies)
p50 := latencies[len(latencies)/2]
p95 := latencies[len(latencies)*95/100]
maxLatency := latencies[len(latencies)-1]
t.Logf("initial-map latency over %d nodes (workers=%d): p50=%s p95=%s max=%s",
nodeCount, workers, p50, p95, maxLatency)
require.Less(t, maxLatency, maxAcceptableLatency,
"slowest initial map took %s: policy reads are serialising instead of running concurrently (issue #3346)", maxLatency)
}
+2 -3
View File
@@ -21,7 +21,6 @@ import (
"github.com/rs/zerolog/log"
"tailscale.com/envknob"
"tailscale.com/tailcfg"
"tailscale.com/tailcfg/nodecap"
"tailscale.com/types/dnstype"
"tailscale.com/types/views"
)
@@ -107,8 +106,8 @@ func generateUserProfiles(
// path, and "nextdns:no-device-info" suppresses the metadata-appending step.
// See https://tailscale.com/docs/integrations/nextdns.
const (
nextDNSAttrPrefix = "nextdns:"
nextDNSAttrNoInfo nodecap.Cap = "nextdns:no-device-info"
nextDNSAttrPrefix = "nextdns:"
nextDNSAttrNoInfo tailcfg.NodeCapability = "nextdns:no-device-info"
)
// nextDNSProfileRE bounds the characters accepted in a `nextdns:<profile>`
+3 -92
View File
@@ -3,7 +3,6 @@ package mapper
import (
"fmt"
"net/netip"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
@@ -223,7 +222,7 @@ func TestBuildFromChangeFiltersPeerPatchesByVisibility(t *testing.T) {
Policy: types.PolicyConfig{Mode: types.PolicyModeDB},
DERP: types.DERPConfig{
DERPMap: &tailcfg.DERPMap{
Regions: map[tailcfg.DERPRegionID]*tailcfg.DERPRegion{999: {RegionID: 999}},
Regions: map[int]*tailcfg.DERPRegion{999: {RegionID: 999}},
},
},
Tuning: types.Tuning{
@@ -309,7 +308,7 @@ func TestBuildFromChangeFiltersUserProfilesByVisibility(t *testing.T) {
Policy: types.PolicyConfig{Mode: types.PolicyModeDB},
DERP: types.DERPConfig{
DERPMap: &tailcfg.DERPMap{
Regions: map[tailcfg.DERPRegionID]*tailcfg.DERPRegion{999: {RegionID: 999}},
Regions: map[int]*tailcfg.DERPRegion{999: {RegionID: 999}},
},
},
Tuning: types.Tuning{
@@ -381,7 +380,7 @@ func TestBuildFromChangeVisibilityMatchesFullMap(t *testing.T) {
Policy: types.PolicyConfig{Mode: types.PolicyModeDB},
DERP: types.DERPConfig{
DERPMap: &tailcfg.DERPMap{
Regions: map[tailcfg.DERPRegionID]*tailcfg.DERPRegion{999: {RegionID: 999}},
Regions: map[int]*tailcfg.DERPRegion{999: {RegionID: 999}},
},
},
Tuning: types.Tuning{
@@ -537,94 +536,6 @@ func TestBuildFromChangeVisibilityMatchesFullMap(t *testing.T) {
}
}
// TestFullMapResponseSurvivesPeerWithInvalidName proves a single node with an
// FQDN-invalid GivenName must not break map generation for its peers.
//
// A node whose stored GivenName is empty (ErrNodeHasNoGivenName) or yields an
// FQDN longer than MaxHostnameLength (ErrHostnameTooLong) makes GetFQDN, and
// therefore TailNode, return an error. buildTailPeers used to abort the entire
// peer list on the first such error, so MapResponseBuilder.Build() failed for
// every node that could see the bad peer; on the initial-connection path that
// surfaced as "PollNetMap: ... unexpected EOF" and the "Unable to connect to
// the Tailscale coordination server" health warning. A legacy DB row loads
// verbatim (NewNodeStore reads db.ListNodes() without re-sanitising names), so
// the bad peer persists across restart. The build for an unaffected viewer
// must succeed: the bad peer is dropped, valid peers and self survive.
func TestFullMapResponseSurvivesPeerWithInvalidName(t *testing.T) {
for _, tt := range []struct {
name string
badName string
}{
{"empty given name", ""},
{"over-long fqdn", strings.Repeat("a", types.MaxHostnameLength+1)},
} {
t.Run(tt.name, func(t *testing.T) {
tmp := t.TempDir()
p4 := netip.MustParsePrefix("100.64.0.0/10")
p6 := netip.MustParsePrefix("fd7a:115c:a1e0::/48")
cfg := &types.Config{
Database: types.DatabaseConfig{
Type: types.DatabaseSqlite,
Sqlite: types.SqliteConfig{Path: tmp + "/h.db"},
},
PrefixV4: &p4,
PrefixV6: &p6,
IPAllocation: types.IPAllocationStrategySequential,
BaseDomain: "headscale.test",
Policy: types.PolicyConfig{Mode: types.PolicyModeDB},
DERP: types.DERPConfig{
DERPMap: &tailcfg.DERPMap{
Regions: map[tailcfg.DERPRegionID]*tailcfg.DERPRegion{999: {RegionID: 999}},
},
},
Tuning: types.Tuning{
NodeStoreBatchSize: state.TestBatchSize,
NodeStoreBatchTimeout: state.TestBatchTimeout,
},
}
database, err := db.NewHeadscaleDatabase(cfg)
require.NoError(t, err)
user := database.CreateUserForTest("u1")
n1 := database.CreateRegisteredNodeForTest(user, "n1") // viewer, valid
bad := database.CreateRegisteredNodeForTest(user, "bad") // peer, name corrupted below
good := database.CreateRegisteredNodeForTest(user, "good") // peer, valid control
// Simulate a legacy/corrupt row that v29 loads verbatim.
require.NoError(t, database.DB.
Model(&types.Node{}).
Where("id = ?", bad.ID).
Update("given_name", tt.badName).Error)
require.NoError(t, database.Close())
s, err := state.NewState(cfg)
require.NoError(t, err)
t.Cleanup(func() { _ = s.Close() })
// Allow-all so n1 sees both peers; the bad one must still be dropped.
_, err = s.SetPolicy([]byte(`{"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}`))
require.NoError(t, err)
m := &mapper{state: s, cfg: cfg}
capVer := tailcfg.CurrentCapabilityVersion
resp, err := m.fullMapResponse(n1.ID, capVer)
require.NoError(t, err, "n1's map must build despite a peer with an invalid name")
require.NotNil(t, resp)
require.NotNil(t, resp.Node, "n1 must receive its own self node")
peers := map[tailcfg.NodeID]bool{}
for _, p := range resp.Peers {
peers[p.ID] = true
}
assert.False(t, peers[bad.ID.NodeID()], "the peer with an invalid name must be dropped")
assert.True(t, peers[good.ID.NodeID()], "valid peers must remain in the map")
})
}
}
// TestGenerateDNSConfigNilHostinfoNoPanic proves generateDNSConfig does not
// panic when a node's Hostinfo is nil (e.g. a legacy DB row with a NULL
// host_info column). addNextDNSMetadata dereferenced node.Hostinfo().OS()
+4 -2
View File
@@ -403,8 +403,10 @@ func (entry *connectionEntry) send(data *tailcfg.MapResponse) error {
// This is critical for detecting Docker containers that are forcefully terminated
// but still have channels that appear open.
//
// Use a timer rather than time.After so the timeout is explicitly released
// on the fast path; both are GC-recoverable since Go 1.23.
// We use time.NewTimer + Stop instead of time.After to avoid leaking timers.
// time.After creates a timer that lives in the runtime's timer heap until it fires,
// even when the send succeeds immediately. On the hot path (1000+ nodes per tick),
// this leaks thousands of timers per second.
timer := time.NewTimer(50 * time.Millisecond) //nolint:mnd
defer timer.Stop()
+27 -28
View File
@@ -12,7 +12,6 @@ import (
"github.com/juanfont/headscale/hscontrol/types"
"tailscale.com/net/tsaddr"
"tailscale.com/tailcfg"
"tailscale.com/tailcfg/nodecap"
"tailscale.com/types/key"
)
@@ -75,10 +74,10 @@ func TestTailNode(t *testing.T) {
MachineAuthorized: true,
CapMap: tailcfg.NodeCapMap{
nodecap.Admin: []tailcfg.RawMessage{},
nodecap.SSH: []tailcfg.RawMessage{},
nodecap.FileSharing: []tailcfg.RawMessage{},
nodecap.DefaultAutoUpdate: []tailcfg.RawMessage{tailcfg.RawMessage("false")},
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{},
tailcfg.NodeAttrDefaultAutoUpdate: []tailcfg.RawMessage{tailcfg.RawMessage("false")},
},
},
wantErr: false,
@@ -164,10 +163,10 @@ func TestTailNode(t *testing.T) {
MachineAuthorized: true,
CapMap: tailcfg.NodeCapMap{
nodecap.Admin: []tailcfg.RawMessage{},
nodecap.SSH: []tailcfg.RawMessage{},
nodecap.FileSharing: []tailcfg.RawMessage{},
nodecap.DefaultAutoUpdate: []tailcfg.RawMessage{tailcfg.RawMessage("false")},
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{},
tailcfg.NodeAttrDefaultAutoUpdate: []tailcfg.RawMessage{tailcfg.RawMessage("false")},
},
},
wantErr: false,
@@ -189,10 +188,10 @@ func TestTailNode(t *testing.T) {
MachineAuthorized: true,
CapMap: tailcfg.NodeCapMap{
nodecap.Admin: []tailcfg.RawMessage{},
nodecap.SSH: []tailcfg.RawMessage{},
nodecap.FileSharing: []tailcfg.RawMessage{},
nodecap.DefaultAutoUpdate: []tailcfg.RawMessage{tailcfg.RawMessage("false")},
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{},
tailcfg.NodeAttrDefaultAutoUpdate: []tailcfg.RawMessage{tailcfg.RawMessage("false")},
},
},
wantErr: false,
@@ -275,10 +274,10 @@ func TestTailNodeBaselineGates(t *testing.T) {
AutoUpdate: types.AutoUpdateConfig{Enabled: false},
},
want: tailcfg.NodeCapMap{
nodecap.Admin: []tailcfg.RawMessage{},
nodecap.SSH: []tailcfg.RawMessage{},
nodecap.FileSharing: []tailcfg.RawMessage{},
nodecap.DefaultAutoUpdate: autoUpdate(false),
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{},
tailcfg.NodeAttrDefaultAutoUpdate: autoUpdate(false),
},
},
{
@@ -288,9 +287,9 @@ func TestTailNodeBaselineGates(t *testing.T) {
AutoUpdate: types.AutoUpdateConfig{Enabled: false},
},
want: tailcfg.NodeCapMap{
nodecap.Admin: []tailcfg.RawMessage{},
nodecap.SSH: []tailcfg.RawMessage{},
nodecap.DefaultAutoUpdate: autoUpdate(false),
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
tailcfg.NodeAttrDefaultAutoUpdate: autoUpdate(false),
},
},
{
@@ -300,10 +299,10 @@ func TestTailNodeBaselineGates(t *testing.T) {
AutoUpdate: types.AutoUpdateConfig{Enabled: true},
},
want: tailcfg.NodeCapMap{
nodecap.Admin: []tailcfg.RawMessage{},
nodecap.SSH: []tailcfg.RawMessage{},
nodecap.FileSharing: []tailcfg.RawMessage{},
nodecap.DefaultAutoUpdate: autoUpdate(true),
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{},
tailcfg.NodeAttrDefaultAutoUpdate: autoUpdate(true),
},
},
{
@@ -313,9 +312,9 @@ func TestTailNodeBaselineGates(t *testing.T) {
AutoUpdate: types.AutoUpdateConfig{Enabled: true},
},
want: tailcfg.NodeCapMap{
nodecap.Admin: []tailcfg.RawMessage{},
nodecap.SSH: []tailcfg.RawMessage{},
nodecap.DefaultAutoUpdate: autoUpdate(true),
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
tailcfg.NodeAttrDefaultAutoUpdate: autoUpdate(true),
},
},
}
@@ -352,7 +351,7 @@ func TestTailNodeBaselineGates(t *testing.T) {
func TestTailNodeDisableIPv4(t *testing.T) {
t.Parallel()
const NodeAttrDisableIPv4 nodecap.Cap = "disable-ipv4"
const NodeAttrDisableIPv4 tailcfg.NodeCapability = "disable-ipv4"
v4 := iap("100.64.0.1")
v6Addr := netip.MustParseAddr("fd7a:115c:a1e0::1")
+1 -1
View File
@@ -150,7 +150,7 @@ func (h *Headscale) NoiseUpgradeHandler(
Host: false,
Proto: true,
Skip: func(r *http.Request) bool {
return r.Method == http.MethodOptions
return r.Method != http.MethodOptions
},
}))
r.Use(middleware.RequestID)
-65
View File
@@ -459,71 +459,6 @@ func TestSSHActionHandler_RejectsMissingSessionWithoutCheck(t *testing.T) {
"a bogus auth_id with no active check must be rejected, body=%s", rec.Body.String())
}
// TestTS2021Route_AcceptsGETAndPOST reproduces a regression where the
// browser/WASM control client could not connect. Tailscale's JS/WASM control
// client opens /ts2021 as a WebSocket, which is an HTTP GET upgrade; the native
// Go client uses an HTTP POST upgrade. The gorilla->chi router migration
// registered /ts2021 for POST only, so the GET WebSocket handshake was rejected
// with 405 Method Not Allowed by the router before it could reach
// NoiseUpgradeHandler. Both methods must route to the handler.
//
// NoiseUpgradeHandler dispatches on the Upgrade header, not the HTTP method, so
// once the route is reachable the handler handles both upgrade styles. The
// httptest recorder is not an http.Hijacker, so the upgrade itself fails past
// the router (501 for the WebSocket path, 400 for the native path) — the point
// is only that neither is 405, i.e. the router no longer rejects GET early.
func TestTS2021Route_AcceptsGETAndPOST(t *testing.T) {
t.Parallel()
handler := createTestApp(t).HTTPHandler()
tests := []struct {
name string
method string
headers map[string]string
}{
{
name: "websocket_get_from_wasm_client",
method: http.MethodGet,
headers: map[string]string{
"Connection": "Upgrade",
"Upgrade": "websocket",
"Sec-WebSocket-Version": "13",
"Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ==",
"Sec-WebSocket-Protocol": "tailscale-control-protocol",
},
},
{
name: "native_post_upgrade",
method: http.MethodPost,
headers: map[string]string{
"Connection": "upgrade",
"Upgrade": "tailscale-control-protocol",
"X-Tailscale-Handshake": "AAAA",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
req := httptest.NewRequestWithContext(context.Background(), tt.method,
"/ts2021?X-Tailscale-Handshake=AAAA", nil)
for k, v := range tt.headers {
req.Header.Set(k, v)
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.NotEqual(t, http.StatusMethodNotAllowed, rec.Code,
"%s /ts2021 must reach NoiseUpgradeHandler, not be rejected by the router with 405",
tt.method)
})
}
}
// newSSHActionFollowUpRequest is like newSSHActionRequest but carries the
// auth_id query parameter that marks a follow-up poll.
func newSSHActionFollowUpRequest(t *testing.T, src, dst types.NodeID, authID types.AuthID) *http.Request {
+2 -2
View File
@@ -515,8 +515,8 @@ func validateOIDCAllowedDomains(
claims *types.OIDCClaims,
) error {
if len(allowedDomains) > 0 {
if _, domain, found := strings.CutLast(claims.Email, "@"); !found ||
!slices.Contains(allowedDomains, domain) {
if at := strings.LastIndex(claims.Email, "@"); at < 0 ||
!slices.Contains(allowedDomains, claims.Email[at+1:]) {
return NewHTTPError(http.StatusUnauthorized, "unauthorised domain", errOIDCAllowedDomains)
}
}
+12 -3
View File
@@ -5,9 +5,9 @@ import (
_ "embed"
"net/http"
textTemplate "text/template"
"uuid"
"github.com/go-chi/chi/v5"
"github.com/gofrs/uuid/v5"
"github.com/juanfont/headscale/hscontrol/templates"
)
@@ -41,8 +41,17 @@ func (h *Headscale) ApplePlatformConfig(
return
}
id := uuid.NewV4()
contentID := uuid.NewV4()
id, err := uuid.NewV4()
if err != nil {
httpError(writer, err)
return
}
contentID, err := uuid.NewV4()
if err != nil {
httpError(writer, err)
return
}
platformConfig := AppleMobilePlatformConfig{
UUID: contentID,
+3 -4
View File
@@ -8,7 +8,6 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"tailscale.com/tailcfg"
"tailscale.com/tailcfg/peercap"
)
func TestMatchFromStrings(t *testing.T) {
@@ -195,7 +194,7 @@ func TestMatchFromFilterRule(t *testing.T) {
netip.MustParsePrefix("100.64.0.3/32"),
},
CapMap: tailcfg.PeerCapMap{
peercap.Relay: nil,
tailcfg.PeerCapabilityRelay: nil,
},
},
},
@@ -219,7 +218,7 @@ func TestMatchFromFilterRule(t *testing.T) {
netip.MustParsePrefix("100.64.0.2/32"),
},
CapMap: tailcfg.PeerCapMap{
peercap.RelayTarget: nil,
tailcfg.PeerCapabilityRelayTarget: nil,
},
},
},
@@ -243,7 +242,7 @@ func TestMatchFromFilterRule(t *testing.T) {
netip.MustParsePrefix("100.64.0.3/32"),
},
CapMap: tailcfg.PeerCapMap{
peercap.Relay: nil,
tailcfg.PeerCapabilityRelay: nil,
},
},
},
-6
View File
@@ -30,12 +30,6 @@ type PolicyManager interface {
// NodeCanHaveTag reports whether the given node can have the given tag.
NodeCanHaveTag(node types.NodeView, tag string) bool
// UserCanHaveTag reports whether the given user owns the given tag, i.e.
// is listed (directly or via a group) in the tag's tagOwners. This is the
// user half of NodeCanHaveTag, used to authorise re-auth tag changes
// against the authenticating user rather than the node's stale ownership.
UserCanHaveTag(user types.UserView, tag string) bool
// TagExists reports whether the given tag is defined in the policy.
TagExists(tag string) bool
+7 -6
View File
@@ -11,18 +11,19 @@ import (
"github.com/juanfont/headscale/hscontrol/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"tailscale.com/types/key"
"tailscale.com/types/views"
)
func TestApproveRoutesWithPolicy_NeverRemovesApprovedRoutes(t *testing.T) {
user1 := types.User{
ID: 1,
Name: "testuser",
Model: gorm.Model{ID: 1},
Name: "testuser",
}
user2 := types.User{
ID: 2,
Name: "otheruser",
Model: gorm.Model{ID: 2},
Name: "otheruser",
}
users := []types.User{user1, user2}
@@ -292,8 +293,8 @@ func TestApproveRoutesWithPolicy_NilAndEmptyCases(t *testing.T) {
t.Run(fmt.Sprintf("%s-policy-index%d", tt.name, i), func(t *testing.T) {
// Create test user
user := types.User{
ID: 1,
Name: "test",
Model: gorm.Model{ID: 1},
Name: "test",
}
users := []types.User{user}
@@ -10,6 +10,7 @@ import (
"github.com/juanfont/headscale/hscontrol/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
)
@@ -155,8 +156,8 @@ func TestApproveRoutesWithPolicy_NeverRemovesRoutes(t *testing.T) {
t.Run(fmt.Sprintf("%s-policy-index%d", tt.name, i), func(t *testing.T) {
// Create test user
user := types.User{
ID: 1,
Name: tt.nodeUser,
Model: gorm.Model{ID: 1},
Name: tt.nodeUser,
}
users := []types.User{user}
@@ -282,8 +283,8 @@ func TestApproveRoutesWithPolicy_EdgeCases(t *testing.T) {
t.Run(fmt.Sprintf("%s-policy-index%d", tt.name, i), func(t *testing.T) {
// Create test user
user := types.User{
ID: 1,
Name: "test",
Model: gorm.Model{ID: 1},
Name: "test",
}
users := []types.User{user}
@@ -325,8 +326,8 @@ func TestApproveRoutesWithPolicy_EdgeCases(t *testing.T) {
func TestApproveRoutesWithPolicy_NilPolicyManagerCase(t *testing.T) {
user := types.User{
ID: 1,
Name: "test",
Model: gorm.Model{ID: 1},
Name: "test",
}
userID := user.ID
+6 -5
View File
@@ -11,6 +11,7 @@ import (
"github.com/juanfont/headscale/hscontrol/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"tailscale.com/tailcfg"
)
@@ -1133,11 +1134,11 @@ func TestReduceNodesFromPolicy(t *testing.T) {
func TestSSHPolicyRules(t *testing.T) {
users := []types.User{
{Name: "user1", ID: 1},
{Name: "user2", ID: 2},
{Name: "user3", ID: 3},
{Name: "alice", Email: "alice@example.com", ID: 4},
{Name: "bob", Email: "bob@example.com", ID: 5},
{Name: "user1", Model: gorm.Model{ID: 1}},
{Name: "user2", Model: gorm.Model{ID: 2}},
{Name: "user3", Model: gorm.Model{ID: 3}},
{Name: "alice", Email: "alice@example.com", Model: gorm.Model{ID: 4}},
{Name: "bob", Email: "bob@example.com", Model: gorm.Model{ID: 5}},
}
// Create standard node setups used across tests
+6 -5
View File
@@ -13,6 +13,7 @@ import (
"github.com/juanfont/headscale/hscontrol/util"
"github.com/rs/zerolog/log"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"tailscale.com/net/tsaddr"
"tailscale.com/tailcfg"
"tailscale.com/util/must"
@@ -107,11 +108,11 @@ func TestTheInternet(t *testing.T) {
func TestReduceFilterRules(t *testing.T) {
users := types.Users{
types.User{ID: 1, Name: "mickael"},
types.User{ID: 2, Name: "user1"},
types.User{ID: 3, Name: "user2"},
types.User{ID: 4, Name: "user100"},
types.User{ID: 5, Name: "user3"},
types.User{Model: gorm.Model{ID: 1}, Name: "mickael"},
types.User{Model: gorm.Model{ID: 2}, Name: "user1"},
types.User{Model: gorm.Model{ID: 3}, Name: "user2"},
types.User{Model: gorm.Model{ID: 4}, Name: "user100"},
types.User{Model: gorm.Model{ID: 5}, Name: "user3"},
}
tests := []struct {
+4 -3
View File
@@ -9,13 +9,14 @@ import (
"github.com/juanfont/headscale/hscontrol/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func TestNodeCanApproveRoute(t *testing.T) {
users := []types.User{
{Name: "user1", ID: 1},
{Name: "user2", ID: 2},
{Name: "user3", ID: 3},
{Name: "user1", Model: gorm.Model{ID: 1}},
{Name: "user2", Model: gorm.Model{ID: 2}},
{Name: "user3", Model: gorm.Model{ID: 3}},
}
// Create standard node setups used across tests
+3 -5
View File
@@ -10,8 +10,6 @@ import (
"github.com/rs/zerolog/log"
"go4.org/netipx"
"tailscale.com/tailcfg"
"tailscale.com/tailcfg/nodecap"
"tailscale.com/tailcfg/peercap"
"tailscale.com/types/views"
"tailscale.com/util/set"
)
@@ -159,7 +157,7 @@ func (pol *Policy) compileNodeAttrs(
}
result := make(map[types.NodeID]tailcfg.NodeCapMap)
stamp := func(id types.NodeID, attr nodecap.Cap) {
stamp := func(id types.NodeID, attr tailcfg.NodeCapability) {
capMap, ok := result[id]
if !ok {
capMap = tailcfg.NodeCapMap{}
@@ -192,7 +190,7 @@ func (pol *Policy) compileNodeAttrs(
if pol.RandomizeClientPort {
for _, ni := range nodeList {
stamp(ni.id, nodecap.RandomizeClientPort)
stamp(ni.id, tailcfg.NodeAttrRandomizeClientPort)
}
}
@@ -600,7 +598,7 @@ func collectRelayTargetIPs(grants []compiledGrant) (*netipx.IPSet, error) {
for i := range grants {
for _, rule := range grants[i].rules {
for _, cg := range rule.CapGrant {
if _, ok := cg.CapMap[peercap.Relay]; !ok {
if _, ok := cg.CapMap[tailcfg.PeerCapabilityRelay]; !ok {
continue
}
+8 -9
View File
@@ -13,7 +13,6 @@ import (
"github.com/rs/zerolog/log"
"go4.org/netipx"
"tailscale.com/tailcfg"
"tailscale.com/tailcfg/peercap"
"tailscale.com/types/views"
)
@@ -25,8 +24,8 @@ var (
// companionCap pairs a well-known Tailscale capability with its
// companion capability.
type companionCap struct {
original peercap.Cap
companion peercap.Cap
original tailcfg.PeerCapability
companion tailcfg.PeerCapability
}
// companionCaps lists certain well-known Tailscale capabilities and
@@ -36,8 +35,8 @@ type companionCap struct {
// The slice is ordered by the original capability name so that
// generated companion rules are emitted deterministically.
var companionCaps = []companionCap{
{peercap.Taildrive, peercap.TaildriveSharer},
{peercap.Relay, peercap.RelayTarget},
{tailcfg.PeerCapabilityTaildrive, tailcfg.PeerCapabilityTaildriveSharer},
{tailcfg.PeerCapabilityRelay, tailcfg.PeerCapabilityRelayTarget},
}
// companionCapGrantRules returns additional [tailcfg.FilterRule]s for any
@@ -512,16 +511,16 @@ func resolveLocalparts(
continue
}
localpart, emailDomain, found := strings.CutLast(user.Email, "@")
if !found {
atIdx := strings.LastIndex(user.Email, "@")
if atIdx < 0 {
continue
}
if !strings.EqualFold(emailDomain, domain) {
if !strings.EqualFold(user.Email[atIdx+1:], domain) {
continue
}
result[user.ID] = localpart
result[user.ID] = user.Email[:atIdx]
}
}
+77 -77
View File
@@ -15,8 +15,8 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go4.org/netipx"
"gorm.io/gorm"
"tailscale.com/tailcfg"
"tailscale.com/tailcfg/peercap"
)
// aliasWithPorts creates an AliasWithPorts structure from an alias and ports.
@@ -29,7 +29,7 @@ func aliasWithPorts(alias Alias, ports ...tailcfg.PortRange) AliasWithPorts {
func TestParsing(t *testing.T) {
users := types.Users{
{ID: 1, Name: "testuser"},
{Model: gorm.Model{ID: 1}, Name: "testuser"},
}
tests := []struct {
name string
@@ -390,8 +390,8 @@ func TestParsing(t *testing.T) {
func TestCompileSSHPolicy_UserMapping(t *testing.T) {
users := types.Users{
{Name: "user1", ID: 1},
{Name: "user2", ID: 2},
{Name: "user1", Model: gorm.Model{ID: 1}},
{Name: "user2", Model: gorm.Model{ID: 2}},
}
// Create test nodes - use tagged nodes as SSH destinations
@@ -638,10 +638,10 @@ func TestCompileSSHPolicy_UserMapping(t *testing.T) {
func TestCompileSSHPolicy_LocalpartMapping(t *testing.T) {
users := types.Users{
{Name: "alice", Email: "alice@example.com", ID: 1},
{Name: "bob", Email: "bob@example.com", ID: 2},
{Name: "charlie", Email: "charlie@other.com", ID: 3},
{Name: "dave", ID: 4}, // CLI user, no email
{Name: "alice", Email: "alice@example.com", Model: gorm.Model{ID: 1}},
{Name: "bob", Email: "bob@example.com", Model: gorm.Model{ID: 2}},
{Name: "charlie", Email: "charlie@other.com", Model: gorm.Model{ID: 3}},
{Name: "dave", Model: gorm.Model{ID: 4}}, // CLI user, no email
}
nodeTaggedServer := types.Node{
@@ -836,10 +836,10 @@ func TestCompileSSHPolicy_LocalpartMapping(t *testing.T) {
{
name: "localpart with special chars in email",
users: types.Users{
{Name: "dave+sshuser", Email: "dave+sshuser@example.com", ID: 10},
{Name: "dave+sshuser", Email: "dave+sshuser@example.com", Model: gorm.Model{ID: 10}},
},
nodes: func() types.Nodes {
specialUser := types.User{Name: "dave+sshuser", Email: "dave+sshuser@example.com", ID: 10}
specialUser := types.User{Name: "dave+sshuser", Email: "dave+sshuser@example.com", Model: gorm.Model{ID: 10}}
n := types.Node{
Hostname: "special-device",
IPv4: createAddr("100.64.0.10"),
@@ -880,10 +880,10 @@ func TestCompileSSHPolicy_LocalpartMapping(t *testing.T) {
{
name: "localpart excludes CLI users without email",
users: types.Users{
{Name: "dave", ID: 4},
{Name: "dave", Model: gorm.Model{ID: 4}},
},
nodes: func() types.Nodes {
cliUser := types.User{Name: "dave", ID: 4}
cliUser := types.User{Name: "dave", Model: gorm.Model{ID: 4}}
n := types.Node{
Hostname: "dave-cli-device",
IPv4: createAddr("100.64.0.5"),
@@ -1005,8 +1005,8 @@ func TestCompileSSHPolicy_LocalpartMapping(t *testing.T) {
func TestCompileSSHPolicy_CheckAction(t *testing.T) {
users := types.Users{
{Name: "user1", ID: 1},
{Name: "user2", ID: 2},
{Name: "user1", Model: gorm.Model{ID: 1}},
{Name: "user2", Model: gorm.Model{ID: 2}},
}
// Use tagged nodes for SSH user mapping tests
@@ -1077,8 +1077,8 @@ func TestCompileSSHPolicy_CheckAction(t *testing.T) {
// the accept rule appears first in the policy definition.
func TestCompileSSHPolicy_CheckBeforeAcceptOrdering(t *testing.T) {
users := types.Users{
{Name: "user1", ID: 1},
{Name: "user2", ID: 2},
{Name: "user1", Model: gorm.Model{ID: 1}},
{Name: "user2", Model: gorm.Model{ID: 2}},
}
nodeTaggedServer := types.Node{
@@ -1153,8 +1153,8 @@ func TestCompileSSHPolicy_CheckBeforeAcceptOrdering(t *testing.T) {
func TestSSHIntegrationReproduction(t *testing.T) {
// Create users matching the integration test
users := types.Users{
{Name: "user1", ID: 1},
{Name: "user2", ID: 2},
{Name: "user1", Model: gorm.Model{ID: 1}},
{Name: "user2", Model: gorm.Model{ID: 2}},
}
// Create simple nodes for testing
@@ -1218,7 +1218,7 @@ func TestSSHIntegrationReproduction(t *testing.T) {
// to JSON and that the sshUsers field is not empty.
func TestSSHJSONSerialization(t *testing.T) {
users := types.Users{
{Name: "user1", ID: 1},
{Name: "user1", Model: gorm.Model{ID: 1}},
}
uid := uint(1)
@@ -1278,8 +1278,8 @@ func TestSSHJSONSerialization(t *testing.T) {
func TestCompileFilterRulesForNodeWithAutogroupSelf(t *testing.T) {
users := types.Users{
{ID: 1, Name: "user1"},
{ID: 2, Name: "user2"},
{Model: gorm.Model{ID: 1}, Name: "user1"},
{Model: gorm.Model{ID: 2}, Name: "user2"},
}
nodes := types.Nodes{
@@ -1439,8 +1439,8 @@ func TestCompileFilterRulesForNodeWithAutogroupSelf(t *testing.T) {
// It also verifies that tag-to-tag rules work correctly.
func TestTagUserMutualExclusivity(t *testing.T) {
users := types.Users{
{ID: 1, Name: "user1"},
{ID: 2, Name: "user2"},
{Model: gorm.Model{ID: 1}, Name: "user1"},
{Model: gorm.Model{ID: 2}, Name: "user2"},
}
nodes := types.Nodes{
@@ -1541,8 +1541,8 @@ func TestTagUserMutualExclusivity(t *testing.T) {
// are valid and should produce filter rules.
func TestUserToTagCrossIdentityGrant(t *testing.T) {
users := types.Users{
{ID: 1, Name: "user1"},
{ID: 2, Name: "user2"},
{Model: gorm.Model{ID: 1}, Name: "user1"},
{Model: gorm.Model{ID: 2}, Name: "user2"},
}
nodes := types.Nodes{
@@ -1626,8 +1626,8 @@ func TestAutogroupTagged(t *testing.T) {
t.Parallel()
users := types.Users{
{ID: 1, Name: "user1"},
{ID: 2, Name: "user2"},
{Model: gorm.Model{ID: 1}, Name: "user1"},
{Model: gorm.Model{ID: 2}, Name: "user2"},
}
nodes := types.Nodes{
@@ -1778,8 +1778,8 @@ func TestAutogroupSelfInSourceIsRejected(t *testing.T) {
// are allowed (and only if they match the target user).
func TestAutogroupSelfWithSpecificUserSource(t *testing.T) {
users := types.Users{
{ID: 1, Name: "user1"},
{ID: 2, Name: "user2"},
{Model: gorm.Model{ID: 1}, Name: "user1"},
{Model: gorm.Model{ID: 2}, Name: "user2"},
}
nodes := types.Nodes{
@@ -1845,9 +1845,9 @@ func TestAutogroupSelfWithSpecificUserSource(t *testing.T) {
// as the target are allowed.
func TestAutogroupSelfWithGroupSource(t *testing.T) {
users := types.Users{
{ID: 1, Name: "user1"},
{ID: 2, Name: "user2"},
{ID: 3, Name: "user3"},
{Model: gorm.Model{ID: 1}, Name: "user1"},
{Model: gorm.Model{ID: 2}, Name: "user2"},
{Model: gorm.Model{ID: 3}, Name: "user3"},
}
nodes := types.Nodes{
@@ -1914,8 +1914,8 @@ func createAddr(ip string) *netip.Addr {
// with autogroup:self in destinations.
func TestSSHWithAutogroupSelfInDestination(t *testing.T) {
users := types.Users{
{ID: 1, Name: "user1"},
{ID: 2, Name: "user2"},
{Model: gorm.Model{ID: 1}, Name: "user1"},
{Model: gorm.Model{ID: 2}, Name: "user2"},
}
nodes := types.Nodes{
@@ -1996,8 +1996,8 @@ func TestSSHWithAutogroupSelfInDestination(t *testing.T) {
// can SSH (and only if they match the target user).
func TestSSHWithAutogroupSelfAndSpecificUser(t *testing.T) {
users := types.Users{
{ID: 1, Name: "user1"},
{ID: 2, Name: "user2"},
{Model: gorm.Model{ID: 1}, Name: "user1"},
{Model: gorm.Model{ID: 2}, Name: "user2"},
}
nodes := types.Nodes{
@@ -2051,9 +2051,9 @@ func TestSSHWithAutogroupSelfAndSpecificUser(t *testing.T) {
// TestSSHWithAutogroupSelfAndGroup verifies SSH with group sources and autogroup:self destinations.
func TestSSHWithAutogroupSelfAndGroup(t *testing.T) {
users := types.Users{
{ID: 1, Name: "user1"},
{ID: 2, Name: "user2"},
{ID: 3, Name: "user3"},
{Model: gorm.Model{ID: 1}, Name: "user1"},
{Model: gorm.Model{ID: 2}, Name: "user2"},
{Model: gorm.Model{ID: 3}, Name: "user3"},
}
nodes := types.Nodes{
@@ -2112,7 +2112,7 @@ func TestSSHWithAutogroupSelfAndGroup(t *testing.T) {
// are excluded from both sources and destinations when autogroup:self is used.
func TestSSHWithAutogroupSelfExcludesTaggedDevices(t *testing.T) {
users := types.Users{
{ID: 1, Name: "user1"},
{Model: gorm.Model{ID: 1}, Name: "user1"},
}
nodes := types.Nodes{
@@ -2173,8 +2173,8 @@ func TestSSHWithAutogroupSelfExcludesTaggedDevices(t *testing.T) {
// autogroup:self filtering only applies to autogroup:self destinations, not others.
func TestSSHWithAutogroupSelfAndMixedDestinations(t *testing.T) {
users := types.Users{
{ID: 1, Name: "user1"},
{ID: 2, Name: "user2"},
{Model: gorm.Model{ID: 1}, Name: "user1"},
{Model: gorm.Model{ID: 2}, Name: "user2"},
}
nodes := types.Nodes{
@@ -2246,9 +2246,9 @@ func TestSSHWithAutogroupSelfAndMixedDestinations(t *testing.T) {
// registered nodes.
func TestAutogroupSelfWithNonExistentUserInGroup(t *testing.T) {
users := types.Users{
{ID: 1, Name: "superadmin"},
{ID: 2, Name: "admin"},
{ID: 3, Name: "direction"},
{Model: gorm.Model{ID: 1}, Name: "superadmin"},
{Model: gorm.Model{ID: 2}, Name: "admin"},
{Model: gorm.Model{ID: 3}, Name: "direction"},
}
nodes := types.Nodes{
@@ -2600,7 +2600,7 @@ func TestMergeFilterRules(t *testing.T) {
func TestCompileSSHPolicy_CheckPeriodVariants(t *testing.T) {
users := types.Users{
{Name: "user1", ID: 1},
{Name: "user1", Model: gorm.Model{ID: 1}},
}
node := types.Node{
@@ -2755,8 +2755,8 @@ func TestIPSetToPrincipals(t *testing.T) {
func TestSSHCheckParams(t *testing.T) {
users := types.Users{
{Name: "user1", ID: 1},
{Name: "user2", ID: 2},
{Name: "user1", Model: gorm.Model{ID: 1}},
{Name: "user2", Model: gorm.Model{ID: 2}},
}
nodeUser1 := types.Node{
@@ -2944,14 +2944,14 @@ func TestResolveLocalparts(t *testing.T) {
{
name: "no entries",
entries: nil,
users: types.Users{{Name: "alice", Email: "alice@example.com", ID: 1}},
users: types.Users{{Name: "alice", Email: "alice@example.com", Model: gorm.Model{ID: 1}}},
want: nil,
},
{
name: "single match",
entries: []SSHUser{"localpart:*@example.com"},
users: types.Users{
{Name: "alice", Email: "alice@example.com", ID: 1},
{Name: "alice", Email: "alice@example.com", Model: gorm.Model{ID: 1}},
},
want: map[uint]string{1: "alice"},
},
@@ -2959,7 +2959,7 @@ func TestResolveLocalparts(t *testing.T) {
name: "domain mismatch",
entries: []SSHUser{"localpart:*@other.com"},
users: types.Users{
{Name: "alice", Email: "alice@example.com", ID: 1},
{Name: "alice", Email: "alice@example.com", Model: gorm.Model{ID: 1}},
},
want: map[uint]string{},
},
@@ -2967,7 +2967,7 @@ func TestResolveLocalparts(t *testing.T) {
name: "case insensitive domain",
entries: []SSHUser{"localpart:*@EXAMPLE.COM"},
users: types.Users{
{Name: "alice", Email: "alice@example.com", ID: 1},
{Name: "alice", Email: "alice@example.com", Model: gorm.Model{ID: 1}},
},
want: map[uint]string{1: "alice"},
},
@@ -2975,7 +2975,7 @@ func TestResolveLocalparts(t *testing.T) {
name: "user without email skipped",
entries: []SSHUser{"localpart:*@example.com"},
users: types.Users{
{Name: "cli-user", ID: 1},
{Name: "cli-user", Model: gorm.Model{ID: 1}},
},
want: map[uint]string{},
},
@@ -2986,9 +2986,9 @@ func TestResolveLocalparts(t *testing.T) {
"localpart:*@other.com",
},
users: types.Users{
{Name: "alice", Email: "alice@example.com", ID: 1},
{Name: "bob", Email: "bob@other.com", ID: 2},
{Name: "charlie", Email: "charlie@nope.com", ID: 3},
{Name: "alice", Email: "alice@example.com", Model: gorm.Model{ID: 1}},
{Name: "bob", Email: "bob@other.com", Model: gorm.Model{ID: 2}},
{Name: "charlie", Email: "charlie@nope.com", Model: gorm.Model{ID: 3}},
},
want: map[uint]string{1: "alice", 2: "bob"},
},
@@ -2996,7 +2996,7 @@ func TestResolveLocalparts(t *testing.T) {
name: "special chars in local part",
entries: []SSHUser{"localpart:*@example.com"},
users: types.Users{
{Name: "d", Email: "dave+ssh@example.com", ID: 1},
{Name: "d", Email: "dave+ssh@example.com", Model: gorm.Model{ID: 1}},
},
want: map[uint]string{1: "dave+ssh"},
},
@@ -3015,11 +3015,11 @@ func TestResolveLocalparts(t *testing.T) {
func TestGroupSourcesByUser(t *testing.T) {
alice := types.User{
Name: "alice", Email: "alice@example.com",
ID: 1,
Model: gorm.Model{ID: 1},
}
bob := types.User{
Name: "bob", Email: "bob@example.com",
ID: 2,
Model: gorm.Model{ID: 2},
}
nodeAlice := types.Node{
@@ -3167,7 +3167,7 @@ func TestCompanionCapGrantRules(t *testing.T) {
dstIPStrings: []string{"100.64.0.1"},
srcPrefixes: []netip.Prefix{mp("100.64.0.2/32")},
capMap: tailcfg.PeerCapMap{
peercap.Taildrive: {tailcfg.RawMessage(`{}`)},
tailcfg.PeerCapabilityTaildrive: {tailcfg.RawMessage(`{}`)},
},
want: []tailcfg.FilterRule{
{
@@ -3176,7 +3176,7 @@ func TestCompanionCapGrantRules(t *testing.T) {
{
Dsts: []netip.Prefix{mp("100.64.0.2/32")},
CapMap: tailcfg.PeerCapMap{
peercap.TaildriveSharer: nil,
tailcfg.PeerCapabilityTaildriveSharer: nil,
},
},
},
@@ -3188,7 +3188,7 @@ func TestCompanionCapGrantRules(t *testing.T) {
dstIPStrings: []string{"100.64.0.10"},
srcPrefixes: []netip.Prefix{mp("100.64.0.20/32")},
capMap: tailcfg.PeerCapMap{
peercap.Relay: {tailcfg.RawMessage(`{}`)},
tailcfg.PeerCapabilityRelay: {tailcfg.RawMessage(`{}`)},
},
want: []tailcfg.FilterRule{
{
@@ -3197,7 +3197,7 @@ func TestCompanionCapGrantRules(t *testing.T) {
{
Dsts: []netip.Prefix{mp("100.64.0.20/32")},
CapMap: tailcfg.PeerCapMap{
peercap.RelayTarget: nil,
tailcfg.PeerCapabilityRelayTarget: nil,
},
},
},
@@ -3209,8 +3209,8 @@ func TestCompanionCapGrantRules(t *testing.T) {
dstIPStrings: []string{"100.64.0.1"},
srcPrefixes: []netip.Prefix{mp("100.64.0.2/32")},
capMap: tailcfg.PeerCapMap{
peercap.Relay: {tailcfg.RawMessage(`{}`)},
peercap.Taildrive: {tailcfg.RawMessage(`{}`)},
tailcfg.PeerCapabilityRelay: {tailcfg.RawMessage(`{}`)},
tailcfg.PeerCapabilityTaildrive: {tailcfg.RawMessage(`{}`)},
},
want: []tailcfg.FilterRule{
{
@@ -3220,7 +3220,7 @@ func TestCompanionCapGrantRules(t *testing.T) {
{
Dsts: []netip.Prefix{mp("100.64.0.2/32")},
CapMap: tailcfg.PeerCapMap{
peercap.TaildriveSharer: nil,
tailcfg.PeerCapabilityTaildriveSharer: nil,
},
},
},
@@ -3231,7 +3231,7 @@ func TestCompanionCapGrantRules(t *testing.T) {
{
Dsts: []netip.Prefix{mp("100.64.0.2/32")},
CapMap: tailcfg.PeerCapMap{
peercap.RelayTarget: nil,
tailcfg.PeerCapabilityRelayTarget: nil,
},
},
},
@@ -3252,7 +3252,7 @@ func TestCompanionCapGrantRules(t *testing.T) {
dstIPStrings: []string{"100.64.0.5"},
srcPrefixes: []netip.Prefix{mp("100.64.0.6/32")},
capMap: tailcfg.PeerCapMap{
peercap.Taildrive: {
tailcfg.PeerCapabilityTaildrive: {
tailcfg.RawMessage(`{"access":"rw"}`),
},
},
@@ -3263,7 +3263,7 @@ func TestCompanionCapGrantRules(t *testing.T) {
{
Dsts: []netip.Prefix{mp("100.64.0.6/32")},
CapMap: tailcfg.PeerCapMap{
peercap.TaildriveSharer: nil,
tailcfg.PeerCapabilityTaildriveSharer: nil,
},
},
},
@@ -3281,7 +3281,7 @@ func TestCompanionCapGrantRules(t *testing.T) {
mp("100.64.0.21/32"),
},
capMap: tailcfg.PeerCapMap{
peercap.Relay: {tailcfg.RawMessage(`{}`)},
tailcfg.PeerCapabilityRelay: {tailcfg.RawMessage(`{}`)},
},
want: []tailcfg.FilterRule{
{
@@ -3293,7 +3293,7 @@ func TestCompanionCapGrantRules(t *testing.T) {
mp("100.64.0.21/32"),
},
CapMap: tailcfg.PeerCapMap{
peercap.RelayTarget: nil,
tailcfg.PeerCapabilityRelayTarget: nil,
},
},
},
@@ -3481,7 +3481,7 @@ func TestFilterAllowAllFix(t *testing.T) {
t.Parallel()
users := types.Users{
{ID: 1, Name: "testuser"},
{Model: gorm.Model{ID: 1}, Name: "testuser"},
}
nodes := types.Nodes{
&types.Node{
@@ -3586,7 +3586,7 @@ func TestCompileViaGrant(t *testing.T) {
t.Parallel()
users := types.Users{
{ID: 1, Name: "testuser"},
{Model: gorm.Model{ID: 1}, Name: "testuser"},
}
allPorts := []ProtocolPort{
@@ -3806,7 +3806,7 @@ func TestCompileViaGrant(t *testing.T) {
Sources: Aliases{up("testuser@")},
Destinations: Aliases{pp("10.0.0.0/24")},
App: tailcfg.PeerCapMap{
peercap.Relay: {tailcfg.RawMessage(`{}`)},
tailcfg.PeerCapabilityRelay: {tailcfg.RawMessage(`{}`)},
},
Via: []Tag{"tag:relay"},
},
@@ -3882,8 +3882,8 @@ func TestCompileGrantWithAutogroupSelf_GrantPaths(t *testing.T) {
t.Parallel()
users := types.Users{
{ID: 1, Name: "user1"},
{ID: 2, Name: "user2"},
{Model: gorm.Model{ID: 1}, Name: "user1"},
{Model: gorm.Model{ID: 2}, Name: "user2"},
}
node1 := &types.Node{
@@ -4089,7 +4089,7 @@ func TestDestinationsToNetPortRange_AutogroupInternet(t *testing.T) {
t.Parallel()
users := types.Users{
{ID: 1, Name: "testuser"},
{Model: gorm.Model{ID: 1}, Name: "testuser"},
}
nodes := types.Nodes{
&types.Node{
+1 -1
View File
@@ -148,7 +148,7 @@ func expectedExitPeerVisibility(
var seen []string
for _, peer := range capture.Netmap.Peers {
peerName, _, _ := strings.Cut(peer.Name(), ".")
peerName := strings.Split(peer.Name(), ".")[0]
if !exitAdvertisers[peerName] {
continue
+3 -2
View File
@@ -12,6 +12,7 @@ import (
"github.com/juanfont/headscale/hscontrol/types"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"tailscale.com/net/tsaddr"
"tailscale.com/tailcfg"
)
@@ -24,8 +25,8 @@ func TestIssue3233ViaInternetExitVisibility(t *testing.T) {
t.Parallel()
users := types.Users{
{ID: 1, Name: "alice", Email: "alice@headscale.net"},
{ID: 2, Name: "bob", Email: "bob@headscale.net"},
{Model: gorm.Model{ID: 1}, Name: "alice", Email: "alice@headscale.net"},
{Model: gorm.Model{ID: 2}, Name: "bob", Email: "bob@headscale.net"},
}
exitRoutes := []netip.Prefix{tsaddr.AllIPv4(), tsaddr.AllIPv6()}
+2 -1
View File
@@ -7,6 +7,7 @@ import (
"github.com/juanfont/headscale/hscontrol/types"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"tailscale.com/tailcfg"
)
@@ -26,7 +27,7 @@ func TestIssue3267ViaGrantBroaderDestination(t *testing.T) {
t.Parallel()
users := types.Users{
{ID: 1, Name: "alice", Email: issue3267AliceEmail}, //nolint:goconst
{Model: gorm.Model{ID: 1}, Name: "alice", Email: issue3267AliceEmail}, //nolint:goconst
}
cases := []struct {
+2 -1
View File
@@ -6,6 +6,7 @@ import (
"github.com/juanfont/headscale/hscontrol/types"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"tailscale.com/tailcfg"
)
@@ -18,7 +19,7 @@ import (
// as not needing a recompute.
func TestNodeNeedsPeerRecompute(t *testing.T) {
users := types.Users{
{ID: 1, Name: "user1", Email: "user1@headscale.net"},
{Model: gorm.Model{ID: 1}, Name: "user1", Email: "user1@headscale.net"},
}
const allowAll = `{"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}`
+34 -34
View File
@@ -10,8 +10,8 @@ import (
"github.com/juanfont/headscale/hscontrol/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"tailscale.com/tailcfg"
"tailscale.com/tailcfg/nodecap"
)
// nodeAttrsTestUsers returns a minimal user set: two passkey-style users on
@@ -19,8 +19,8 @@ import (
// resolution is exercised across both.
func nodeAttrsTestUsers() types.Users {
return types.Users{
{ID: 1, Name: "alice", Email: "alice@example.com"},
{ID: 2, Name: "bob", Email: "bob@example.org"},
{Model: gorm.Model{ID: 1}, Name: "alice", Email: "alice@example.com"},
{Model: gorm.Model{ID: 2}, Name: "bob", Email: "bob@example.org"},
}
}
@@ -82,7 +82,7 @@ const nodeAttrsTagOwners = `"tag:server": ["alice@example.com"],
func TestNodeAttrsCompile(t *testing.T) {
t.Parallel()
capMap := func(c nodecap.Cap) tailcfg.NodeCapMap {
capMap := func(c tailcfg.NodeCapability) tailcfg.NodeCapMap {
return tailcfg.NodeCapMap{c: nil}
}
@@ -96,18 +96,18 @@ func TestNodeAttrsCompile(t *testing.T) {
name: "wildcard target hits every node",
extra: `"nodeAttrs": [{"target": ["*"], "attr": ["randomize-client-port"]}]`,
want: map[types.NodeID]tailcfg.NodeCapMap{
1: capMap(nodecap.RandomizeClientPort),
2: capMap(nodecap.RandomizeClientPort),
3: capMap(nodecap.RandomizeClientPort),
4: capMap(nodecap.RandomizeClientPort),
5: capMap(nodecap.RandomizeClientPort),
1: capMap(tailcfg.NodeAttrRandomizeClientPort),
2: capMap(tailcfg.NodeAttrRandomizeClientPort),
3: capMap(tailcfg.NodeAttrRandomizeClientPort),
4: capMap(tailcfg.NodeAttrRandomizeClientPort),
5: capMap(tailcfg.NodeAttrRandomizeClientPort),
},
},
{
name: "user target hits only that user's untagged nodes",
extra: `"nodeAttrs": [{"target": ["alice@example.com"], "attr": ["randomize-client-port"]}]`,
want: map[types.NodeID]tailcfg.NodeCapMap{
1: capMap(nodecap.RandomizeClientPort),
1: capMap(tailcfg.NodeAttrRandomizeClientPort),
},
},
{
@@ -115,8 +115,8 @@ func TestNodeAttrsCompile(t *testing.T) {
extra: `"nodeAttrs": [{"target": ["tag:server"], "attr": ["drive:share", "drive:access"]}]`,
want: map[types.NodeID]tailcfg.NodeCapMap{
3: {
nodecap.TaildriveShare: nil,
nodecap.TaildriveAccess: nil,
tailcfg.NodeAttrsTaildriveShare: nil,
tailcfg.NodeAttrsTaildriveAccess: nil,
},
},
},
@@ -124,17 +124,17 @@ func TestNodeAttrsCompile(t *testing.T) {
name: "autogroup:member hits untagged nodes only",
extra: `"nodeAttrs": [{"target": ["autogroup:member"], "attr": ["randomize-client-port"]}]`,
want: map[types.NodeID]tailcfg.NodeCapMap{
1: capMap(nodecap.RandomizeClientPort),
2: capMap(nodecap.RandomizeClientPort),
1: capMap(tailcfg.NodeAttrRandomizeClientPort),
2: capMap(tailcfg.NodeAttrRandomizeClientPort),
},
},
{
name: "autogroup:tagged hits tagged nodes only",
extra: `"nodeAttrs": [{"target": ["autogroup:tagged"], "attr": ["disable-captive-portal-detection"]}]`,
want: map[types.NodeID]tailcfg.NodeCapMap{
3: capMap(nodecap.DisableCaptivePortalDetection),
4: capMap(nodecap.DisableCaptivePortalDetection),
5: capMap(nodecap.DisableCaptivePortalDetection),
3: capMap(tailcfg.NodeAttrDisableCaptivePortalDetection),
4: capMap(tailcfg.NodeAttrDisableCaptivePortalDetection),
5: capMap(tailcfg.NodeAttrDisableCaptivePortalDetection),
},
},
{
@@ -144,14 +144,14 @@ func TestNodeAttrsCompile(t *testing.T) {
{"target": ["tag:server"], "attr": ["drive:share"]}
]`,
want: map[types.NodeID]tailcfg.NodeCapMap{
1: capMap(nodecap.TaildriveAccess),
2: capMap(nodecap.TaildriveAccess),
1: capMap(tailcfg.NodeAttrsTaildriveAccess),
2: capMap(tailcfg.NodeAttrsTaildriveAccess),
3: {
nodecap.TaildriveAccess: nil,
nodecap.TaildriveShare: nil,
tailcfg.NodeAttrsTaildriveAccess: nil,
tailcfg.NodeAttrsTaildriveShare: nil,
},
4: capMap(nodecap.TaildriveAccess),
5: capMap(nodecap.TaildriveAccess),
4: capMap(tailcfg.NodeAttrsTaildriveAccess),
5: capMap(tailcfg.NodeAttrsTaildriveAccess),
},
},
{
@@ -163,11 +163,11 @@ func TestNodeAttrsCompile(t *testing.T) {
name: "top-level randomizeClientPort stamps every node",
extra: `"randomizeClientPort": true`,
want: map[types.NodeID]tailcfg.NodeCapMap{
1: capMap(nodecap.RandomizeClientPort),
2: capMap(nodecap.RandomizeClientPort),
3: capMap(nodecap.RandomizeClientPort),
4: capMap(nodecap.RandomizeClientPort),
5: capMap(nodecap.RandomizeClientPort),
1: capMap(tailcfg.NodeAttrRandomizeClientPort),
2: capMap(tailcfg.NodeAttrRandomizeClientPort),
3: capMap(tailcfg.NodeAttrRandomizeClientPort),
4: capMap(tailcfg.NodeAttrRandomizeClientPort),
5: capMap(tailcfg.NodeAttrRandomizeClientPort),
},
},
{
@@ -175,14 +175,14 @@ func TestNodeAttrsCompile(t *testing.T) {
extra: `"randomizeClientPort": true,
"nodeAttrs": [{"target": ["tag:server"], "attr": ["disable-captive-portal-detection"]}]`,
want: map[types.NodeID]tailcfg.NodeCapMap{
1: capMap(nodecap.RandomizeClientPort),
2: capMap(nodecap.RandomizeClientPort),
1: capMap(tailcfg.NodeAttrRandomizeClientPort),
2: capMap(tailcfg.NodeAttrRandomizeClientPort),
3: {
nodecap.RandomizeClientPort: nil,
nodecap.DisableCaptivePortalDetection: nil,
tailcfg.NodeAttrRandomizeClientPort: nil,
tailcfg.NodeAttrDisableCaptivePortalDetection: nil,
},
4: capMap(nodecap.RandomizeClientPort),
5: capMap(nodecap.RandomizeClientPort),
4: capMap(tailcfg.NodeAttrRandomizeClientPort),
5: capMap(tailcfg.NodeAttrRandomizeClientPort),
},
},
}
+66 -107
View File
@@ -15,7 +15,6 @@ import (
"github.com/juanfont/headscale/hscontrol/policy/matcher"
"github.com/juanfont/headscale/hscontrol/policy/policyutil"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/puzpuzpuz/xsync/v4"
"github.com/rs/zerolog/log"
"go4.org/netipx"
"tailscale.com/net/tsaddr"
@@ -29,10 +28,7 @@ import (
var ErrInvalidTagOwner = errors.New("tag owner is not an Alias")
type PolicyManager struct {
// RWMutex, not Mutex, so concurrent map generation does not serialise on
// reads. The per-node caches are xsync.Maps so a read can fill them without
// taking the write lock.
mu sync.RWMutex
mu sync.Mutex
pol *Policy
users []types.User
nodes views.Slice[types.NodeView]
@@ -59,7 +55,7 @@ type PolicyManager struct {
viaTargetTags map[Tag]struct{}
// Lazy map of SSH policies
sshPolicyMap *xsync.Map[types.NodeID, *tailcfg.SSHPolicy]
sshPolicyMap map[types.NodeID]*tailcfg.SSHPolicy
// compiledGrants are the grants with sources pre-resolved.
// The single source of truth for filter compilation. Both
@@ -68,12 +64,12 @@ type PolicyManager struct {
userNodeIdx userNodeIndex
// Lazy map of per-node filter rules (reduced, for packet filters)
filterRulesMap *xsync.Map[types.NodeID, []tailcfg.FilterRule]
filterRulesMap map[types.NodeID][]tailcfg.FilterRule
// Lazy map of per-node matchers derived from UNREDUCED filter
// rules. Only populated on the slow path when needsPerNodeFilter
// is true; the fast path returns pm.matchers directly.
matchersForNodeMap *xsync.Map[types.NodeID, []matcher.Match]
matchersForNodeMap map[types.NodeID][]matcher.Match
// needsPerNodeFilter is true when any compiled grant requires
// per-node work (autogroup:self or via grants).
@@ -201,9 +197,9 @@ func NewPolicyManager(b []byte, users []types.User, nodes views.Slice[types.Node
pol: policy,
users: users,
nodes: nodes,
sshPolicyMap: xsync.NewMap[types.NodeID, *tailcfg.SSHPolicy](),
filterRulesMap: xsync.NewMap[types.NodeID, []tailcfg.FilterRule](),
matchersForNodeMap: xsync.NewMap[types.NodeID, []matcher.Match](),
sshPolicyMap: make(map[types.NodeID]*tailcfg.SSHPolicy, nodes.Len()),
filterRulesMap: make(map[types.NodeID][]tailcfg.FilterRule, nodes.Len()),
matchersForNodeMap: make(map[types.NodeID][]matcher.Match, nodes.Len()),
}
_, err = pm.updateLocked()
@@ -358,9 +354,9 @@ func (pm *PolicyManager) updateLocked() (bool, error) {
// TODO(kradalby): This could potentially be optimized by only clearing the
// policies for nodes that have changed. Particularly if the only difference is
// that nodes has been added or removed.
pm.sshPolicyMap.Clear()
pm.filterRulesMap.Clear()
pm.matchersForNodeMap.Clear()
clear(pm.sshPolicyMap)
clear(pm.filterRulesMap)
clear(pm.matchersForNodeMap)
}
// If nothing changed, no need to update nodes
@@ -404,8 +400,8 @@ func (pm *PolicyManager) NodeNeedsPeerRecompute(node types.NodeView) bool {
return true
}
pm.mu.RLock()
defer pm.mu.RUnlock()
pm.mu.Lock()
defer pm.mu.Unlock()
if pm.relayTargetIPs != nil && node.InIPSet(pm.relayTargetIPs) {
return true
@@ -426,10 +422,10 @@ func (pm *PolicyManager) NodeNeedsPeerRecompute(node types.NodeView) bool {
// /machine/ssh/action/{src}/to/{dst}?local_user={local_user} per the
// SaaS wire format. Cache is invalidated on policy reload.
func (pm *PolicyManager) SSHPolicy(baseURL string, node types.NodeView) (*tailcfg.SSHPolicy, error) {
pm.mu.RLock()
defer pm.mu.RUnlock()
pm.mu.Lock()
defer pm.mu.Unlock()
if sshPol, ok := pm.sshPolicyMap.Load(node.ID()); ok {
if sshPol, ok := pm.sshPolicyMap[node.ID()]; ok {
return sshPol, nil
}
@@ -438,7 +434,7 @@ func (pm *PolicyManager) SSHPolicy(baseURL string, node types.NodeView) (*tailcf
return nil, fmt.Errorf("compiling SSH policy: %w", err)
}
pm.sshPolicyMap.Store(node.ID(), sshPol)
pm.sshPolicyMap[node.ID()] = sshPol
return sshPol, nil
}
@@ -454,8 +450,8 @@ func (pm *PolicyManager) SSHPolicy(baseURL string, node types.NodeView) (*tailcf
func (pm *PolicyManager) SSHCheckParams(
srcNodeID, dstNodeID types.NodeID,
) (time.Duration, bool) {
pm.mu.RLock()
defer pm.mu.RUnlock()
pm.mu.Lock()
defer pm.mu.Unlock()
if pm.pol == nil || len(pm.pol.SSHs) == 0 {
return 0, false
@@ -588,8 +584,8 @@ func (pm *PolicyManager) Filter() ([]tailcfg.FilterRule, []matcher.Match) {
return nil, nil
}
pm.mu.RLock()
defer pm.mu.RUnlock()
pm.mu.Lock()
defer pm.mu.Unlock()
return pm.filter, pm.matchers
}
@@ -608,8 +604,8 @@ func (pm *PolicyManager) BuildPeerMap(nodes views.Slice[types.NodeView]) map[typ
return nil
}
pm.mu.RLock()
defer pm.mu.RUnlock()
pm.mu.Lock()
defer pm.mu.Unlock()
// Precompute each node's subnet routes and exit-node status once; the
// O(n^2) pair scans below would otherwise recompute them for every pair.
@@ -730,7 +726,7 @@ func (pm *PolicyManager) filterForNodeLocked(
return nil
}
if rules, ok := pm.filterRulesMap.Load(node.ID()); ok {
if rules, ok := pm.filterRulesMap[node.ID()]; ok {
return rules
}
@@ -742,7 +738,7 @@ func (pm *PolicyManager) filterForNodeLocked(
}
reduced := policyutil.ReduceFilterRules(node, unreduced)
pm.filterRulesMap.Store(node.ID(), reduced)
pm.filterRulesMap[node.ID()] = reduced
return reduced
}
@@ -759,8 +755,8 @@ func (pm *PolicyManager) FilterForNode(node types.NodeView) ([]tailcfg.FilterRul
return nil, nil
}
pm.mu.RLock()
defer pm.mu.RUnlock()
pm.mu.Lock()
defer pm.mu.Unlock()
return pm.filterForNodeLocked(node), nil
}
@@ -780,8 +776,8 @@ func (pm *PolicyManager) MatchersForNode(node types.NodeView) ([]matcher.Match,
return nil, nil
}
pm.mu.RLock()
defer pm.mu.RUnlock()
pm.mu.Lock()
defer pm.mu.Unlock()
// For global policies, return the shared global matchers.
// Via grants require per-node matchers because the global matchers
@@ -790,7 +786,7 @@ func (pm *PolicyManager) MatchersForNode(node types.NodeView) ([]matcher.Match,
return pm.matchers, nil
}
if cached, ok := pm.matchersForNodeMap.Load(node.ID()); ok {
if cached, ok := pm.matchersForNodeMap[node.ID()]; ok {
return cached, nil
}
@@ -798,7 +794,7 @@ func (pm *PolicyManager) MatchersForNode(node types.NodeView) ([]matcher.Match,
// the stored compiled grants for this specific node.
unreduced := pm.filterRulesForNodeLocked(node)
matchers := matcher.MatchesFromFilterRules(unreduced)
pm.matchersForNodeMap.Store(node.ID(), matchers)
pm.matchersForNodeMap[node.ID()] = matchers
return matchers, nil
}
@@ -817,7 +813,7 @@ func (pm *PolicyManager) SetUsers(users []types.User) (bool, error) {
// Clear SSH policy map when users change to force SSH policy recomputation
// This ensures that if SSH policy compilation previously failed due to missing users,
// it will be retried with the new user list
pm.sshPolicyMap.Clear()
clear(pm.sshPolicyMap)
changed, err := pm.updateLocked()
if err != nil {
@@ -870,9 +866,9 @@ func (pm *PolicyManager) SetNodes(nodes views.Slice[types.NodeView]) (bool, erro
if !needsUpdate {
// This ensures fresh filter rules are generated for all nodes
pm.sshPolicyMap.Clear()
pm.filterRulesMap.Clear()
pm.matchersForNodeMap.Clear()
clear(pm.sshPolicyMap)
clear(pm.filterRulesMap)
clear(pm.matchersForNodeMap)
}
// Always return true when nodes changed, even if filter hash didn't change
// (can happen with autogroup:self or when nodes are added but don't affect rules)
@@ -933,8 +929,8 @@ func (pm *PolicyManager) NodeCanHaveTag(node types.NodeView, tag string) bool {
return false
}
pm.mu.RLock()
defer pm.mu.RUnlock()
pm.mu.Lock()
defer pm.mu.Unlock()
// pm.pol is written by SetPolicy under pm.mu; reading it before the
// lock races with concurrent policy reloads.
@@ -972,37 +968,6 @@ func (pm *PolicyManager) NodeCanHaveTag(node types.NodeView, tag string) bool {
return false
}
// UserCanHaveTag reports whether the given user is one of the tag's owners
// (directly or via a group). It is the user half of [PolicyManager.NodeCanHaveTag]:
// re-authentication authorises requested tags against the authenticating user,
// because a tag-owned node carries no user and its IP is not in any owner set,
// so only the user presenting the credential can prove ownership.
func (pm *PolicyManager) UserCanHaveTag(user types.UserView, tag string) bool {
if pm == nil || !user.Valid() {
return false
}
pm.mu.RLock()
defer pm.mu.RUnlock()
if pm.pol == nil {
return false
}
owners, exists := pm.pol.TagOwners[Tag(tag)]
if !exists {
return false
}
for _, owner := range owners {
if pm.userMatchesOwner(user, owner) {
return true
}
}
return false
}
// TagOwnedByTags reports whether a credential holding ownerTags is authorised to
// apply tag. It is true when tag is one of ownerTags, or when tag's tagOwners
// chain (tag-to-tag ownership) transitively includes one of ownerTags. This is
@@ -1026,8 +991,8 @@ func (pm *PolicyManager) TagOwnedByTags(tag string, ownerTags []string) bool {
return true
}
pm.mu.RLock()
defer pm.mu.RUnlock()
pm.mu.Lock()
defer pm.mu.Unlock()
// Owned-by delegation requires the policy's tagOwners.
if pm.pol == nil {
@@ -1113,8 +1078,8 @@ func (pm *PolicyManager) TagExists(tag string) bool {
return false
}
pm.mu.RLock()
defer pm.mu.RUnlock()
pm.mu.Lock()
defer pm.mu.Unlock()
// pm.pol is written by SetPolicy under pm.mu; reading it before the
// lock races with concurrent policy reloads.
@@ -1132,8 +1097,8 @@ func (pm *PolicyManager) NodeCanApproveRoute(node types.NodeView, route netip.Pr
return false
}
pm.mu.RLock()
defer pm.mu.RUnlock()
pm.mu.Lock()
defer pm.mu.Unlock()
// If the route to-be-approved is an exit route, then we need to check
// if the node is in allowed to approve it. This is treated differently
@@ -1195,8 +1160,8 @@ func (pm *PolicyManager) ViaRoutesForPeer(viewer, peer types.NodeView) types.Via
return result
}
pm.mu.RLock()
defer pm.mu.RUnlock()
pm.mu.Lock()
defer pm.mu.Unlock()
// pm.pol is written by SetPolicy under pm.mu; reading it before the
// lock races with concurrent policy reloads.
@@ -1414,8 +1379,8 @@ func (pm *PolicyManager) DebugString() string {
// pm.pol, filter, matchers, and the derived maps are all written
// under pm.mu by SetPolicy/SetUsers/SetNodes.
pm.mu.RLock()
defer pm.mu.RUnlock()
pm.mu.Lock()
defer pm.mu.Unlock()
var sb strings.Builder
@@ -1560,7 +1525,7 @@ func (pm *PolicyManager) invalidateAutogroupSelfCache(oldNodes, newNodes views.S
// Clear cache entries for affected users only.
// For autogroup:self, we need to clear all nodes belonging to affected users
// because autogroup:self rules depend on the entire user's device set.
pm.filterRulesMap.Range(func(nodeID types.NodeID, _ []tailcfg.FilterRule) bool {
for nodeID := range pm.filterRulesMap {
// Find the user for this cached node using the already-built indexes.
node, ok := newNodeMap[nodeID]
if !ok {
@@ -1569,10 +1534,10 @@ func (pm *PolicyManager) invalidateAutogroupSelfCache(oldNodes, newNodes views.S
// Node not found in either old or new list, clear it.
if !ok {
pm.filterRulesMap.Delete(nodeID)
pm.matchersForNodeMap.Delete(nodeID)
delete(pm.filterRulesMap, nodeID)
delete(pm.matchersForNodeMap, nodeID)
return true
continue
}
// Tagged nodes don't participate in autogroup:self, so their cache
@@ -1584,17 +1549,15 @@ func (pm *PolicyManager) invalidateAutogroupSelfCache(oldNodes, newNodes views.S
// If the owning user is affected, clear this cache entry.
if _, affected := affectedUsers[nodeUserID]; affected {
pm.filterRulesMap.Delete(nodeID)
pm.matchersForNodeMap.Delete(nodeID)
delete(pm.filterRulesMap, nodeID)
delete(pm.matchersForNodeMap, nodeID)
}
return true
})
}
if len(affectedUsers) > 0 {
log.Debug().
Int("affected_users", len(affectedUsers)).
Int("remaining_cache_entries", pm.filterRulesMap.Size()).
Int("remaining_cache_entries", len(pm.filterRulesMap)).
Msg("Selectively cleared autogroup:self cache for affected users")
}
}
@@ -1628,27 +1591,23 @@ func (pm *PolicyManager) invalidateGlobalPolicyCache(newNodes views.Slice[types.
}
if newNode.HasNetworkChanges(oldNode) {
pm.filterRulesMap.Delete(nodeID)
pm.matchersForNodeMap.Delete(nodeID)
delete(pm.filterRulesMap, nodeID)
delete(pm.matchersForNodeMap, nodeID)
}
}
// Remove deleted nodes from cache
pm.filterRulesMap.Range(func(nodeID types.NodeID, _ []tailcfg.FilterRule) bool {
for nodeID := range pm.filterRulesMap {
if _, exists := newNodeMap[nodeID]; !exists {
pm.filterRulesMap.Delete(nodeID)
delete(pm.filterRulesMap, nodeID)
}
}
return true
})
pm.matchersForNodeMap.Range(func(nodeID types.NodeID, _ []matcher.Match) bool {
for nodeID := range pm.matchersForNodeMap {
if _, exists := newNodeMap[nodeID]; !exists {
pm.matchersForNodeMap.Delete(nodeID)
delete(pm.matchersForNodeMap, nodeID)
}
return true
})
}
}
// flattenTags resolves nested tag-owner references. Cycles
@@ -1830,8 +1789,8 @@ func (pm *PolicyManager) NodeCapMap(id types.NodeID) tailcfg.NodeCapMap {
return nil
}
pm.mu.RLock()
defer pm.mu.RUnlock()
pm.mu.Lock()
defer pm.mu.Unlock()
src := pm.nodeAttrsMap[id]
if len(src) == 0 {
@@ -1854,8 +1813,8 @@ func (pm *PolicyManager) NodeCapMaps() map[types.NodeID]tailcfg.NodeCapMap {
return nil
}
pm.mu.RLock()
defer pm.mu.RUnlock()
pm.mu.Lock()
defer pm.mu.Unlock()
out := make(map[types.NodeID]tailcfg.NodeCapMap, len(pm.nodeAttrsMap))
maps.Copy(out, pm.nodeAttrsMap)
@@ -1,102 +0,0 @@
package v2
import (
"fmt"
"sync"
"testing"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestPolicyManagerConcurrentReads is the correctness guard for the #3346 fix:
// PolicyManager read methods take a shared RLock and populate their per-node
// caches (filterRulesMap, matchersForNodeMap) concurrently. This test hammers
// those reads from many goroutines while a writer mutates the node set, so the
// race detector catches any unsafe access to the shared caches or policy state.
//
// It uses an autogroup:self policy so reads take the per-node filter slow path
// — the same path that made #3346's reconnect storm expensive — which is where
// the lazy caches are written.
func TestPolicyManagerConcurrentReads(t *testing.T) {
users := types.Users{
{ID: 1, Name: "user1", Email: "user1@headscale.net"},
{ID: 2, Name: "user2", Email: "user2@headscale.net"},
{ID: 3, Name: "user3", Email: "user3@headscale.net"},
}
policy := `{
"acls": [
{
"action": "accept",
"src": ["autogroup:member"],
"dst": ["autogroup:self:*"]
}
]
}`
const nodeCount = 60
nodes := make(types.Nodes, 0, nodeCount)
for i := range nodeCount {
n := node(
fmt.Sprintf("node%d", i),
fmt.Sprintf("100.64.0.%d", i+1),
fmt.Sprintf("fd7a:115c:a1e0::%d", i+1),
users[i%len(users)],
)
n.ID = types.NodeID(i + 1) //nolint:gosec // safe in test
nodes = append(nodes, n)
}
pm, err := NewPolicyManager([]byte(policy), users, nodes.ViewSlice())
require.NoError(t, err)
const (
readers = 16
iterations = 60
mutatorReloads = 30
)
var wg sync.WaitGroup
// Concurrent readers exercise every converted RLock read path, including
// the two lazily populated per-node caches. Assertions inside the
// goroutines use assert (not require) so a failure does not call
// t.FailNow from a non-test goroutine.
for r := range readers {
wg.Go(func() {
for i := range iterations {
nv := nodes[(r+i)%len(nodes)].View()
rules, err := pm.FilterForNode(nv)
assert.NoError(t, err) //nolint:testifylint // assert (not require) is correct off the test goroutine
assert.NotNil(t, rules)
_, err = pm.MatchersForNode(nv)
assert.NoError(t, err) //nolint:testifylint // assert (not require) is correct off the test goroutine
pm.Filter()
pm.NodeCapMap(nv.ID())
// BuildPeerMap is the O(n^2) writer-side read; exercise it
// under RLock too, but not every iteration.
if i%8 == 0 {
assert.NotNil(t, pm.BuildPeerMap(nodes.ViewSlice()))
}
}
})
}
// A writer repeatedly re-sets the node set, invalidating and racing the
// caches the readers are populating.
wg.Go(func() {
for range mutatorReloads {
_, err := pm.SetNodes(nodes.ViewSlice())
assert.NoError(t, err) //nolint:testifylint // assert (not require) is correct off the test goroutine
}
})
wg.Wait()
}
+50 -55
View File
@@ -8,8 +8,8 @@ import (
"github.com/google/go-cmp/cmp"
"github.com/juanfont/headscale/hscontrol/policy/matcher"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/puzpuzpuz/xsync/v4"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"tailscale.com/net/tsaddr"
"tailscale.com/tailcfg"
)
@@ -27,8 +27,8 @@ func node(name, ipv4, ipv6 string, user types.User) *types.Node {
func TestPolicyManager(t *testing.T) {
users := types.Users{
{ID: 1, Name: "testuser", Email: "testuser@headscale.net"},
{ID: 2, Name: "otheruser", Email: "otheruser@headscale.net"},
{Model: gorm.Model{ID: 1}, Name: "testuser", Email: "testuser@headscale.net"},
{Model: gorm.Model{ID: 2}, Name: "otheruser", Email: "otheruser@headscale.net"},
}
tests := []struct {
@@ -86,9 +86,9 @@ func TestPolicyManager(t *testing.T) {
func TestInvalidateAutogroupSelfCache(t *testing.T) {
users := types.Users{
{ID: 1, Name: "user1", Email: "user1@headscale.net"},
{ID: 2, Name: "user2", Email: "user2@headscale.net"},
{ID: 3, Name: "user3", Email: "user3@headscale.net"},
{Model: gorm.Model{ID: 1}, Name: "user1", Email: "user1@headscale.net"},
{Model: gorm.Model{ID: 2}, Name: "user2", Email: "user2@headscale.net"},
{Model: gorm.Model{ID: 3}, Name: "user3", Email: "user3@headscale.net"},
}
//nolint:goconst // test-specific inline policy for clarity
@@ -122,7 +122,7 @@ func TestInvalidateAutogroupSelfCache(t *testing.T) {
require.NoError(t, err)
}
require.Equal(t, len(initialNodes), pm.filterRulesMap.Size())
require.Len(t, pm.filterRulesMap, len(initialNodes))
tests := []struct {
name string
@@ -207,20 +207,19 @@ func TestInvalidateAutogroupSelfCache(t *testing.T) {
}
}
pm.filterRulesMap.Clear()
pm.filterRulesMap = make(map[types.NodeID][]tailcfg.FilterRule)
for _, n := range initialNodes {
_, err := pm.FilterForNode(n.View())
require.NoError(t, err)
}
initialCacheSize := pm.filterRulesMap.Size()
initialCacheSize := len(pm.filterRulesMap)
require.Equal(t, len(initialNodes), initialCacheSize)
pm.invalidateAutogroupSelfCache(initialNodes.ViewSlice(), tt.newNodes.ViewSlice())
// Verify the expected number of cache entries were cleared
finalCacheSize := pm.filterRulesMap.Size()
finalCacheSize := len(pm.filterRulesMap)
clearedEntries := initialCacheSize - finalCacheSize
require.Equal(t, tt.expectedCleared, clearedEntries, tt.description)
})
@@ -236,8 +235,8 @@ func TestInvalidateAutogroupSelfCache(t *testing.T) {
// owning user from UserID, not from the User view.
func TestSetNodesAutogroupSelfUnhydratedUser(t *testing.T) {
users := types.Users{
{ID: 1, Name: "user1", Email: "user1@headscale.net"},
{ID: 2, Name: "user2", Email: "user2@headscale.net"},
{Model: gorm.Model{ID: 1}, Name: "user1", Email: "user1@headscale.net"},
{Model: gorm.Model{ID: 2}, Name: "user2", Email: "user2@headscale.net"},
}
policy := `{
@@ -304,7 +303,7 @@ func TestSetNodesAutogroupSelfUnhydratedUser(t *testing.T) {
// autogroup:self destination is active.
func TestSSHCheckParamsUnhydratedUserNoPanic(t *testing.T) {
users := types.Users{
{ID: 1, Name: "user1", Email: "user1@headscale.net"},
{Model: gorm.Model{ID: 1}, Name: "user1", Email: "user1@headscale.net"},
}
policy := `{
@@ -499,19 +498,15 @@ func TestInvalidateGlobalPolicyCache(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pm := &PolicyManager{
nodes: tt.oldNodes.ViewSlice(),
filterRulesMap: xsync.NewMap[types.NodeID, []tailcfg.FilterRule](),
matchersForNodeMap: xsync.NewMap[types.NodeID, []matcher.Match](),
}
for id, rules := range tt.initialCache {
pm.filterRulesMap.Store(id, rules)
nodes: tt.oldNodes.ViewSlice(),
filterRulesMap: tt.initialCache,
}
pm.invalidateGlobalPolicyCache(tt.newNodes.ViewSlice())
// Verify cache state
for nodeID, shouldExist := range tt.expectedCacheAfter {
_, exists := pm.filterRulesMap.Load(nodeID)
_, exists := pm.filterRulesMap[nodeID]
require.Equal(t, shouldExist, exists, "node %d cache existence mismatch", nodeID)
}
})
@@ -522,8 +517,8 @@ func TestInvalidateGlobalPolicyCache(t *testing.T) {
// 1. BuildPeerMap uses unreduced compiled rules for determining peer relationships
// 2. FilterForNode returns reduced compiled rules for packet filters.
func TestAutogroupSelfReducedVsUnreducedRules(t *testing.T) {
user1 := types.User{ID: 1, Name: "user1", Email: "user1@headscale.net"}
user2 := types.User{ID: 2, Name: "user2", Email: "user2@headscale.net"}
user1 := types.User{Model: gorm.Model{ID: 1}, Name: "user1", Email: "user1@headscale.net"}
user2 := types.User{Model: gorm.Model{ID: 2}, Name: "user2", Email: "user2@headscale.net"}
users := types.Users{user1, user2}
// Create two nodes
@@ -599,8 +594,8 @@ func TestAutogroupSelfReducedVsUnreducedRules(t *testing.T) {
// This ensures that autogroup:self doesn't interfere with other ACL rules.
func TestAutogroupSelfWithOtherRules(t *testing.T) {
users := types.Users{
{ID: 1, Name: "test-1", Email: "test-1@example.com"},
{ID: 2, Name: "test-2", Email: "test-2@example.com"},
{Model: gorm.Model{ID: 1}, Name: "test-1", Email: "test-1@example.com"},
{Model: gorm.Model{ID: 2}, Name: "test-2", Email: "test-2@example.com"},
}
// test-1 has a regular device
@@ -680,8 +675,8 @@ func TestAutogroupSelfWithOtherRules(t *testing.T) {
// leaving nodes with stale filter rules until reconnect.
func TestAutogroupSelfPolicyUpdateTriggersMapResponse(t *testing.T) {
users := types.Users{
{ID: 1, Name: "test-1", Email: "test-1@example.com"},
{ID: 2, Name: "test-2", Email: "test-2@example.com"},
{Model: gorm.Model{ID: 1}, Name: "test-1", Email: "test-1@example.com"},
{Model: gorm.Model{ID: 2}, Name: "test-2", Email: "test-2@example.com"},
}
test1Node := &types.Node{
@@ -764,8 +759,8 @@ func TestAutogroupSelfPolicyUpdateTriggersMapResponse(t *testing.T) {
// https://github.com/juanfont/headscale/issues/2389
func TestTagPropagationToPeerMap(t *testing.T) {
users := types.Users{
{ID: 1, Name: "user1", Email: "user1@headscale.net"},
{ID: 2, Name: "user2", Email: "user2@headscale.net"},
{Model: gorm.Model{ID: 1}, Name: "user1", Email: "user1@headscale.net"},
{Model: gorm.Model{ID: 2}, Name: "user2", Email: "user2@headscale.net"},
}
// Policy: user2 can access tag:web nodes
@@ -892,8 +887,8 @@ func TestTagPropagationToPeerMap(t *testing.T) {
// BOTH admin and tagged node should see each other as peers.
func TestAutogroupSelfWithAdminOverride(t *testing.T) {
users := types.Users{
{ID: 1, Name: "admin", Email: "admin@example.com"},
{ID: 2, Name: "user1", Email: "user1@example.com"},
{Model: gorm.Model{ID: 1}, Name: "admin", Email: "admin@example.com"},
{Model: gorm.Model{ID: 2}, Name: "user1", Email: "user1@example.com"},
}
// Admin has a regular device
@@ -975,8 +970,8 @@ func TestAutogroupSelfWithAdminOverride(t *testing.T) {
// This is the same behavior as the global filter path.
func TestAutogroupSelfSymmetricVisibility(t *testing.T) {
users := types.Users{
{ID: 1, Name: "user1", Email: "user1@example.com"},
{ID: 2, Name: "user2", Email: "user2@example.com"},
{Model: gorm.Model{ID: 1}, Name: "user1", Email: "user1@example.com"},
{Model: gorm.Model{ID: 2}, Name: "user2", Email: "user2@example.com"},
}
// user1 has device A
@@ -1058,10 +1053,10 @@ func TestAutogroupSelfSymmetricVisibility(t *testing.T) {
// - All tagged nodes should be visible to users who can access them.
func TestAutogroupSelfDoesNotBreakOtherUsersAccess(t *testing.T) {
users := types.Users{
{ID: 1, Name: "superadmin", Email: "superadmin@example.com"},
{ID: 2, Name: "admin", Email: "admin@example.com"},
{ID: 3, Name: "direction", Email: "direction@example.com"},
{ID: 4, Name: "tagowner", Email: "tagowner@example.com"},
{Model: gorm.Model{ID: 1}, Name: "superadmin", Email: "superadmin@example.com"},
{Model: gorm.Model{ID: 2}, Name: "admin", Email: "admin@example.com"},
{Model: gorm.Model{ID: 3}, Name: "direction", Email: "direction@example.com"},
{Model: gorm.Model{ID: 4}, Name: "tagowner", Email: "tagowner@example.com"},
}
// Create nodes:
@@ -1228,8 +1223,8 @@ func TestAutogroupSelfDoesNotBreakOtherUsersAccess(t *testing.T) {
// visible to nodes that can access them.
func TestEmptyFilterNodesStillVisible(t *testing.T) {
users := types.Users{
{ID: 1, Name: "admin", Email: "admin@example.com"},
{ID: 2, Name: "tagowner", Email: "tagowner@example.com"},
{Model: gorm.Model{ID: 1}, Name: "admin", Email: "admin@example.com"},
{Model: gorm.Model{ID: 2}, Name: "tagowner", Email: "tagowner@example.com"},
}
adminDevice := &types.Node{
@@ -1296,8 +1291,8 @@ func TestEmptyFilterNodesStillVisible(t *testing.T) {
// tagged nodes AND their own devices.
func TestAutogroupSelfCombinedWithTags(t *testing.T) {
users := types.Users{
{ID: 1, Name: "admin", Email: "admin@example.com"},
{ID: 2, Name: "tagowner", Email: "tagowner@example.com"},
{Model: gorm.Model{ID: 1}, Name: "admin", Email: "admin@example.com"},
{Model: gorm.Model{ID: 2}, Name: "tagowner", Email: "tagowner@example.com"},
}
// Admin has two devices
@@ -1392,7 +1387,7 @@ func TestAutogroupSelfCombinedWithTags(t *testing.T) {
// Expected: node1 should be able to reach node2 via group:admin -> *:* rule.
func TestIssue2990SameUserTaggedDevice(t *testing.T) {
users := types.Users{
{ID: 1, Name: "user1", Email: "user1@"},
{Model: gorm.Model{ID: 1}, Name: "user1", Email: "user1@"},
}
// node1: user device (not tagged), belongs to user1
@@ -1492,8 +1487,8 @@ func TestViaRoutesForPeer(t *testing.T) {
t.Parallel()
users := types.Users{
{ID: 1, Name: "user1", Email: "user1@"},
{ID: 2, Name: "user2", Email: "user2@"},
{Model: gorm.Model{ID: 1}, Name: "user1", Email: "user1@"},
{Model: gorm.Model{ID: 2}, Name: "user2", Email: "user2@"},
}
t.Run("self_returns_empty", func(t *testing.T) {
@@ -2101,7 +2096,7 @@ func TestBuildPeerMap_AutogroupInternetMakesExitNodeVisible(t *testing.T) {
t.Parallel()
users := types.Users{
{ID: 1, Name: "alice", Email: "alice@headscale.net"},
{Model: gorm.Model{ID: 1}, Name: "alice", Email: "alice@headscale.net"},
}
aliceNode := node("alice-laptop", "100.64.0.10", "fd7a:115c:a1e0::a", users[0])
@@ -2140,8 +2135,8 @@ func TestBuildPeerMap_AutogroupInternetMakesExitNodeVisible(t *testing.T) {
// Reproduction for #3160: ambiguous user@ used to silently drop rules.
func TestNewPolicyManager_DuplicateUsername(t *testing.T) {
users := types.Users{
{ID: 2, Name: "yala"},
{ID: 7, Name: "yala", Email: "yala@yala.yala"},
{Model: gorm.Model{ID: 2}, Name: "yala"},
{Model: gorm.Model{ID: 7}, Name: "yala", Email: "yala@yala.yala"},
}
polB := []byte(`{
@@ -2163,7 +2158,7 @@ func TestNewPolicyManager_DuplicateUsername(t *testing.T) {
// Missing-user tokens stay tolerant per #2863; only multi-match blocks load.
func TestNewPolicyManager_UnknownUsernameTolerant(t *testing.T) {
users := types.Users{
{ID: 1, Name: "alice"},
{Model: gorm.Model{ID: 1}, Name: "alice"},
}
polB := []byte(`{
@@ -2177,8 +2172,8 @@ func TestNewPolicyManager_UnknownUsernameTolerant(t *testing.T) {
// Rejected SetPolicy must keep the previous policy intact.
func TestSetPolicy_DuplicateUsername(t *testing.T) {
users := types.Users{
{ID: 2, Name: "yala"},
{ID: 7, Name: "yala", Email: "yala@yala.yala"},
{Model: gorm.Model{ID: 2}, Name: "yala"},
{Model: gorm.Model{ID: 7}, Name: "yala", Email: "yala@yala.yala"},
}
good := []byte(`{
@@ -2222,9 +2217,9 @@ func TestValidateUserReferences_EmptyUsersTolerant(t *testing.T) {
// One case per AST site so a dropped walk fails the matching subtest.
func TestValidateUserReferences_AllSites(t *testing.T) {
users := types.Users{
{ID: 1, Name: "alice"},
{ID: 2, Name: "dup"},
{ID: 3, Name: "dup"},
{Model: gorm.Model{ID: 1}, Name: "alice"},
{Model: gorm.Model{ID: 2}, Name: "dup"},
{Model: gorm.Model{ID: 3}, Name: "dup"},
}
tests := []struct {
@@ -2316,8 +2311,8 @@ func TestValidateUserReferences_AllSites(t *testing.T) {
// IP-level grant.
func TestPeerRelayGrantMakesRelayVisible(t *testing.T) {
users := types.Users{
{ID: 1, Name: "alice", Email: "alice@headscale.net"},
{ID: 2, Name: "tagowner", Email: "tagowner@headscale.net"},
{Model: gorm.Model{ID: 1}, Name: "alice", Email: "alice@headscale.net"},
{Model: gorm.Model{ID: 2}, Name: "tagowner", Email: "tagowner@headscale.net"},
}
// Helper for tagged nodes belonging to the tag-owner user.
@@ -28,6 +28,7 @@ import (
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/types/testcapture"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"tailscale.com/tailcfg"
)
@@ -43,9 +44,9 @@ var knownPolicyTesterDivergences = map[string]string{} //nolint:gosec // strings
// minimal one is enough to make the runner go.
func policyTesterCompatUsers() types.Users {
return types.Users{
{ID: 1, Name: "odin", Email: "odin@example.com"},
{ID: 2, Name: "thor", Email: "thor@example.org"},
{ID: 3, Name: "freya", Email: "freya@example.com"},
{Model: gorm.Model{ID: 1}, Name: "odin", Email: "odin@example.com"},
{Model: gorm.Model{ID: 2}, Name: "thor", Email: "thor@example.org"},
{Model: gorm.Model{ID: 3}, Name: "freya", Email: "freya@example.com"},
}
}
+2 -2
View File
@@ -122,8 +122,8 @@ func (pm *PolicyManager) RunSSHTests() error {
return nil
}
pm.mu.RLock()
defer pm.mu.RUnlock()
pm.mu.Lock()
defer pm.mu.Unlock()
cache := make(map[types.NodeID]*tailcfg.SSHPolicy)
results := runSSHPolicyTests(pm.pol, pm.users, pm.nodes, cache)
+4 -3
View File
@@ -8,6 +8,7 @@ import (
"github.com/juanfont/headscale/hscontrol/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
// sshTestUsers/sshTestNodes are reused across the table below to keep
@@ -21,9 +22,9 @@ import (
// - prod (alice-created tagged node) → tag:prod
func sshTestUsers() types.Users {
return types.Users{
{ID: 1, Name: "alice", Email: "alice@headscale.net"},
{ID: 2, Name: "bob", Email: "bob@headscale.net"},
{ID: 3, Name: "thor", Email: "thor@example.org"},
{Model: gorm.Model{ID: 1}, Name: "alice", Email: "alice@headscale.net"},
{Model: gorm.Model{ID: 2}, Name: "bob", Email: "bob@headscale.net"},
{Model: gorm.Model{ID: 3}, Name: "thor", Email: "thor@example.org"},
}
}
+17 -18
View File
@@ -19,7 +19,6 @@ import (
"github.com/juanfont/headscale/hscontrol/types"
"tailscale.com/tailcfg"
"tailscale.com/tailcfg/nodecap"
)
// PeerCapMap returns the subset of peerSelfCaps the Tailscale client
@@ -48,12 +47,12 @@ func PeerCapMap(peer types.NodeView, peerSelfCaps tailcfg.NodeCapMap) tailcfg.No
// the suggestion from following an advertised-but-not-yet-trusted
// node.
if peer.IsExitNode() {
if v, ok := peerSelfCaps[nodecap.SuggestExitNode]; ok {
if v, ok := peerSelfCaps[tailcfg.NodeAttrSuggestExitNode]; ok {
if out == nil {
out = tailcfg.NodeCapMap{}
}
out[nodecap.SuggestExitNode] = v
out[tailcfg.NodeAttrSuggestExitNode] = v
}
}
@@ -71,7 +70,7 @@ func PeerCapMap(peer types.NodeView, peerSelfCaps tailcfg.NodeCapMap) tailcfg.No
// anonymized capture.
// 4. Caps that are internal magicsock or embedded-SSH tuning with no
// headscale-side equivalent.
var unmodelledTailnetStateCaps = []nodecap.Cap{
var unmodelledTailnetStateCaps = []tailcfg.NodeCapability{
// --- 1. User-role gated ---
// [tailcfg.CapabilityAdmin]: the hosted control plane stamps this
@@ -81,13 +80,13 @@ var unmodelledTailnetStateCaps = []nodecap.Cap{
// the always-on baseline. Stripping on both sides keeps the diff
// from failing on every user-owned non-admin node in a capture.
// Long-term fix is autogroup:admin support.
nodecap.Admin,
tailcfg.CapabilityAdmin,
// [tailcfg.CapabilityOwner]: same shape as is-admin, conditional
// on the "owner" role rather than admin. Headscale does not emit
// this cap at all. autogroup:owner support is tracked under
// NO_USER_ROLES — see the compat skip list.
nodecap.Owner,
tailcfg.CapabilityOwner,
// --- 2. Feature not implemented ---
@@ -96,23 +95,23 @@ var unmodelledTailnetStateCaps = []nodecap.Cap{
// re-keying by the control plane. Client reads at
// ipn/ipnlocal/local.go:1752 (b.capTailnetLock). Headscale has no
// tailnet-lock implementation.
nodecap.TailnetLock,
tailcfg.CapabilityTailnetLock,
// [tailcfg.NodeAttrServiceHost]: marks a node as approved to host
// VIP services (Tailscale Services). Client reads via
// UnmarshalNodeCapViewJSON at ipn/ipnlocal/local.go:2704.
// Headscale does not implement Tailscale Services.
nodecap.ServiceHost,
tailcfg.NodeAttrServiceHost,
// [tailcfg.NodeAttrStoreAppCRoutes]: tells an app-connector node
// to persist learned routes across restarts. Client reads via
// controlknobs:148. Headscale does not implement app connectors.
nodecap.StoreAppCRoutes,
tailcfg.NodeAttrStoreAppCRoutes,
// [tailcfg.CapabilityWarnFunnelNoHTTPS]: deprecated in Tailscale
// 2023-08-09. Should not appear in fresh captures — listed
// defensively in case a stale tailnet still emits it.
nodecap.WarnFunnelNoHTTPS,
tailcfg.CapabilityWarnFunnelNoHTTPS,
// --- 3. Tailnet-state metadata not derivable from headscale config ---
@@ -122,36 +121,36 @@ var unmodelledTailnetStateCaps = []nodecap.Cap{
// from cfg.Domain() that does not round-trip through the
// anonymized capture string. Skip rather than diverge on a value
// with no real-world equivalent.
nodecap.TailnetDisplayName,
tailcfg.NodeAttrTailnetDisplayName,
// [tailcfg.NodeAttrMaxKeyDuration]: tailnet-wide max key duration
// value. Headscale has cfg.Node.Expiry but does not surface it
// as a cap today; the hosted control plane emits this only when
// a non-default value is configured.
nodecap.MaxKeyDuration,
tailcfg.NodeAttrMaxKeyDuration,
// [tailcfg.NodeAttrNativeIPV4]: peer-consumed cap conditional on
// tailnet ipv4 reachability state. Out of scope for the current
// peer-cap adoption (only suggest-exit-node is wired in this
// PR).
nodecap.NativeIPV4,
tailcfg.NodeAttrNativeIPV4,
// --- 4. Internal tuning, no headscale equivalent ---
// [tailcfg.NodeAttrProbeUDPLifetime]: tunes magicsock's UDP
// path-lifetime probe behavior. Internal performance knob; not
// policy-driven. Client reads via controlknobs:147.
nodecap.ProbeUDPLifetime,
tailcfg.NodeAttrProbeUDPLifetime,
// [tailcfg.NodeAttrSSHBehaviorV1]: configures the embedded SSH
// server (no su, in-process SFTP). Internal tuning; the embedded
// server picks Tailscale-vendored defaults without the cap.
nodecap.SSHBehaviorV1,
tailcfg.NodeAttrSSHBehaviorV1,
// [tailcfg.NodeAttrSSHEnvironmentVariables]: gates SendEnv
// forwarding in the embedded SSH server. Internal; default chosen
// by the server.
nodecap.SSHEnvironmentVariables,
tailcfg.NodeAttrSSHEnvironmentVariables,
}
// strippedCapPrefixes lists URL/string prefixes for parameterized or
@@ -173,7 +172,7 @@ func stripUnmodelledTailnetStateCaps(cm tailcfg.NodeCapMap) tailcfg.NodeCapMap {
}
out := maps.Clone(cm)
maps.DeleteFunc(out, func(k nodecap.Cap, _ []tailcfg.RawMessage) bool {
maps.DeleteFunc(out, func(k tailcfg.NodeCapability, _ []tailcfg.RawMessage) bool {
return isUnmodelledTailnetStateCap(k)
})
@@ -184,7 +183,7 @@ func stripUnmodelledTailnetStateCaps(cm tailcfg.NodeCapMap) tailcfg.NodeCapMap {
return out
}
func isUnmodelledTailnetStateCap(k nodecap.Cap) bool {
func isUnmodelledTailnetStateCap(k tailcfg.NodeCapability) bool {
if slices.Contains(unmodelledTailnetStateCaps, k) {
return true
}
@@ -28,6 +28,7 @@ import (
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/types/testcapture"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"tailscale.com/tailcfg"
)
@@ -44,9 +45,9 @@ func ptrAddr(s string) *netip.Addr {
// norse-god names and nodes get original-151 pokémon names.
func setupACLCompatUsers() types.Users {
return types.Users{
{ID: 1, Name: "odin", Email: "odin@example.com"},
{ID: 2, Name: "thor", Email: "thor@example.org"},
{ID: 3, Name: "freya", Email: "freya@example.com"},
{Model: gorm.Model{ID: 1}, Name: "odin", Email: "odin@example.com"},
{Model: gorm.Model{ID: 2}, Name: "thor", Email: "thor@example.org"},
{Model: gorm.Model{ID: 3}, Name: "freya", Email: "freya@example.com"},
}
}
@@ -29,6 +29,7 @@ import (
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/types/testcapture"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"tailscale.com/tailcfg"
)
@@ -38,9 +39,9 @@ import (
// .
func setupGrantsCompatUsers() types.Users {
return types.Users{
{ID: 1, Name: "odin", Email: "odin@example.com"},
{ID: 2, Name: "thor", Email: "thor@example.org"},
{ID: 3, Name: "freya", Email: "freya@example.com"},
{Model: gorm.Model{ID: 1}, Name: "odin", Email: "odin@example.com"},
{Model: gorm.Model{ID: 2}, Name: "thor", Email: "thor@example.org"},
{Model: gorm.Model{ID: 3}, Name: "freya", Email: "freya@example.com"},
}
}
@@ -27,8 +27,8 @@ import (
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/types/testcapture"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"tailscale.com/tailcfg"
"tailscale.com/tailcfg/nodecap"
"tailscale.com/types/views"
)
@@ -36,9 +36,9 @@ import (
// tool's anonymizer rewrites the SaaS users into.
func nodeAttrsCompatUsers() types.Users {
return types.Users{
{ID: 1, Name: "odin", Email: "odin@example.com"},
{ID: 2, Name: "thor", Email: "thor@example.org"},
{ID: 3, Name: "freya", Email: "freya@example.com"},
{Model: gorm.Model{ID: 1}, Name: "odin", Email: "odin@example.com"},
{Model: gorm.Model{ID: 2}, Name: "thor", Email: "thor@example.org"},
{Model: gorm.Model{ID: 3}, Name: "freya", Email: "freya@example.com"},
}
}
@@ -322,7 +322,7 @@ func testNodeAttrsSuccess(
// capMapFromView materialises a captured CapMap view into the
// [tailcfg.NodeCapMap] shape headscale renders, so both sides of the
// diff have the same concrete type.
func capMapFromView(view views.MapSlice[nodecap.Cap, tailcfg.RawMessage]) tailcfg.NodeCapMap {
func capMapFromView(view views.MapSlice[tailcfg.NodeCapability, tailcfg.RawMessage]) tailcfg.NodeCapMap {
if view.Len() == 0 {
return nil
}
@@ -42,6 +42,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go4.org/netipx"
"gorm.io/gorm"
"tailscale.com/tailcfg"
)
@@ -80,16 +81,16 @@ func buildRoutesUsersAndNodes(
users = make(types.Users, 0, len(topo.Users))
for _, u := range topo.Users {
users = append(users, types.User{
ID: u.ID,
Model: gorm.Model{ID: u.ID},
Name: u.Name,
Email: convertSaaSEmail(u.Email),
})
}
} else {
users = types.Users{
{ID: 1, Name: "kratail2tid", Email: "kratail2tid@example.com"},
{ID: 2, Name: "kristoffer", Email: "kristoffer@example.com"},
{ID: 3, Name: "monitorpasskeykradalby", Email: "monitorpasskeykradalby@example.com"},
{Model: gorm.Model{ID: 1}, Name: "kratail2tid", Email: "kratail2tid@example.com"},
{Model: gorm.Model{ID: 2}, Name: "kristoffer", Email: "kristoffer@example.com"},
{Model: gorm.Model{ID: 3}, Name: "monitorpasskeykradalby", Email: "monitorpasskeykradalby@example.com"},
}
}
@@ -1454,7 +1455,7 @@ func TestRoutesCompatPeerAllowedIPs(t *testing.T) {
for _, nmPeer := range capture.Netmap.Peers {
// Extract the short name from the FQDN.
peerName, _, _ := strings.Cut(nmPeer.Name(), ".")
peerName := strings.Split(nmPeer.Name(), ".")[0]
peer := findNodeByGivenName(nodes, peerName)
if peer == nil {
@@ -18,6 +18,7 @@ import (
"github.com/juanfont/headscale/hscontrol/types/testcapture"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"tailscale.com/tailcfg"
)
@@ -28,17 +29,17 @@ import (
func setupSSHDataCompatUsers() types.Users {
return types.Users{
{
ID: 1,
Model: gorm.Model{ID: 1},
Name: "odin",
Email: "odin@example.com",
},
{
ID: 2,
Model: gorm.Model{ID: 2},
Name: "thor",
Email: "thor@example.org",
},
{
ID: 3,
Model: gorm.Model{ID: 3},
Name: "freya",
Email: "freya@example.com",
},

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