mirror of
https://github.com/juanfont/headscale.git
synced 2026-09-02 21:21:32 +09:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b17c26d11 | |||
| 3fca03ad61 | |||
| 90bffbebdd | |||
| 84bb7c3794 | |||
| 87555ce621 | |||
| 3fdc068c8c |
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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{
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
- 
|
||||

|
||||
- 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.
|
||||
- [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.
|
||||
|
||||
@@ -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)
|
||||
- 
|
||||

|
||||
- 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
|
||||
- [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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"vendor": {
|
||||
"goModSum": "sha256-KJAFccbKRC32xxF7gMKO7C4tqokz1sQAbKQpw3jiqe4=",
|
||||
"sri": "sha256-sJtPLelpL4TYjo9mgXkeLlKumgNrR6YjV+BuAlbmkn4="
|
||||
"goModSum": "sha256-SJml8RXGmb2p0g1nOsHn86FA1hwgd5ZffLSkUj5zek8=",
|
||||
"sri": "sha256-pjGNuVtgFFzWNq/2cK7a4iyF13AfcHz098nk92a9Ido="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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":["*:*"]}]}`
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
@@ -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"},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user