Compare commits

..

53 Commits

Author SHA1 Message Date
Kristoffer Dalby
d5037c25a6 date in changelog
Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2025-02-11 11:56:59 +01:00
Kristoffer Dalby
c53ff2ce00 update changelog (#2414)
Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2025-02-07 11:32:01 +01:00
Kristoffer Dalby
b4ac8cd9a3 hscontrol/db: add migration setting non existing pak on nodes to null (#2412)
Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2025-02-07 10:22:23 +01:00
Nbelles
22277d1fc7 Spell check 2025-02-05 17:29:30 +01:00
Kristoffer Dalby
9ae3570154 drop versions older than 1.62 (#2405) 2025-02-05 12:02:32 +01:00
Florian Preinstorfer
f12cb2e048 Headscale now updates the user profile 2025-02-05 11:05:03 +01:00
Florian Preinstorfer
8c09afe20c Headscale implements a single tailnet
Separate the term "tailnet" from user and be more explicit about
providing a single tailnet.

Also be more explicit about users. Refer to "headscale users" when
mentioning commandline invocations and use the term "local users" when
discussing unix accounts.

Fixes: #2335
2025-02-05 11:05:03 +01:00
Kristoffer Dalby
8b92c017ec add 1.80 to capver and update deps (#2394) 2025-02-05 07:17:51 +01:00
github-actions[bot]
9a7890d56b flake.lock: Update (#2402) 2025-02-02 07:58:12 +00:00
Kristoffer Dalby
45752db0f6 Return better web errors to the user (#2398)
* add dedicated http error to propagate to user

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

* classify user errors in http handlers

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

* move validation of pre auth key out of db

This move separates the logic a bit and allow us to
write specific errors for the caller, in this case the web
layer so we can present the user with the correct error
codes without bleeding web stuff into a generic validate.

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

* update changelog

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

---------

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2025-02-01 15:25:18 +01:00
Kristoffer Dalby
1c7f3bc440 no edit of oidc users, minimum hostname length (#2393)
* return an error when renaming users from OIDC
* set minimum hostname length of 2
2025-02-01 09:40:37 +00:00
Kristoffer Dalby
9bd143852f do not allow preauth keys to be deleted if assigned to node (#2396)
* do not allow preauth keys to be deleted if assigned to node

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

* update changelog

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

---------

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2025-02-01 10:31:13 +01:00
Kristoffer Dalby
d57a55c024 Rewrite authentication flow (#2374) 2025-02-01 09:16:51 +00:00
Kristoffer Dalby
e172c29360 initial capver packet tracking version (#2391)
* initial capver packet tracking version

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

* Log the minimum version as client version, not only capver

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

* remove old versions

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

* use capver for integration tests

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

* changelog

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

* patch through m and n key

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

---------

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2025-01-30 21:49:09 +00:00
Kristoffer Dalby
cd3b8e68ff clean up handler methods, common logging (#2384)
* clean up handler methods, common logging

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

* streamline http.Error calls

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

---------

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2025-01-30 22:40:29 +01:00
nblock
f44b1d37c4 Remove routes without a node_id (#2386)
The routes table has a NOT NULL constraint on node_id.

Fixes: #2376
2025-01-30 14:57:06 +01:00
Kristoffer Dalby
7ba6ad3489 simplify findUserByToken in ACL, add missing testcases (#2388)
* update users doc on unique constraints

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

* simplify finduser func

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

* add initial tests for findUserFromToken

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

* add changelog

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

---------

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2025-01-30 11:35:49 +01:00
Kristoffer Dalby
2c279e0a7b create and rename usernames validated by new func (#2381)
Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2025-01-27 22:58:10 +01:00
Kristoffer Dalby
4c8e847f47 use dedicated registration ID for auth flow (#2337) 2025-01-26 22:20:11 +01:00
github-actions[bot]
97e5d95399 flake.lock: Update (#2378) 2025-01-26 15:33:51 +00:00
Kristoffer Dalby
d1dbe4ece9 fix panic if derp update is 0 (#2368)
* fix panic if derp update is 0

Fixes #2362

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

* update changelog

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

---------

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2025-01-23 16:16:12 +01:00
Kristoffer Dalby
9e3f945eda fix postgres migration issue with 0.24 (#2367)
* fix postgres migration issue with 0.24

Fixes #2351

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

* add postgres migration test for 2351

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

* update changelog

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

---------

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2025-01-23 14:58:42 +01:00
Kristoffer Dalby
615ee5df75 make it harder to insert invalid routes (#2371)
* make it harder to insert invalid routes

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

* dont panic if node is not available for route

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

* update changelog

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

---------

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2025-01-23 13:40:23 +01:00
Kristoffer Dalby
c1f42cdf4b relax user validation to allow emails, add tests from various oidc providers (#2364)
* relax user validation to allow emails, add tests from various oidc providers

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

* changelog

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

---------

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2025-01-22 18:10:15 +01:00
github-actions[bot]
aa76980b43 flake.lock: Update (#2353) 2025-01-19 09:59:29 +00:00
Kristoffer Dalby
5b986ed0a7 set oidc.map_legacy_users false (#2350) 2025-01-17 14:44:04 +00:00
Kristoffer Dalby
8076c94444 Release docs 0.24 (#2349)
* correct changelog date

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

* update docs version and copyright

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

* fix deprecated goreleaser key and DRY

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

---------

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2025-01-17 12:57:13 +00:00
Kristoffer Dalby
e88406e837 set changelog date (#2347) 2025-01-17 12:01:06 +01:00
Kristoffer Dalby
e4a3dcc3b8 use headscale server url as domain instead of base_domain (#2338) 2025-01-16 18:05:20 +01:00
Kristoffer Dalby
caad5c613d fix nil pointer deref (#2339) 2025-01-16 18:05:05 +01:00
Kristoffer Dalby
38aef77e54 allow @ and Log if OIDC username is not consider valid (#2340) 2025-01-16 18:04:54 +01:00
Dmitry Gordin
1ab7b315a2 Update apple.md for latest version of iOS (#2321)
The official iOS app now has a simpler login process for custom instances, directly within the app.
2025-01-13 12:09:53 +00:00
github-actions[bot]
610597bfb7 flake.lock: Update (#2342) 2025-01-12 18:54:59 +00:00
Stefan Majer
ede4f97a16 Fix typos 2025-01-09 10:38:25 +01:00
Kristoffer Dalby
fa641e38b8 Set CSRF cookies for OIDC (#2328)
* set state and nounce in oidc to prevent csrf

Fixes #2276

* try to fix new postgres issue

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

---------

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2025-01-08 16:29:37 +01:00
github-actions[bot]
41bad2b9fd flake.lock: Update (#2324) 2025-01-05 07:35:18 +00:00
github-actions[bot]
f9bbfa5eab flake.lock: Update (#2320) 2024-12-29 11:41:52 +00:00
Rorical
b81420bef1 feat: Add PKCE Verifier for OIDC (#2314)
* feat: add PKCE verifier for OIDC

* Update CHANGELOG.md
2024-12-22 16:46:36 +00:00
github-actions[bot]
9313e5b058 flake.lock: Update (#2313) 2024-12-22 07:07:26 +00:00
Kristoffer Dalby
770f3dcb93 fix tags not resolving to username if email is present (#2309)
* ensure valid tags is populated on user gets too

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

* ensure forced tags are added

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

* remove unused envvar in test

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

* debug log auth/unauth tags in policy man

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

* defer shutdown in tags test

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

* add tag test with groups

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

* add email, display name, picture to create user

Updates #2166

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

* add ability to set display and email to cli

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

* add email to test users in integration

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

* fix issue where tags were only assigned to email, not username

Fixes #2300
Fixes #2307

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

* expand principles to correct login name

and if fix an issue where nodeip principles might not expand to all
relevant IPs instead of taking the first in a prefix.

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

* fix ssh unit test

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

* update cli and oauth tests for users with email

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

* index by test email

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

* fix last test

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

---------

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2024-12-19 13:10:10 +01:00
Kristoffer Dalby
af4508b9dc bump deps (#2308)
* Bump go crypto

Closes #2281

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

* upgrade tailscale

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

* upgrade rest

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

* nix: flake update

---------

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2024-12-17 15:35:42 +01:00
Florian Preinstorfer
bbc93a90a2 Set title for code listings 2024-12-17 14:08:34 +01:00
Florian Preinstorfer
0acb2b5647 Misc doc updates 2024-12-17 14:08:34 +01:00
Florian Preinstorfer
3269cfdca0 Mention reload and SIGHUP when editing the ACL policy file
Fixes: #2284
2024-12-17 14:08:34 +01:00
Florian Preinstorfer
319ce67c87 Update DNS documentation for dns.extra_records_path
* Describe both ways to add extra DNS records
* Use "extra" instead of "custom" to align with the configuration file
* Include dns.extra_records_path in the configuration file
2024-12-17 14:08:34 +01:00
Florian Preinstorfer
47b405d6c6 Changelog: support client verify for DERP
and fix some links

Ref: #2304
2024-12-17 14:08:34 +01:00
Florian Preinstorfer
65304a0ce7 Remove sealos documentation
The referenced version is outdated (0.23.0-beta1) and seems
unmaintained.
2024-12-17 14:08:34 +01:00
Dongjun Na
e270169c13 Add -race Flag to GitHub Action and Fix Data Race in CreateTailscaleNodesInUser (#2038)
* Add -race flag to Makefile and integration tests; fix data race in CreateTailscaleNodesInUser

* Fix data race in ExecuteCommand by using local buffers and mutex

Signed-off-by: Dongjun Na <kmu5544616@gmail.com>

* lint

Signed-off-by: Dongjun Na <kmu5544616@gmail.com>

---------

Signed-off-by: Dongjun Na <kmu5544616@gmail.com>
2024-12-17 14:06:57 +01:00
Shaw Drastin
7d937c6bd0 Correct macOS GUI connect guide because there's no ALT key on a mac (#2306)
* Correct macOS GUI connect guide because there's no ALT key on a mac
* also correct macOS GUI connect in hscontrol text
2024-12-17 12:11:27 +00:00
Kristoffer Dalby
ccc895b4c6 fixes to extra-record file watcher (#2298)
* Fix excess error message during writes

Fixes #2290

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

* retry filewatcher on removed files

This should handled if files are deleted and added again, and for rename
scenarios.

Fixes #2289

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

* test more write and remove in filewatcher

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

---------

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2024-12-16 11:26:56 +01:00
Kristoffer Dalby
5345f19693 fix issue where some oidc claim bools are sent as string (#2297)
Jumpcloud send invalid json, so we need to handle it.

Fixes #2293

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2024-12-16 11:26:32 +01:00
Kristoffer Dalby
ec8729b772 fix sighup issue with empty acl (#2296)
Fixes #2291

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2024-12-16 07:48:19 +01:00
github-actions[bot]
e00b9d9a91 flake.lock: Update (#2294) 2024-12-15 06:46:14 +00:00
115 changed files with 4189 additions and 2710 deletions

View File

@@ -32,7 +32,7 @@ jobs:
- name: Generate and check integration tests
if: steps.changed-files.outputs.files == 'true'
run: |
nix develop --command bash -c "cd cmd/gh-action-integration-generator/ && go generate"
nix develop --command bash -c "cd .github/workflows && go generate"
git diff --exit-code .github/workflows/test-integration.yaml
- name: Show missing tests

View File

@@ -1,6 +1,6 @@
package main
//go:generate go run ./main.go
//go:generate go run ./gh-action-integration-generator.go
import (
"bytes"
@@ -42,15 +42,19 @@ func updateYAML(tests []string) {
testsForYq := fmt.Sprintf("[%s]", strings.Join(tests, ", "))
yqCommand := fmt.Sprintf(
"yq eval '.jobs.integration-test.strategy.matrix.test = %s' ../../.github/workflows/test-integration.yaml -i",
"yq eval '.jobs.integration-test.strategy.matrix.test = %s' ./test-integration.yaml -i",
testsForYq,
)
cmd := exec.Command("bash", "-c", yqCommand)
var out bytes.Buffer
cmd.Stdout = &out
var stdout bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if err != nil {
log.Printf("stdout: %s", stdout.String())
log.Printf("stderr: %s", stderr.String())
log.Fatalf("failed to run yq command: %s", err)
}

View File

@@ -22,9 +22,13 @@ jobs:
- TestACLNamedHostsCanReach
- TestACLDevice1CanAccessDevice2
- TestPolicyUpdateWhileRunningWithCLIInDatabase
- TestAuthKeyLogoutAndReloginSameUser
- TestAuthKeyLogoutAndReloginNewUser
- TestOIDCAuthenticationPingAll
- TestOIDCExpireNodesBasedOnTokenExpiry
- TestOIDC024UserCreation
- TestOIDCAuthenticationWithPKCE
- TestOIDCReloginSameNodeNewUser
- TestAuthWebFlowAuthenticationPingAll
- TestAuthWebFlowLogoutAndRelogin
- TestUserCommand
@@ -49,7 +53,6 @@ jobs:
- TestDERPServerWebsocketScenario
- TestPingAllByIP
- TestPingAllByIPPublicDERP
- TestAuthKeyLogoutAndRelogin
- TestEphemeral
- TestEphemeralInAlternateTimezone
- TestEphemeral2006DeletedTooQuickly

View File

@@ -34,4 +34,10 @@ jobs:
- name: Run tests
if: steps.changed-files.outputs.files == 'true'
env:
# As of 2025-01-06, these env vars was not automatically
# set anymore which breaks the initdb for postgres on
# some of the database migration tests.
LC_ALL: "en_US.UTF-8"
LC_CTYPE: "en_US.UTF-8"
run: nix develop --command -- gotestsum

View File

@@ -82,7 +82,9 @@ nfpms:
kos:
- id: ghcr
repository: ghcr.io/juanfont/headscale
repositories:
- ghcr.io/juanfont/headscale
- headscale/headscale
# bare tells KO to only use the repository
# for tagging and naming the container.
@@ -110,31 +112,11 @@ kos:
- '{{ trimprefix .Tag "v" }}'
- "sha-{{ .ShortCommit }}"
- id: dockerhub
build: headscale
base_image: gcr.io/distroless/base-debian12
repository: headscale/headscale
bare: true
platforms:
- linux/amd64
- linux/386
- linux/arm64
- linux/arm/v7
tags:
- "{{ if not .Prerelease }}latest{{ end }}"
- "{{ if not .Prerelease }}{{ .Major }}.{{ .Minor }}.{{ .Patch }}{{ end }}"
- "{{ if not .Prerelease }}{{ .Major }}.{{ .Minor }}{{ end }}"
- "{{ if not .Prerelease }}{{ .Major }}{{ end }}"
- "{{ if not .Prerelease }}v{{ .Major }}.{{ .Minor }}.{{ .Patch }}{{ end }}"
- "{{ if not .Prerelease }}v{{ .Major }}.{{ .Minor }}{{ end }}"
- "{{ if not .Prerelease }}v{{ .Major }}{{ end }}"
- "{{ if not .Prerelease }}stable{{ else }}unstable{{ end }}"
- "{{ .Tag }}"
- '{{ trimprefix .Tag "v" }}'
- "sha-{{ .ShortCommit }}"
- id: ghcr-debug
repository: ghcr.io/juanfont/headscale
repositories:
- ghcr.io/juanfont/headscale
- headscale/headscale
bare: true
base_image: gcr.io/distroless/base-debian12:debug
build: headscale
@@ -159,29 +141,6 @@ kos:
- '{{ trimprefix .Tag "v" }}-debug'
- "sha-{{ .ShortCommit }}-debug"
- id: dockerhub-debug
build: headscale
base_image: gcr.io/distroless/base-debian12:debug
repository: headscale/headscale
bare: true
platforms:
- linux/amd64
- linux/386
- linux/arm64
- linux/arm/v7
tags:
- "{{ if not .Prerelease }}latest-debug{{ end }}"
- "{{ if not .Prerelease }}{{ .Major }}.{{ .Minor }}.{{ .Patch }}-debug{{ end }}"
- "{{ if not .Prerelease }}{{ .Major }}.{{ .Minor }}-debug{{ end }}"
- "{{ if not .Prerelease }}{{ .Major }}-debug{{ end }}"
- "{{ if not .Prerelease }}v{{ .Major }}.{{ .Minor }}.{{ .Patch }}-debug{{ end }}"
- "{{ if not .Prerelease }}v{{ .Major }}.{{ .Minor }}-debug{{ end }}"
- "{{ if not .Prerelease }}v{{ .Major }}-debug{{ end }}"
- "{{ if not .Prerelease }}stable-debug{{ else }}unstable-debug{{ end }}"
- "{{ .Tag }}-debug"
- '{{ trimprefix .Tag "v" }}-debug'
- "sha-{{ .ShortCommit }}-debug"
checksum:
name_template: "checksums.txt"
snapshot:

View File

@@ -2,7 +2,75 @@
## Next
## 0.24.0 (2024-xx-xx)
## 0.25.0 (2025-02-11)
### BREAKING
- Authentication flow has been rewritten
[#2374](https://github.com/juanfont/headscale/pull/2374) This change should be
transparent to users with the exception of some buxfixes that has been
discovered and was fixed as part of the rewrite.
- When a node is registered with _a new user_, it will be registered as a new
node ([#2327](https://github.com/juanfont/headscale/issues/2327) and
[#1310](https://github.com/juanfont/headscale/issues/1310)).
- A logged out node logging in with the same user will replace the existing
node.
- Remove support for Tailscale clients older than 1.62 (Capability version 87)
[#2405](https://github.com/juanfont/headscale/pull/2405)
### Changes
- `oidc.map_legacy_users` is now `false` by default
[#2350](https://github.com/juanfont/headscale/pull/2350)
- Print Tailscale version instead of capability versions for outdated nodes
[#2391](https://github.com/juanfont/headscale/pull/2391)
- Do not allow renaming of users from OIDC
[#2393](https://github.com/juanfont/headscale/pull/2393)
- Change minimum hostname length to 2
[#2393](https://github.com/juanfont/headscale/pull/2393)
- Fix migration error caused by nodes having invalid auth keys
[#2412](https://github.com/juanfont/headscale/pull/2412)
- Pre auth keys belonging to a user are no longer deleted with the user
[#2396](https://github.com/juanfont/headscale/pull/2396)
- Pre auth keys that are used by a node can no longer be deleted
[#2396](https://github.com/juanfont/headscale/pull/2396)
- Rehaul HTTP errors, return better status code and errors to users
[#2398](https://github.com/juanfont/headscale/pull/2398)
## 0.24.3 (2025-02-07)
### Changes
- Fix migration error caused by nodes having invalid auth keys
[#2412](https://github.com/juanfont/headscale/pull/2412)
- Pre auth keys belonging to a user are no longer deleted with the user
[#2396](https://github.com/juanfont/headscale/pull/2396)
- Pre auth keys that are used by a node can no longer be deleted
[#2396](https://github.com/juanfont/headscale/pull/2396)
## 0.24.2 (2025-01-30)
### Changes
- Fix issue where email and username being equal fails to match in Policy
[#2388](https://github.com/juanfont/headscale/pull/2388)
- Delete invalid routes before adding a NOT NULL constraint on node_id
[#2386](https://github.com/juanfont/headscale/pull/2386)
## 0.24.1 (2025-01-23)
### Changes
- Fix migration issue with user table for PostgreSQL
[#2367](https://github.com/juanfont/headscale/pull/2367)
- Relax username validation to allow emails
[#2364](https://github.com/juanfont/headscale/pull/2364)
- Remove invalid routes and add stronger constraints for routes to avoid API
panic [#2371](https://github.com/juanfont/headscale/pull/2371)
- Fix panic when `derp.update_frequency` is 0
[#2368](https://github.com/juanfont/headscale/pull/2368)
## 0.24.0 (2025-01-17)
### Security fix: OIDC changes in Headscale 0.24.0
@@ -33,8 +101,7 @@ and have it populate to Headscale automatically the next time they log in.
However, this may affect the way you reference users in policies.
Headscale v0.23.0 and earlier never recorded the `iss` and `sub` fields, so all
legacy (existing) OIDC accounts _need to be migrated_ to be properly
secured.
legacy (existing) OIDC accounts _need to be migrated_ to be properly secured.
#### What do I need to do to migrate?
@@ -46,8 +113,8 @@ The migration will mostly be done automatically, with one exception. If your
OIDC does not provide an `email_verified` claim, Headscale will ignore the
`email`. This means that either the administrator will have to mark the user
emails as verified, or ensure the users verify their emails. Any unverified
emails will be ignored, meaning that the users will get new accounts instead
of being migrated.
emails will be ignored, meaning that the users will get new accounts instead of
being migrated.
After this exception is ensured, make all users log into Headscale with their
account, and Headscale will automatically update the account record. This will
@@ -148,12 +215,13 @@ This will also affect the way you
- User gRPC/API [#2261](https://github.com/juanfont/headscale/pull/2261):
- If you depend on a Headscale Web UI, you should wait with this update until
the UI have been updated to match the new API.
- `GET /api/v1/user/{name}` and `GetUser` have been removed in favour of `ListUsers` with an ID parameter
- `GET /api/v1/user/{name}` and `GetUser` have been removed in favour of
`ListUsers` with an ID parameter
- `RenameUser` and `DeleteUser` now require an ID instead of a name.
### Changes
- Improved compatibilty of built-in DERP server with clients connecting over
- Improved compatibility of built-in DERP server with clients connecting over
WebSocket [#2132](https://github.com/juanfont/headscale/pull/2132)
- Allow nodes to use SSH agent forwarding
[#2145](https://github.com/juanfont/headscale/pull/2145)
@@ -164,13 +232,18 @@ This will also affect the way you
- Fixed updating of hostname and givenName when it is updated in HostInfo
[#2199](https://github.com/juanfont/headscale/pull/2199)
- Fixed missing `stable-debug` container tag
[#2232](https://github.com/juanfont/headscale/pr/2232)
[#2232](https://github.com/juanfont/headscale/pull/2232)
- Loosened up `server_url` and `base_domain` check. It was overly strict in some
cases. [#2248](https://github.com/juanfont/headscale/pull/2248)
- CLI for managing users now accepts `--identifier` in addition to `--name`,
usage of `--identifier` is recommended
[#2261](https://github.com/juanfont/headscale/pull/2261)
- Add `dns.extra_records_path` configuration option [#2262](https://github.com/juanfont/headscale/issues/2262)
- Add `dns.extra_records_path` configuration option
[#2262](https://github.com/juanfont/headscale/issues/2262)
- Support client verify for DERP
[#2046](https://github.com/juanfont/headscale/pull/2046)
- Add PKCE Verifier for OIDC
[#2314](https://github.com/juanfont/headscale/pull/2314)
## 0.23.0 (2024-09-18)
@@ -253,14 +326,14 @@ part of adopting [#1460](https://github.com/juanfont/headscale/pull/1460).
- Entrypoint of container image has changed from shell to headscale, require
change from `headscale serve` to `serve`
- `/var/lib/headscale` and `/var/run/headscale` is no longer created
automatically, see [container docs](./docs/running-headscale-container.md)
automatically, see [container docs](./docs/setup/install/container.md)
- Prefixes are now defined per v4 and v6 range.
[#1756](https://github.com/juanfont/headscale/pull/1756)
- `ip_prefixes` option is now `prefixes.v4` and `prefixes.v6`
- `prefixes.allocation` can be set to assign IPs at `sequential` or `random`.
[#1869](https://github.com/juanfont/headscale/pull/1869)
- MagicDNS domains no longer contain usernames []()
- This is in preperation to fix Headscales implementation of tags which
- This is in preparation to fix Headscales implementation of tags which
currently does not correctly remove the link between a tagged device and a
user. As tagged devices will not have a user, this will require a change to
the DNS generation, removing the username, see
@@ -346,7 +419,7 @@ part of adopting [#1460](https://github.com/juanfont/headscale/pull/1460).
[#1391](https://github.com/juanfont/headscale/pull/1391)
- Improvements on Noise implementation
[#1379](https://github.com/juanfont/headscale/pull/1379)
- Replace node filter logic, ensuring nodes with access can see eachother
- Replace node filter logic, ensuring nodes with access can see each other
[#1381](https://github.com/juanfont/headscale/pull/1381)
- Disable (or delete) both exit routes at the same time
[#1428](https://github.com/juanfont/headscale/pull/1428)
@@ -643,7 +716,7 @@ part of adopting [#1460](https://github.com/juanfont/headscale/pull/1460).
- Boundaries between Namespaces has been removed and all nodes can communicate
by default [#357](https://github.com/juanfont/headscale/pull/357)
- To limit access between nodes, use [ACLs](./docs/acls.md).
- To limit access between nodes, use [ACLs](./docs/ref/acls.md).
- `/metrics` is now a configurable host:port endpoint:
[#344](https://github.com/juanfont/headscale/pull/344). You must update your
`config.yaml` file to include:
@@ -701,13 +774,12 @@ behaviour.
- All machines can communicate with all machines by default
- Tags should now work correctly and adding a host to Headscale should now
reload the rules.
- The documentation have a [fictional example](docs/acls.md) that should cover
some use cases of the ACLs features
- The documentation have a [fictional example](./docs/ref/acls.md) that should
cover some use cases of the ACLs features
### Features
- Add support for configurable mTLS
[docs](docs/tls.md#configuring-mutual-tls-authentication-mtls)
- Add support for configurable mTLS [docs](./docs/ref/tls.md)
[#297](https://github.com/juanfont/headscale/pull/297)
### Changes
@@ -721,7 +793,8 @@ behaviour.
- Add IPv6 support to the prefix assigned to namespaces
- Add API Key support
- Enable remote control of `headscale` via CLI [docs](docs/remote-cli.md)
- Enable remote control of `headscale` via CLI
[docs](./docs/ref/remote-cli.md)
- Enable HTTP API (beta, subject to change)
- OpenID Connect users will be mapped per namespaces
- Each user will get its own namespace, created if it does not exist

View File

@@ -22,7 +22,7 @@ build:
dev: lint test build
test:
gotestsum -- -short -coverprofile=coverage.out ./...
gotestsum -- -short -race -coverprofile=coverage.out ./...
test_integration:
docker run \
@@ -33,7 +33,7 @@ test_integration:
-v /var/run/docker.sock:/var/run/docker.sock \
-v $$PWD/control_logs:/tmp/control \
golang:1 \
go run gotest.tools/gotestsum@latest -- -failfast ./... -timeout 120m -parallel 8
go run gotest.tools/gotestsum@latest -- -race -failfast ./... -timeout 120m -parallel 8
lint:
golangci-lint run --fix --timeout 10m

View File

@@ -32,12 +32,12 @@ organisation.
## Design goal
Headscale aims to implement a self-hosted, open source alternative to the Tailscale
control server.
Headscale's goal is to provide self-hosters and hobbyists with an open-source
server they can use for their projects and labs.
It implements a narrow scope, a single Tailnet, suitable for a personal use, or a small
open-source organisation.
Headscale aims to implement a self-hosted, open source alternative to the
[Tailscale](https://tailscale.com/) control server. Headscale's goal is to
provide self-hosters and hobbyists with an open-source server they can use for
their projects and labs. It implements a narrow scope, a _single_ Tailscale
network (tailnet), suitable for a personal use, or a small open-source
organisation.
## Supporting Headscale

View File

@@ -4,10 +4,10 @@ import (
"fmt"
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"google.golang.org/grpc/status"
"tailscale.com/types/key"
)
const (
@@ -79,7 +79,7 @@ var createNodeCmd = &cobra.Command{
)
}
machineKey, err := cmd.Flags().GetString("key")
registrationID, err := cmd.Flags().GetString("key")
if err != nil {
ErrorOutput(
err,
@@ -88,8 +88,7 @@ var createNodeCmd = &cobra.Command{
)
}
var mkey key.MachinePublic
err = mkey.UnmarshalText([]byte(machineKey))
_, err = types.RegistrationIDFromString(registrationID)
if err != nil {
ErrorOutput(
err,
@@ -108,7 +107,7 @@ var createNodeCmd = &cobra.Command{
}
request := &v1.DebugCreateNodeRequest{
Key: machineKey,
Key: registrationID,
Name: name,
User: user,
Routes: routes,

View File

@@ -39,33 +39,33 @@ func init() {
err := registerNodeCmd.MarkFlagRequired("user")
if err != nil {
log.Fatalf(err.Error())
log.Fatal(err.Error())
}
registerNodeCmd.Flags().StringP("key", "k", "", "Key")
err = registerNodeCmd.MarkFlagRequired("key")
if err != nil {
log.Fatalf(err.Error())
log.Fatal(err.Error())
}
nodeCmd.AddCommand(registerNodeCmd)
expireNodeCmd.Flags().Uint64P("identifier", "i", 0, "Node identifier (ID)")
err = expireNodeCmd.MarkFlagRequired("identifier")
if err != nil {
log.Fatalf(err.Error())
log.Fatal(err.Error())
}
nodeCmd.AddCommand(expireNodeCmd)
renameNodeCmd.Flags().Uint64P("identifier", "i", 0, "Node identifier (ID)")
err = renameNodeCmd.MarkFlagRequired("identifier")
if err != nil {
log.Fatalf(err.Error())
log.Fatal(err.Error())
}
nodeCmd.AddCommand(renameNodeCmd)
deleteNodeCmd.Flags().Uint64P("identifier", "i", 0, "Node identifier (ID)")
err = deleteNodeCmd.MarkFlagRequired("identifier")
if err != nil {
log.Fatalf(err.Error())
log.Fatal(err.Error())
}
nodeCmd.AddCommand(deleteNodeCmd)
@@ -73,7 +73,7 @@ func init() {
err = moveNodeCmd.MarkFlagRequired("identifier")
if err != nil {
log.Fatalf(err.Error())
log.Fatal(err.Error())
}
moveNodeCmd.Flags().StringP("user", "u", "", "New user")
@@ -85,7 +85,7 @@ func init() {
err = moveNodeCmd.MarkFlagRequired("user")
if err != nil {
log.Fatalf(err.Error())
log.Fatal(err.Error())
}
nodeCmd.AddCommand(moveNodeCmd)
@@ -93,7 +93,7 @@ func init() {
err = tagCmd.MarkFlagRequired("identifier")
if err != nil {
log.Fatalf(err.Error())
log.Fatal(err.Error())
}
tagCmd.Flags().
StringSliceP("tags", "t", []string{}, "List of tags to add to the node")
@@ -122,7 +122,7 @@ var registerNodeCmd = &cobra.Command{
defer cancel()
defer conn.Close()
machineKey, err := cmd.Flags().GetString("key")
registrationID, err := cmd.Flags().GetString("key")
if err != nil {
ErrorOutput(
err,
@@ -132,7 +132,7 @@ var registerNodeCmd = &cobra.Command{
}
request := &v1.RegisterNodeRequest{
Key: machineKey,
Key: registrationID,
User: user,
}

View File

@@ -25,21 +25,21 @@ func init() {
enableRouteCmd.Flags().Uint64P("route", "r", 0, "Route identifier (ID)")
err := enableRouteCmd.MarkFlagRequired("route")
if err != nil {
log.Fatalf(err.Error())
log.Fatal(err.Error())
}
routesCmd.AddCommand(enableRouteCmd)
disableRouteCmd.Flags().Uint64P("route", "r", 0, "Route identifier (ID)")
err = disableRouteCmd.MarkFlagRequired("route")
if err != nil {
log.Fatalf(err.Error())
log.Fatal(err.Error())
}
routesCmd.AddCommand(disableRouteCmd)
deleteRouteCmd.Flags().Uint64P("route", "r", 0, "Route identifier (ID)")
err = deleteRouteCmd.MarkFlagRequired("route")
if err != nil {
log.Fatalf(err.Error())
log.Fatal(err.Error())
}
routesCmd.AddCommand(deleteRouteCmd)
}
@@ -251,10 +251,15 @@ func routesToPtables(routes []*v1.Route) pterm.TableData {
isPrimaryStr = strconv.FormatBool(route.GetIsPrimary())
}
var nodeName string
if route.GetNode() != nil {
nodeName = route.GetNode().GetGivenName()
}
tableData = append(tableData,
[]string{
strconv.FormatUint(route.GetId(), Base10),
route.GetNode().GetGivenName(),
nodeName,
route.GetPrefix(),
strconv.FormatBool(route.GetAdvertised()),
strconv.FormatBool(route.GetEnabled()),

View File

@@ -3,6 +3,7 @@ package cli
import (
"errors"
"fmt"
"net/url"
survey "github.com/AlecAivazis/survey/v2"
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
@@ -40,6 +41,9 @@ func usernameAndIDFromFlag(cmd *cobra.Command) (uint64, string) {
func init() {
rootCmd.AddCommand(userCmd)
userCmd.AddCommand(createUserCmd)
createUserCmd.Flags().StringP("display-name", "d", "", "Display name")
createUserCmd.Flags().StringP("email", "e", "", "Email")
createUserCmd.Flags().StringP("picture-url", "p", "", "Profile picture URL")
userCmd.AddCommand(listUsersCmd)
usernameAndIDFlag(listUsersCmd)
listUsersCmd.Flags().StringP("email", "e", "", "Email")
@@ -83,6 +87,28 @@ var createUserCmd = &cobra.Command{
request := &v1.CreateUserRequest{Name: userName}
if displayName, _ := cmd.Flags().GetString("display-name"); displayName != "" {
request.DisplayName = displayName
}
if email, _ := cmd.Flags().GetString("email"); email != "" {
request.Email = email
}
if pictureURL, _ := cmd.Flags().GetString("picture-url"); pictureURL != "" {
if _, err := url.Parse(pictureURL); err != nil {
ErrorOutput(
err,
fmt.Sprintf(
"Invalid Picture URL: %s",
err,
),
output,
)
}
request.PictureUrl = pictureURL
}
log.Trace().Interface("request", request).Msg("Sending CreateUser request")
response, err := client.CreateUser(ctx, request)
if err != nil {

View File

@@ -301,7 +301,7 @@ dns:
search_domains: []
# Extra DNS records
# so far only A-records are supported (on the tailscale side)
# so far only A and AAAA records are supported (on the tailscale side)
# See: docs/ref/dns.md
extra_records: []
# - name: "grafana.myvpn.example.com"
@@ -310,6 +310,10 @@ dns:
#
# # you can also put it in one line
# - { name: "prometheus.myvpn.example.com", type: "A", value: "100.64.0.3" }
#
# Alternatively, extra DNS records can be loaded from a JSON file.
# Headscale processes this file on each change.
# extra_records_path: /var/lib/headscale/extra-records.json
# Unix socket used for the CLI to connect without authentication
# Note: for production you will want to set this to something like:
@@ -360,6 +364,18 @@ unix_socket_permission: "0770"
# allowed_users:
# - alice@example.com
#
# # Optional: PKCE (Proof Key for Code Exchange) configuration
# # PKCE adds an additional layer of security to the OAuth 2.0 authorization code flow
# # by preventing authorization code interception attacks
# # See https://datatracker.ietf.org/doc/html/rfc7636
# pkce:
# # Enable or disable PKCE support (default: false)
# enabled: false
# # PKCE method to use:
# # - plain: Use plain code verifier
# # - S256: Use SHA256 hashed code verifier (default, recommended)
# method: S256
#
# # Map legacy users from pre-0.24.0 versions of headscale to the new OIDC users
# # by taking the username from the legacy user and matching it with the username
# # provided by the OIDC. This is useful when migrating from legacy users to OIDC
@@ -368,10 +384,10 @@ unix_socket_permission: "0770"
# # Note that this will only work if the username from the legacy user is the same
# # and there is a possibility for account takeover should a username have changed
# # with the provider.
# # Disabling this feature will cause all new logins to be created as new users.
# # When this feature is disabled, it will cause all new logins to be created as new users.
# # Note this option will be removed in the future and should be set to false
# # on all new installations, or when all users have logged in with OIDC once.
# map_legacy_users: true
# map_legacy_users: false
# Logtail configuration
# Logtail is Tailscales logging and auditing infrastructure, it allows the control panel

View File

@@ -10,7 +10,7 @@ headscale.
| OpenBSD | Yes |
| FreeBSD | Yes |
| Windows | Yes (see [docs](../usage/connect/windows.md) and `/windows` on your headscale for more information) |
| Android | Yes (see [docs](../usage/connect/android.md)) |
| Android | Yes (see [docs](../usage/connect/android.md) for more information) |
| macOS | Yes (see [docs](../usage/connect/apple.md#macos) and `/apple` on your headscale for more information) |
| iOS | Yes (see [docs](../usage/connect/apple.md#ios) and `/apple` on your headscale for more information) |
| tvOS | Yes (see [docs](../usage/connect/apple.md#tvos) and `/apple` on your headscale for more information) |

View File

@@ -2,12 +2,12 @@
## What is the design goal of headscale?
Headscale aims to implement a self-hosted, open source alternative to the [Tailscale](https://tailscale.com/)
control server.
Headscale's goal is to provide self-hosters and hobbyists with an open-source
server they can use for their projects and labs.
It implements a narrow scope, a _single_ Tailnet, suitable for a personal use, or a small
open-source organisation.
Headscale aims to implement a self-hosted, open source alternative to the
[Tailscale](https://tailscale.com/) control server. Headscale's goal is to
provide self-hosters and hobbyists with an open-source server they can use for
their projects and labs. It implements a narrow scope, a _single_ Tailscale
network (tailnet), suitable for a personal use, or a small open-source
organisation.
## How can I contribute?

View File

@@ -12,7 +12,7 @@ provides on overview of headscale's feature and compatibility with the Tailscale
- [x] [MagicDNS](https://tailscale.com/kb/1081/magicdns)
- [x] [Global and restricted nameservers (split DNS)](https://tailscale.com/kb/1054/dns#nameservers)
- [x] [search domains](https://tailscale.com/kb/1054/dns#search-domains)
- [x] [Extra DNS records (headscale only)](../ref/dns.md#setting-custom-dns-records)
- [x] [Extra DNS records (headscale only)](../ref/dns.md#setting-extra-dns-records)
- [x] [Taildrop (File Sharing)](https://tailscale.com/kb/1106/taildrop)
- [x] Routing advertising (including exit nodes)
- [x] Dual stack (IPv4 and IPv6)
@@ -25,6 +25,7 @@ provides on overview of headscale's feature and compatibility with the Tailscale
- [ ] `autogroup:member`
* [ ] Node registration using Single-Sign-On (OpenID Connect) ([GitHub label "OIDC"](https://github.com/juanfont/headscale/labels/OIDC))
- [x] Basic registration
- [x] Update user profile from identity provider
- [ ] Dynamic ACL support
- [ ] OIDC groups cannot be used in ACLs
- [ ] [Funnel](https://tailscale.com/kb/1223/funnel) ([#1040](https://github.com/juanfont/headscale/issues/1040))

View File

@@ -14,12 +14,12 @@ Join our [Discord server](https://discord.gg/c84AZQhmpx) for a chat and communit
## Design goal
Headscale aims to implement a self-hosted, open source alternative to the Tailscale
control server.
Headscale's goal is to provide self-hosters and hobbyists with an open-source
server they can use for their projects and labs.
It implements a narrower scope, a single Tailnet, suitable for a personal use, or a small
open-source organisation.
Headscale aims to implement a self-hosted, open source alternative to the
[Tailscale](https://tailscale.com/) control server. Headscale's goal is to
provide self-hosters and hobbyists with an open-source server they can use for
their projects and labs. It implements a narrow scope, a _single_ Tailscale
network (tailnet), suitable for a personal use, or a small open-source
organisation.
## Supporting headscale

View File

@@ -40,9 +40,6 @@ servers.
## ACL setup
Note: Users will be created automatically when users authenticate with the
headscale server.
ACLs have to be written in [huJSON](https://github.com/tailscale/hujson).
When [registering the servers](../usage/getting-started.md#register-a-node) we
@@ -52,11 +49,17 @@ tags to a server they can register, the check of the tags is done on headscale
server and only valid tags are applied. A tag is valid if the user that is
registering it is allowed to do it.
To use ACLs in headscale, you must edit your `config.yaml` file. In there you will find a `policy.path` parameter. This will need to point to your ACL file. More info on how these policies are written can be found [here](https://tailscale.com/kb/1018/acls/).
To use ACLs in headscale, you must edit your `config.yaml` file. In there you will find a `policy.path` parameter. This
will need to point to your ACL file. More info on how these policies are written can be found
[here](https://tailscale.com/kb/1018/acls/).
Please reload or restart Headscale after updating the ACL file. Headscale may be reloaded either via its systemd service
(`sudo systemctl reload headscale`) or by sending a SIGHUP signal (`sudo kill -HUP $(pidof headscale)`) to the main
process. Headscale logs the result of ACL policy processing after each reload.
Here are the ACL's to implement the same permissions as above:
```json
```json title="acl.json"
{
// groups are collections of users having a common scope. A user can be in multiple groups
// groups cannot be composed of groups

View File

@@ -1,44 +1,76 @@
# DNS
Headscale supports [most DNS features](../about/features.md) from Tailscale and DNS releated settings can be configured
in the [configuration file](./configuration.md) within the `dns` section.
Headscale supports [most DNS features](../about/features.md) from Tailscale. DNS related settings can be configured
within `dns` section of the [configuration file](./configuration.md).
## Setting custom DNS records
## Setting extra DNS records
!!! warning "Community documentation"
Headscale allows to set extra DNS records which are made available via
[MagicDNS](https://tailscale.com/kb/1081/magicdns). Extra DNS records can be configured either via static entries in the
[configuration file](./configuration.md) or from a JSON file that Headscale continuously watches for changes:
This page is not actively maintained by the headscale authors and is
written by community members. It is _not_ verified by headscale developers.
* Use the `dns.extra_records` option in the [configuration file](./configuration.md) for entries that are static and
don't change while Headscale is running. Those entries are processed when Headscale is starting up and changes to the
configuration require a restart of Headscale.
* For dynamic DNS records that may be added, updated or removed while Headscale is running or DNS records that are
generated by scripts the option `dns.extra_records_path` in the [configuration file](./configuration.md) is useful.
Set it to the absolute path of the JSON file containing DNS records and Headscale processes this file as it detects
changes.
**It might be outdated and it might miss necessary steps**.
Headscale allows to set custom DNS records which are made available via
[MagicDNS](https://tailscale.com/kb/1081/magicdns). An example use case is to serve multiple apps on the same host via a
reverse proxy like NGINX, in this case a Prometheus monitoring stack. This allows to nicely access the service with
"http://grafana.myvpn.example.com" instead of the hostname and port combination
"http://hostname-in-magic-dns.myvpn.example.com:3000".
An example use case is to serve multiple apps on the same host via a reverse proxy like NGINX, in this case a Prometheus
monitoring stack. This allows to nicely access the service with "http://grafana.myvpn.example.com" instead of the
hostname and port combination "http://hostname-in-magic-dns.myvpn.example.com:3000".
!!! warning "Limitations"
[Not all types of records are supported](https://github.com/tailscale/tailscale/blob/6edf357b96b28ee1be659a70232c0135b2ffedfd/ipn/ipnlocal/local.go#L2989-L3007), especially no CNAME records.
Currently, [only A and AAAA records are processed by Tailscale](https://github.com/tailscale/tailscale/blob/v1.78.3/ipn/ipnlocal/local.go#L4461-L4479).
1. Update the [configuration file](./configuration.md) to contain the desired records like so:
```yaml
dns:
...
extra_records:
- name: "prometheus.myvpn.example.com"
type: "A"
value: "100.64.0.3"
1. Configure extra DNS records using one of the available configuration options:
- name: "grafana.myvpn.example.com"
type: "A"
value: "100.64.0.3"
...
```
=== "Static entries, via `dns.extra_records`"
1. Restart your headscale instance.
```yaml title="config.yaml"
dns:
...
extra_records:
- name: "grafana.myvpn.example.com"
type: "A"
value: "100.64.0.3"
- name: "prometheus.myvpn.example.com"
type: "A"
value: "100.64.0.3"
...
```
Restart your headscale instance.
=== "Dynamic entries, via `dns.extra_records_path`"
```json title="extra-records.json"
[
{
"name": "grafana.myvpn.example.com",
"type": "A",
"value": "100.64.0.3"
},
{
"name": "prometheus.myvpn.example.com",
"type": "A",
"value": "100.64.0.3"
}
]
```
Headscale picks up changes to the above JSON file automatically.
!!! tip "Good to know"
* The `dns.extra_records_path` option in the [configuration file](./configuration.md) needs to reference the
JSON file containing extra DNS records.
* Be sure to "sort keys" and produce a stable output in case you generate the JSON file with a script.
Headscale uses a checksum to detect changes to the file and a stable output avoids unnecessary processing.
1. Verify that DNS records are properly set using the DNS querying tool of your choice:
@@ -61,7 +93,7 @@ reverse proxy like NGINX, in this case a Prometheus monitoring stack. This allow
The motivating example here was to be able to access internal monitoring services on the same host without
specifying a port, depicted as NGINX configuration snippet:
```
```nginx title="nginx.conf"
server {
listen 80;
listen [::]:80;

View File

@@ -23,7 +23,7 @@ Running headscale behind a cloudflare proxy or cloudflare tunnel is not supporte
Headscale can be configured not to use TLS, leaving it to the reverse proxy to handle. Add the following configuration values to your headscale config file.
```yaml
```yaml title="config.yaml"
server_url: https://<YOUR_SERVER_NAME> # This should be the FQDN at which headscale will be served
listen_addr: 0.0.0.0:8080
metrics_listen_addr: 0.0.0.0:9090
@@ -35,7 +35,7 @@ tls_key_path: ""
The following example configuration can be used in your nginx setup, substituting values as necessary. `<IP:PORT>` should be the IP address and port where headscale is running. In most cases, this will be `http://localhost:8080`.
```Nginx
```nginx title="nginx.conf"
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
@@ -113,7 +113,7 @@ spec:
The following Caddyfile is all that is necessary to use Caddy as a reverse proxy for headscale, in combination with the `config.yaml` specifications above to disable headscale's built in TLS. Replace values as necessary - `<YOUR_SERVER_NAME>` should be the FQDN at which headscale will be served, and `<IP:PORT>` should be the IP address and port where headscale is running. In most cases, this will be `localhost:8080`.
```
```none title="Caddyfile"
<YOUR_SERVER_NAME> {
reverse_proxy <IP:PORT>
}
@@ -127,7 +127,7 @@ For a slightly more complex configuration which utilizes Docker containers to ma
The following minimal Apache config will proxy traffic to the headscale instance on `<IP:PORT>`. Note that `upgrade=any` is required as a parameter for `ProxyPass` so that WebSockets traffic whose `Upgrade` header value is not equal to `WebSocket` (i. e. Tailscale Control Protocol) is forwarded correctly. See the [Apache docs](https://httpd.apache.org/docs/2.4/mod/mod_proxy_wstunnel.html) for more information on this.
```
```apache title="apache.conf"
<VirtualHost *:443>
ServerName <YOUR_SERVER_NAME>

View File

@@ -11,7 +11,7 @@ Headscale doesn't provide a built-in web interface but users may pick one from t
| --------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| headscale-webui | [Github](https://github.com/ifargle/headscale-webui) | A simple headscale web UI for small-scale deployments. |
| headscale-ui | [Github](https://github.com/gurucomputing/headscale-ui) | A web frontend for the headscale Tailscale-compatible coordination server |
| HeadscaleUi | [GitHub](https://github.com/simcu/headscale-ui) | A static headscale admin ui, no backend enviroment required |
| HeadscaleUi | [GitHub](https://github.com/simcu/headscale-ui) | A static headscale admin ui, no backend environment required |
| Headplane | [GitHub](https://github.com/tale/headplane) | An advanced Tailscale inspired frontend for headscale |
| headscale-admin | [Github](https://github.com/GoodiesHQ/headscale-admin) | Headscale-Admin is meant to be a simple, modern web interface for headscale |
| ouroboros | [Github](https://github.com/yellowsink/ouroboros) | Ouroboros is designed for users to manage their own devices, rather than for admins |

View File

@@ -11,7 +11,7 @@ Known limitations:
In your `config.yaml`, customize this to your liking:
```yaml
```yaml title="config.yaml"
oidc:
# Block further startup until the OIDC provider is healthy and available
only_start_if_oidc_is_available: true
@@ -45,6 +45,18 @@ oidc:
allowed_users:
- alice@example.com
# Optional: PKCE (Proof Key for Code Exchange) configuration
# PKCE adds an additional layer of security to the OAuth 2.0 authorization code flow
# by preventing authorization code interception attacks
# See https://datatracker.ietf.org/doc/html/rfc7636
pkce:
# Enable or disable PKCE support (default: false)
enabled: false
# PKCE method to use:
# - plain: Use plain code verifier
# - S256: Use SHA256 hashed code verifier (default, recommended)
method: S256
# If `strip_email_domain` is set to `true`, the domain part of the username email address will be removed.
# This will transform `first-name.last-name@example.com` to the user `first-name.last-name`
# If `strip_email_domain` is set to `false` the domain part will NOT be removed resulting to the following
@@ -56,7 +68,7 @@ oidc:
In order to integrate headscale with Azure Active Directory, we'll need to provision an App Registration with the correct scopes and redirect URI. Here with Terraform:
```hcl
```hcl title="terraform.hcl"
resource "azuread_application" "headscale" {
display_name = "Headscale"
@@ -127,7 +139,7 @@ output "headscale_client_secret" {
And in your headscale `config.yaml`:
```yaml
```yaml title="config.yaml"
oidc:
issuer: "https://login.microsoftonline.com/<tenant-UUID>/v2.0"
client_id: "<client-id-from-terraform>"
@@ -162,7 +174,7 @@ However if you don't have a domain, or need to add users outside of your domain,
8. Click `Save` at the bottom of the form
9. Take note of the `Client ID` and `Client secret`, you can also download it for reference if you need it.
10. Edit your headscale config, under `oidc`, filling in your `client_id` and `client_secret`:
```yaml
```yaml title="config.yaml"
oidc:
issuer: "https://accounts.google.com"
client_id: ""

View File

@@ -54,7 +54,7 @@ headscale apikeys expire --prefix "<PREFIX>"
=== "Minimal YAML configuration file"
```yaml
```yaml title="config.yaml"
cli:
address: <HEADSCALE_ADDRESS>:<PORT>
api_key: <API_KEY_FROM_PREVIOUS_STEP>
@@ -69,7 +69,7 @@ headscale apikeys expire --prefix "<PREFIX>"
!!! bug
Headscale 0.23.0 requires at least an empty configuration file when environment variables are used to
Headscale currently requires at least an empty configuration file when environment variables are used to
specify connection details. See [issue 2193](https://github.com/juanfont/headscale/issues/2193) for more
information.

View File

@@ -4,7 +4,7 @@
Headscale can be configured to expose its web service via TLS. To configure the certificate and key file manually, set the `tls_cert_path` and `tls_cert_path` configuration parameters. If the path is relative, it will be interpreted as relative to the directory the configuration file was read from.
```yaml
```yaml title="config.yaml"
tls_cert_path: ""
tls_key_path: ""
```
@@ -15,7 +15,7 @@ The certificate should contain the full chain, else some clients, like the Tails
To get a certificate automatically via [Let's Encrypt](https://letsencrypt.org/), set `tls_letsencrypt_hostname` to the desired certificate hostname. This name must resolve to the IP address(es) headscale is reachable on (i.e., it must correspond to the `server_url` configuration parameter). The certificate and Let's Encrypt account credentials will be stored in the directory configured in `tls_letsencrypt_cache_dir`. If the path is relative, it will be interpreted as relative to the directory the configuration file was read from.
```yaml
```yaml title="config.yaml"
tls_letsencrypt_hostname: ""
tls_letsencrypt_listen: ":http"
tls_letsencrypt_cache_dir: ".cache"

View File

@@ -1,25 +0,0 @@
# Running headscale in a cloud
!!! warning "Community documentation"
This page is not actively maintained by the headscale authors and is
written by community members. It is _not_ verified by headscale developers.
**It might be outdated and it might miss necessary steps**.
## Sealos
[Deploy headscale as service on Sealos.](https://icloudnative.io/en/posts/how-to-set-up-or-migrate-headscale/)
1. Click the following prebuilt template:
[![](https://cdn.jsdelivr.net/gh/labring-actions/templates@main/Deploy-on-Sealos.svg)](https://cloud.sealos.io/?openapp=system-template%3FtemplateName%3Dheadscale)
2. Click "Deploy Application" on the template page to start deployment. Upon completion, two applications appear: headscale, and one of its [web interfaces](../../ref/integration/web-ui.md).
3. Once deployment concludes, click 'Details' on the headscale application page to navigate to the application's details.
4. Wait for the application's status to switch to running. For accessing the headscale server, the Public Address associated with port 8080 is the address of the headscale server. To access the headscale console, simply append `/admin/` to the headscale public URL.
!!! tip "Remote CLI"
Headscale can be managed remotely via its remote CLI support. See our [Controlling headscale with remote
CLI](../../ref/remote-cli.md) documentation for details.

View File

@@ -4,7 +4,7 @@ Several Linux distributions and community members provide packages for headscale
the [official releases](./official.md) provided by the headscale maintainers. Such packages offer improved integration
for their targeted operating system and usually:
- setup a dedicated user account to run headscale
- setup a dedicated local user account to run headscale
- provide a default configuration
- install headscale as system service

View File

@@ -89,7 +89,7 @@ not work with alternatives like [Podman](https://podman.io). The Docker image ca
curl http://127.0.0.1:9090/metrics
```
1. Create a user ([tailnet](https://tailscale.com/kb/1136/tailnet/)):
1. Create a headscale user:
```shell
docker exec -it headscale \

View File

@@ -6,8 +6,8 @@ Both are available on the [GitHub releases page](https://github.com/juanfont/hea
## Using packages for Debian/Ubuntu (recommended)
It is recommended to use our DEB packages to install headscale on a Debian based system as those packages configure a
user to run headscale, provide a default configuration and ship with a systemd service file. Supported distributions are
Ubuntu 20.04 or newer, Debian 11 or newer.
local user to run headscale, provide a default configuration and ship with a systemd service file. Supported
distributions are Ubuntu 20.04 or newer, Debian 11 or newer.
1. Download the [latest headscale package](https://github.com/juanfont/headscale/releases/latest) for your platform (`.deb` for Ubuntu and Debian).
@@ -46,13 +46,13 @@ Ubuntu 20.04 or newer, Debian 11 or newer.
!!! warning "Advanced"
This installation method is considered advanced as one needs to take care of the headscale user and the systemd
This installation method is considered advanced as one needs to take care of the local user and the systemd
service themselves. If possible, use the [DEB packages](#using-packages-for-debianubuntu-recommended) or a
[community package](./community.md) instead.
This section describes the installation of headscale according to the [Requirements and
assumptions](../requirements.md#assumptions). Headscale is run by a dedicated user and the service itself is managed by
systemd.
assumptions](../requirements.md#assumptions). Headscale is run by a dedicated local user and the service itself is
managed by systemd.
1. Download the latest [`headscale` binary from GitHub's release page](https://github.com/juanfont/headscale/releases):
@@ -67,7 +67,7 @@ systemd.
sudo chmod +x /usr/local/bin/headscale
```
1. Add a dedicated user to run headscale:
1. Add a dedicated local user to run headscale:
```shell
sudo useradd \
@@ -94,7 +94,7 @@ systemd.
1. In `/etc/headscale/config.yaml`, override the default `headscale` unix socket with a path that is writable by the
`headscale` user or group:
```yaml
```yaml title="config.yaml"
unix_socket: /var/run/headscale/headscale.sock
```

View File

@@ -16,7 +16,7 @@ README](https://github.com/juanfont/headscale#contributing) for more information
### Install from source
```shell
# Install prerequistes
# Install prerequisites
pkg_add go
git clone https://github.com/juanfont/headscale.git
@@ -42,7 +42,7 @@ cp headscale /usr/local/sbin
### Install from source via cross compile
```shell
# Install prerequistes
# Install prerequisites
# 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

View File

@@ -6,14 +6,14 @@ Headscale should just work as long as the following requirements are met:
recommended.
- Headscale is served via HTTPS on port 443[^1].
- A reasonably modern Linux or BSD based operating system.
- A dedicated user account to run headscale.
- A dedicated local user account to run headscale.
- A little bit of command line knowledge to configure and operate headscale.
## Assumptions
The headscale documentation and the provided examples are written with a few assumptions in mind:
- Headscale is running as system service via a dedicated user `headscale`.
- Headscale is running as system service via a dedicated local user `headscale`.
- The [configuration](../ref/configuration.md) is loaded from `/etc/headscale/config.yaml`.
- SQLite is used as database.
- The data directory for headscale (used for private keys, ACLs, SQLite database, …) is located in `/var/lib/headscale`.

View File

@@ -1,6 +1,6 @@
# Upgrade an existing installation
An existing headscale installation can be updated to a new version:
Update an existing headscale installation to a new version:
- Read the announcement on the [GitHub releases](https://github.com/juanfont/headscale/releases) page for the new
version. It lists the changes of the release along with possible breaking changes.

View File

@@ -15,14 +15,10 @@ Install the official Tailscale iOS client from the [App Store](https://apps.appl
### Configuring the headscale URL
- Open Tailscale and make sure you are _not_ logged in to any account
- Open Settings on the iOS device
- Scroll down to the `third party apps` section, under `Game Center` or `TV Provider`
- Find Tailscale and select it
- If the iOS device was previously logged into Tailscale, switch the `Reset Keychain` toggle to `on`
- Enter the URL of your headscale instance (e.g `https://headscale.example.com`) under `Alternate Coordination Server URL`
- Restart the app by closing it from the iOS app switcher, open the app and select the regular sign in option
_(non-SSO)_. It should open up to the headscale authentication page.
- Open the Tailscale app
- Click the account icon in the top-right corner and select `Log in…`.
- Tap the top-right options menu button and select `Use custom coordination server`.
- Enter your instance url (e.g `https://headscale.example.com`)
- Enter your credentials and log in. Headscale should now be working on your iOS device.
## macOS
@@ -43,7 +39,7 @@ tailscale login --login-server <YOUR_HEADSCALE_URL>
#### GUI
- ALT + Click the Tailscale icon in the menu and hover over the Debug menu
- Option + Click the Tailscale icon in the menu and hover over the Debug menu
- Under `Custom Login Server`, select `Add Account...`
- Enter the URL of your headscale instance (e.g `https://headscale.example.com`) and press `Add Account`
- Follow the login procedure in the browser

View File

@@ -41,13 +41,14 @@ options, run:
headscale <COMMAND> --help
```
## Manage users
## Manage headscale users
In headscale, a node (also known as machine or device) is always assigned to a specific user, a
[tailnet](https://tailscale.com/kb/1136/tailnet/). Such users can be managed with the `headscale users` command. Invoke
the built-in help for more information: `headscale users --help`.
In headscale, a node (also known as machine or device) is always assigned to a
headscale user. Such a headscale user may have many nodes assigned to them and
can be managed with the `headscale users` command. Invoke the built-in help for
more information: `headscale users --help`.
### Create a user
### Create a headscale user
=== "Native"
@@ -62,7 +63,7 @@ the built-in help for more information: `headscale users --help`.
headscale users create <USER>
```
### List existing users
### List existing headscale users
=== "Native"

6
flake.lock generated
View File

@@ -20,11 +20,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1733376361,
"narHash": "sha256-aLJxoTDDSqB+/3orsulE6/qdlX6MzDLIITLZqdgMpqo=",
"lastModified": 1738297584,
"narHash": "sha256-AYvaFBzt8dU0fcSK2jKD0Vg23K2eIRxfsVXIPCW9a0E=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "929116e316068c7318c54eb4d827f7d9756d5e9c",
"rev": "9189ac18287c599860e878e905da550aa6dec1cd",
"type": "github"
},
"original": {

View File

@@ -31,8 +31,8 @@
checkFlags = ["-short"];
# When updating go.mod or go.sum, a new sha will need to be calculated,
# update this if you have a mismatch after doing a change to thos files.
vendorHash = "sha256-NyXMSIVcmPlUhE3LmEsYZQxJdz+e435r+GZC8umQKqQ=";
# update this if you have a mismatch after doing a change to those files.
vendorHash = "sha256-ZQj2A0GdLhHc7JLW7qgpGBveXXNWg9ueSG47OZQQXEw=";
subPackages = ["cmd/headscale"];

View File

@@ -1,6 +1,6 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.35.1
// protoc-gen-go v1.35.2
// protoc (unknown)
// source: headscale/v1/apikey.proto

View File

@@ -1,6 +1,6 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.35.1
// protoc-gen-go v1.35.2
// protoc (unknown)
// source: headscale/v1/device.proto

View File

@@ -1,6 +1,6 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.35.1
// protoc-gen-go v1.35.2
// protoc (unknown)
// source: headscale/v1/headscale.proto

View File

@@ -1,6 +1,6 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.35.1
// protoc-gen-go v1.35.2
// protoc (unknown)
// source: headscale/v1/node.proto

View File

@@ -1,6 +1,6 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.35.1
// protoc-gen-go v1.35.2
// protoc (unknown)
// source: headscale/v1/policy.proto

View File

@@ -1,6 +1,6 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.35.1
// protoc-gen-go v1.35.2
// protoc (unknown)
// source: headscale/v1/preauthkey.proto

View File

@@ -1,6 +1,6 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.35.1
// protoc-gen-go v1.35.2
// protoc (unknown)
// source: headscale/v1/routes.proto

View File

@@ -1,6 +1,6 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.35.1
// protoc-gen-go v1.35.2
// protoc (unknown)
// source: headscale/v1/user.proto
@@ -127,7 +127,10 @@ type CreateUserRequest struct {
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
DisplayName string `protobuf:"bytes,2,opt,name=display_name,json=displayName,proto3" json:"display_name,omitempty"`
Email string `protobuf:"bytes,3,opt,name=email,proto3" json:"email,omitempty"`
PictureUrl string `protobuf:"bytes,4,opt,name=picture_url,json=pictureUrl,proto3" json:"picture_url,omitempty"`
}
func (x *CreateUserRequest) Reset() {
@@ -167,6 +170,27 @@ func (x *CreateUserRequest) GetName() string {
return ""
}
func (x *CreateUserRequest) GetDisplayName() string {
if x != nil {
return x.DisplayName
}
return ""
}
func (x *CreateUserRequest) GetEmail() string {
if x != nil {
return x.Email
}
return ""
}
func (x *CreateUserRequest) GetPictureUrl() string {
if x != nil {
return x.PictureUrl
}
return ""
}
type CreateUserResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
@@ -520,38 +544,43 @@ var file_headscale_v1_user_proto_rawDesc = []byte{
0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x72,
0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x26, 0x0a, 0x0f, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c,
0x65, 0x5f, 0x70, 0x69, 0x63, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52,
0x0d, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x50, 0x69, 0x63, 0x55, 0x72, 0x6c, 0x22, 0x27,
0x0a, 0x11, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75,
0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28,
0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x3c, 0x0a, 0x12, 0x43, 0x72, 0x65, 0x61, 0x74,
0x0d, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x50, 0x69, 0x63, 0x55, 0x72, 0x6c, 0x22, 0x81,
0x01, 0x0a, 0x11, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x71,
0x75, 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01,
0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x64, 0x69, 0x73, 0x70,
0x6c, 0x61, 0x79, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b,
0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x65,
0x6d, 0x61, 0x69, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x6d, 0x61, 0x69,
0x6c, 0x12, 0x1f, 0x0a, 0x0b, 0x70, 0x69, 0x63, 0x74, 0x75, 0x72, 0x65, 0x5f, 0x75, 0x72, 0x6c,
0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x70, 0x69, 0x63, 0x74, 0x75, 0x72, 0x65, 0x55,
0x72, 0x6c, 0x22, 0x3c, 0x0a, 0x12, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72,
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x26, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72,
0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x68, 0x65, 0x61, 0x64, 0x73, 0x63, 0x61,
0x6c, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72,
0x22, 0x45, 0x0a, 0x11, 0x52, 0x65, 0x6e, 0x61, 0x6d, 0x65, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65,
0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x15, 0x0a, 0x06, 0x6f, 0x6c, 0x64, 0x5f, 0x69, 0x64, 0x18,
0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x05, 0x6f, 0x6c, 0x64, 0x49, 0x64, 0x12, 0x19, 0x0a, 0x08,
0x6e, 0x65, 0x77, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07,
0x6e, 0x65, 0x77, 0x4e, 0x61, 0x6d, 0x65, 0x22, 0x3c, 0x0a, 0x12, 0x52, 0x65, 0x6e, 0x61, 0x6d,
0x65, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x26, 0x0a,
0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x68, 0x65,
0x61, 0x64, 0x73, 0x63, 0x61, 0x6c, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x52,
0x04, 0x75, 0x73, 0x65, 0x72, 0x22, 0x45, 0x0a, 0x11, 0x52, 0x65, 0x6e, 0x61, 0x6d, 0x65, 0x55,
0x73, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x15, 0x0a, 0x06, 0x6f, 0x6c,
0x64, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x05, 0x6f, 0x6c, 0x64, 0x49,
0x64, 0x12, 0x19, 0x0a, 0x08, 0x6e, 0x65, 0x77, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20,
0x01, 0x28, 0x09, 0x52, 0x07, 0x6e, 0x65, 0x77, 0x4e, 0x61, 0x6d, 0x65, 0x22, 0x3c, 0x0a, 0x12,
0x52, 0x65, 0x6e, 0x61, 0x6d, 0x65, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
0x73, 0x65, 0x12, 0x26, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b,
0x32, 0x12, 0x2e, 0x68, 0x65, 0x61, 0x64, 0x73, 0x63, 0x61, 0x6c, 0x65, 0x2e, 0x76, 0x31, 0x2e,
0x55, 0x73, 0x65, 0x72, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x22, 0x23, 0x0a, 0x11, 0x44, 0x65,
0x6c, 0x65, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12,
0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x02, 0x69, 0x64, 0x22,
0x14, 0x0a, 0x12, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x73,
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x4c, 0x0a, 0x10, 0x4c, 0x69, 0x73, 0x74, 0x55, 0x73, 0x65,
0x72, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18,
0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d,
0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x14, 0x0a,
0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x6d,
0x61, 0x69, 0x6c, 0x22, 0x3d, 0x0a, 0x11, 0x4c, 0x69, 0x73, 0x74, 0x55, 0x73, 0x65, 0x72, 0x73,
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x28, 0x0a, 0x05, 0x75, 0x73, 0x65, 0x72,
0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x68, 0x65, 0x61, 0x64, 0x73, 0x63,
0x61, 0x6c, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x52, 0x05, 0x75, 0x73, 0x65,
0x72, 0x73, 0x42, 0x29, 0x5a, 0x27, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d,
0x2f, 0x6a, 0x75, 0x61, 0x6e, 0x66, 0x6f, 0x6e, 0x74, 0x2f, 0x68, 0x65, 0x61, 0x64, 0x73, 0x63,
0x61, 0x6c, 0x65, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x67, 0x6f, 0x2f, 0x76, 0x31, 0x62, 0x06, 0x70,
0x72, 0x6f, 0x74, 0x6f, 0x33,
0x04, 0x75, 0x73, 0x65, 0x72, 0x22, 0x23, 0x0a, 0x11, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x55,
0x73, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64,
0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x02, 0x69, 0x64, 0x22, 0x14, 0x0a, 0x12, 0x44, 0x65,
0x6c, 0x65, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
0x22, 0x4c, 0x0a, 0x10, 0x4c, 0x69, 0x73, 0x74, 0x55, 0x73, 0x65, 0x72, 0x73, 0x52, 0x65, 0x71,
0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04,
0x52, 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01,
0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x6d, 0x61, 0x69,
0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x22, 0x3d,
0x0a, 0x11, 0x4c, 0x69, 0x73, 0x74, 0x55, 0x73, 0x65, 0x72, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f,
0x6e, 0x73, 0x65, 0x12, 0x28, 0x0a, 0x05, 0x75, 0x73, 0x65, 0x72, 0x73, 0x18, 0x01, 0x20, 0x03,
0x28, 0x0b, 0x32, 0x12, 0x2e, 0x68, 0x65, 0x61, 0x64, 0x73, 0x63, 0x61, 0x6c, 0x65, 0x2e, 0x76,
0x31, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x52, 0x05, 0x75, 0x73, 0x65, 0x72, 0x73, 0x42, 0x29, 0x5a,
0x27, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6a, 0x75, 0x61, 0x6e,
0x66, 0x6f, 0x6e, 0x74, 0x2f, 0x68, 0x65, 0x61, 0x64, 0x73, 0x63, 0x61, 0x6c, 0x65, 0x2f, 0x67,
0x65, 0x6e, 0x2f, 0x67, 0x6f, 0x2f, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}
var (

View File

@@ -1039,6 +1039,15 @@
"properties": {
"name": {
"type": "string"
},
"displayName": {
"type": "string"
},
"email": {
"type": "string"
},
"pictureUrl": {
"type": "string"
}
}
},

130
go.mod
View File

@@ -4,50 +4,52 @@ go 1.23.1
require (
github.com/AlecAivazis/survey/v2 v2.3.7
github.com/chasefleming/elem-go v0.29.0
github.com/cenkalti/backoff/v4 v4.3.0
github.com/chasefleming/elem-go v0.30.0
github.com/coder/websocket v1.8.12
github.com/coreos/go-oidc/v3 v3.11.0
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc
github.com/fsnotify/fsnotify v1.8.0
github.com/glebarez/sqlite v1.11.0
github.com/go-gormigrate/gormigrate/v2 v2.1.2
github.com/go-gormigrate/gormigrate/v2 v2.1.3
github.com/gofrs/uuid/v5 v5.3.0
github.com/google/go-cmp v0.6.0
github.com/gorilla/mux v1.8.1
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0
github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0
github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0
github.com/jagottsicher/termcolor v1.0.2
github.com/klauspost/compress v1.17.9
github.com/klauspost/compress v1.17.11
github.com/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25
github.com/ory/dockertest/v3 v3.11.0
github.com/philip-bui/grpc-zerolog v1.0.1
github.com/pkg/profile v1.7.0
github.com/prometheus/client_golang v1.20.2
github.com/prometheus/common v0.58.0
github.com/pterm/pterm v0.12.79
github.com/prometheus/client_golang v1.20.5
github.com/prometheus/common v0.61.0
github.com/pterm/pterm v0.12.80
github.com/puzpuzpuz/xsync/v3 v3.4.0
github.com/rs/zerolog v1.33.0
github.com/samber/lo v1.47.0
github.com/sasha-s/go-deadlock v0.3.5
github.com/spf13/cobra v1.8.1
github.com/spf13/viper v1.20.0-alpha.6
github.com/stretchr/testify v1.9.0
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a
github.com/tailscale/tailsql v0.0.0-20240418235827-820559f382c1
github.com/stretchr/testify v1.10.0
github.com/tailscale/hujson v0.0.0-20241010212012-29efb4a0184b
github.com/tailscale/tailsql v0.0.0-20241211062219-bf96884c6a49
github.com/tcnksm/go-latest v0.0.0-20170313132115-e3007ae9052e
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba
golang.org/x/crypto v0.26.0
golang.org/x/exp v0.0.0-20240823005443-9b4947da3948
golang.org/x/net v0.28.0
golang.org/x/oauth2 v0.22.0
golang.org/x/sync v0.8.0
google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1
google.golang.org/grpc v1.66.0
google.golang.org/protobuf v1.35.1
golang.org/x/crypto v0.32.0
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8
golang.org/x/net v0.34.0
golang.org/x/oauth2 v0.25.0
golang.org/x/sync v0.10.0
google.golang.org/genproto/googleapis/api v0.0.0-20241216192217-9240e9c98484
google.golang.org/grpc v1.69.0
google.golang.org/protobuf v1.36.0
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/postgres v1.5.9
gorm.io/gorm v1.25.11
tailscale.com v1.75.0-pre.0.20240926101731-7d1160ddaab7
gorm.io/driver/postgres v1.5.11
gorm.io/gorm v1.25.12
tailscale.com v1.80.0
zgo.at/zcache/v2 v2.1.0
zombiezen.com/go/postgrestest v1.0.1
)
@@ -73,7 +75,7 @@ require (
modernc.org/libc v1.55.3 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
modernc.org/sqlite v1.33.1 // indirect
modernc.org/sqlite v1.34.5 // indirect
)
require (
@@ -87,43 +89,41 @@ require (
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect
github.com/akutz/memconn v0.1.0 // indirect
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect
github.com/aws/aws-sdk-go-v2 v1.24.1 // indirect
github.com/aws/aws-sdk-go-v2/config v1.26.6 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.16.16 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.7.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10 // indirect
github.com/aws/aws-sdk-go-v2 v1.26.1 // indirect
github.com/aws/aws-sdk-go-v2/config v1.27.11 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.11 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.7 // indirect
github.com/aws/aws-sdk-go-v2/service/ssm v1.45.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.18.7 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.26.7 // indirect
github.com/aws/smithy-go v1.19.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.20.5 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.28.6 // indirect
github.com/aws/smithy-go v1.20.2 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bits-and-blooms/bitset v1.13.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/containerd/console v1.0.4 // indirect
github.com/containerd/continuity v0.4.5 // indirect
github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 // indirect
github.com/creachadair/mds v0.14.5 // indirect
github.com/creachadair/mds v0.20.0 // indirect
github.com/dblohm7/wingoes v0.0.0-20240123200102-b75a8a7d7eb0 // indirect
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e // indirect
github.com/docker/cli v27.3.1+incompatible // indirect
github.com/docker/docker v27.3.1+incompatible // indirect
github.com/docker/cli v27.4.1+incompatible // indirect
github.com/docker/docker v27.4.1+incompatible // indirect
github.com/docker/go-connections v0.5.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/felixge/fgprof v0.9.5 // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/fxamacker/cbor/v2 v2.6.0 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/gaissmai/bart v0.11.1 // indirect
github.com/glebarez/go-sqlite v1.22.0 // indirect
github.com/go-jose/go-jose/v3 v3.0.3 // indirect
github.com/go-jose/go-jose/v4 v4.0.2 // indirect
github.com/go-json-experiment/json v0.0.0-20231102232822-2e55bd4e08b0 // indirect
github.com/go-json-experiment/json v0.0.0-20250103232110-6a9a0fde9288 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect
@@ -135,11 +135,11 @@ require (
github.com/google/go-github v17.0.0+incompatible // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 // indirect
github.com/google/pprof v0.0.0-20240829160300-da1f7e9f2b25 // indirect
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gookit/color v1.5.4 // indirect
github.com/gorilla/csrf v1.7.2 // indirect
github.com/gorilla/csrf v1.7.3-0.20250123201450-9dd6af1f6d30 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/hashicorp/go-version v1.7.0 // indirect
github.com/hdevalence/ed25519consensus v0.2.0 // indirect
@@ -148,12 +148,11 @@ require (
github.com/insomniacslk/dhcp v0.0.0-20240129002554-15c9b8791914 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/jackc/pgx/v5 v5.7.1 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86 // indirect
github.com/jsimonetti/rtnetlink v1.4.1 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a // indirect
@@ -165,7 +164,7 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mdlayher/genetlink v1.3.2 // indirect
github.com/mdlayher/netlink v1.7.2 // indirect
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect
github.com/mdlayher/sdnotify v1.0.0 // indirect
github.com/mdlayher/socket v0.5.0 // indirect
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
@@ -178,9 +177,9 @@ require (
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.0 // indirect
github.com/opencontainers/runc v1.2.2 // indirect
github.com/opencontainers/runc v1.2.3 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 // indirect
github.com/petermattis/goid v0.0.0-20241211131331-93ee7e083c43 // indirect
github.com/pierrec/lz4/v4 v4.1.21 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
@@ -189,7 +188,7 @@ require (
github.com/prometheus/procfs v0.15.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.12.0 // indirect
github.com/rogpeppe/go-internal v1.13.1 // indirect
github.com/safchain/ethtool v0.3.0 // indirect
github.com/sagikazarmark/locafero v0.6.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
@@ -203,13 +202,12 @@ require (
github.com/tailscale/golang-x-crypto v0.0.0-20240604161659-3fde5e568aa4 // indirect
github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 // indirect
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 // indirect
github.com/tailscale/peercred v0.0.0-20240214030740-b535050b2aa4 // indirect
github.com/tailscale/setec v0.0.0-20240314234648-9da8e7407257 // indirect
github.com/tailscale/squibble v0.0.0-20240418235321-9ee0eeb78185 // indirect
github.com/tailscale/web-client-prebuilt v0.0.0-20240226180453-5db17b287bf1 // indirect
github.com/tailscale/wireguard-go v0.0.0-20240905161824-799c1978fafc // indirect
github.com/tcnksm/go-httpstat v0.2.0 // indirect
github.com/u-root/uio v0.0.0-20240118234441-a3c409a6018e // indirect
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect
github.com/tailscale/setec v0.0.0-20240930150730-e6eb93658ed3 // indirect
github.com/tailscale/squibble v0.0.0-20240909231413-32a80b9743f7 // indirect
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect
github.com/tailscale/wireguard-go v0.0.0-20250107165329-0b8b35511f19 // indirect
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect
github.com/vishvananda/netns v0.0.4 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
@@ -217,16 +215,16 @@ require (
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.uber.org/multierr v1.11.0 // indirect
go4.org/mem v0.0.0-20220726221520-4f986261bf13 // indirect
golang.org/x/mod v0.20.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/term v0.23.0 // indirect
golang.org/x/text v0.17.0 // indirect
golang.org/x/time v0.5.0 // indirect
golang.org/x/tools v0.24.0 // indirect
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect
golang.org/x/mod v0.22.0 // indirect
golang.org/x/sys v0.29.1-0.20250107080300-1c14dcadc3ab // indirect
golang.org/x/term v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
golang.org/x/time v0.9.0 // indirect
golang.org/x/tools v0.29.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20241216192217-9240e9c98484 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gvisor.dev/gvisor v0.0.0-20240722211153-64c016c92987 // indirect
)

309
go.sum
View File

@@ -42,44 +42,44 @@ github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1L
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/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk=
github.com/aws/aws-sdk-go-v2 v1.24.1 h1:xAojnj+ktS95YZlDf0zxWBkbFtymPeDP+rvUQIH3uAU=
github.com/aws/aws-sdk-go-v2 v1.24.1/go.mod h1:LNh45Br1YAkEKaAqvmE1m8FUx6a5b/V0oAKV7of29b4=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4 h1:OCs21ST2LrepDfD3lwlQiOqIGp6JiEUqG84GzTDoyJs=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4/go.mod h1:usURWEKSNNAcAZuzRn/9ZYPT8aZQkR7xcCtunK/LkJo=
github.com/aws/aws-sdk-go-v2/config v1.26.6 h1:Z/7w9bUqlRI0FFQpetVuFYEsjzE3h7fpU6HuGmfPL/o=
github.com/aws/aws-sdk-go-v2/config v1.26.6/go.mod h1:uKU6cnDmYCvJ+pxO9S4cWDb2yWWIH5hra+32hVh1MI4=
github.com/aws/aws-sdk-go-v2/credentials v1.16.16 h1:8q6Rliyv0aUFAVtzaldUEcS+T5gbadPbWdV1WcAddK8=
github.com/aws/aws-sdk-go-v2/credentials v1.16.16/go.mod h1:UHVZrdUsv63hPXFo1H7c5fEneoVo9UXiz36QG1GEPi0=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11 h1:c5I5iH+DZcH3xOIMlz3/tCKJDaHFwYEmxvlh2fAcFo8=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11/go.mod h1:cRrYDYAMUohBJUtUnOhydaMHtiK/1NZ0Otc9lIb6O0Y=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10 h1:vF+Zgd9s+H4vOXd5BMaPWykta2a6Ih0AKLq/X6NYKn4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10/go.mod h1:6BkRjejp/GR4411UGqkX8+wFMbFbqsUIimfK4XjOKR4=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10 h1:nYPe006ktcqUji8S2mqXf9c/7NdiKriOwMvWQHgYztw=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10/go.mod h1:6UV4SZkVvmODfXKql4LCbaZUpF7HO2BX38FgBf9ZOLw=
github.com/aws/aws-sdk-go-v2/internal/ini v1.7.3 h1:n3GDfwqF2tzEkXlv5cuy4iy7LpKDtqDMcNLfZDu9rls=
github.com/aws/aws-sdk-go-v2/internal/ini v1.7.3/go.mod h1:6fQQgfuGmw8Al/3M2IgIllycxV7ZW7WCdVSqfBeUiCY=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9 h1:ugD6qzjYtB7zM5PN/ZIeaAIyefPaD82G8+SJopgvUpw=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9/go.mod h1:YD0aYBWCrPENpHolhKw2XDlTIWae2GKXT1T4o6N6hiM=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 h1:/b31bi3YVNlkzkBrm9LfpaKoaYZUxIAj4sHfOTmLfqw=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4/go.mod h1:2aGXHFmbInwgP9ZfpmdIfOELL79zhdNYNmReK8qDfdQ=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9 h1:/90OR2XbSYfXucBMJ4U14wrjlfleq/0SB6dZDPncgmo=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9/go.mod h1:dN/Of9/fNZet7UrQQ6kTDo/VSwKPIq94vjlU16bRARc=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10 h1:DBYTXwIGQSGs9w4jKm60F5dmCQ3EEruxdc0MFh+3EY4=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10/go.mod h1:wohMUQiFdzo0NtxbBg0mSRGZ4vL3n0dKjLTINdcIino=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9 h1:iEAeF6YC3l4FzlJPP9H3Ko1TXpdjdqWffxXjp8SY6uk=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9/go.mod h1:kjsXoK23q9Z/tLBrckZLLyvjhZoS+AGrzqzUfEClvMM=
github.com/aws/aws-sdk-go-v2/service/s3 v1.47.7 h1:o0ASbVwUAIrfp/WcCac+6jioZt4Hd8k/1X8u7GJ/QeM=
github.com/aws/aws-sdk-go-v2/service/s3 v1.47.7/go.mod h1:vADO6Jn+Rq4nDtfwNjhgR84qkZwiC6FqCaXdw/kYwjA=
github.com/aws/aws-sdk-go-v2 v1.26.1 h1:5554eUqIYVWpU0YmeeYZ0wU64H2VLBs8TlhRB2L+EkA=
github.com/aws/aws-sdk-go-v2 v1.26.1/go.mod h1:ffIFB97e2yNsv4aTSGkqtHnppsIJzw7G7BReUZ3jCXM=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.2 h1:x6xsQXGSmW6frevwDA+vi/wqhp1ct18mVXYN08/93to=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.2/go.mod h1:lPprDr1e6cJdyYeGXnRaJoP4Md+cDBvi2eOj00BlGmg=
github.com/aws/aws-sdk-go-v2/config v1.27.11 h1:f47rANd2LQEYHda2ddSCKYId18/8BhSRM4BULGmfgNA=
github.com/aws/aws-sdk-go-v2/config v1.27.11/go.mod h1:SMsV78RIOYdve1vf36z8LmnszlRWkwMQtomCAI0/mIE=
github.com/aws/aws-sdk-go-v2/credentials v1.17.11 h1:YuIB1dJNf1Re822rriUOTxopaHHvIq0l/pX3fwO+Tzs=
github.com/aws/aws-sdk-go-v2/credentials v1.17.11/go.mod h1:AQtFPsDH9bI2O+71anW6EKL+NcD7LG3dpKGMV4SShgo=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.1 h1:FVJ0r5XTHSmIHJV6KuDmdYhEpvlHpiSd38RQWhut5J4=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.1/go.mod h1:zusuAeqezXzAB24LGuzuekqMAEgWkVYukBec3kr3jUg=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5 h1:aw39xVGeRWlWx9EzGVnhOR4yOjQDHPQ6o6NmBlscyQg=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5/go.mod h1:FSaRudD0dXiMPK2UjknVwwTYyZMRsHv3TtkabsZih5I=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5 h1:PG1F3OD1szkuQPzDw3CIQsRIrtTlUC3lP84taWzHlq0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5/go.mod h1:jU1li6RFryMz+so64PpKtudI+QzbKoIEivqdf6LNpOc=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.5 h1:81KE7vaZzrl7yHBYHVEzYB8sypz11NMOZ40YlWvPxsU=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.5/go.mod h1:LIt2rg7Mcgn09Ygbdh/RdIm0rQ+3BNkbP1gyVMFtRK0=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2 h1:Ji0DY1xUsUr3I8cHps0G+XM3WWU16lP6yG8qu1GAZAs=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2/go.mod h1:5CsjAbs3NlGQyZNFACh+zztPDI7fU6eW9QsxjfnuBKg=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.7 h1:ZMeFZ5yk+Ek+jNr1+uwCd2tG89t6oTS5yVWpa6yy2es=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.7/go.mod h1:mxV05U+4JiHqIpGqqYXOHLPKUC6bDXC44bsUhNjOEwY=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.7 h1:ogRAwT1/gxJBcSWDMZlgyFUM962F51A5CRhDLbxLdmo=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.7/go.mod h1:YCsIZhXfRPLFFCl5xxY+1T9RKzOKjCut+28JSX2DnAk=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.5 h1:f9RyWNtS8oH7cZlbn+/JNPpjUk5+5fLd5lM9M0i49Ys=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.5/go.mod h1:h5CoMZV2VF297/VLhRhO1WF+XYWOzXo+4HsObA4HjBQ=
github.com/aws/aws-sdk-go-v2/service/s3 v1.53.1 h1:6cnno47Me9bRykw9AEv9zkXE+5or7jz8TsskTTccbgc=
github.com/aws/aws-sdk-go-v2/service/s3 v1.53.1/go.mod h1:qmdkIIAC+GCLASF7R2whgNrJADz0QZPX+Seiw/i4S3o=
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.18.7 h1:eajuO3nykDPdYicLlP3AGgOyVN3MOlFmZv7WGTuJPow=
github.com/aws/aws-sdk-go-v2/service/sso v1.18.7/go.mod h1:+mJNDdF+qiUlNKNC3fxn74WWNN+sOiGOEImje+3ScPM=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7 h1:QPMJf+Jw8E1l7zqhZmMlFw6w1NmfkfiSK8mS4zOx3BA=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7/go.mod h1:ykf3COxYI0UJmxcfcxcVuz7b6uADi1FkiUz6Eb7AgM8=
github.com/aws/aws-sdk-go-v2/service/sts v1.26.7 h1:NzO4Vrau795RkUdSHKEwiR01FaGzGOH1EETJ+5QHnm0=
github.com/aws/aws-sdk-go-v2/service/sts v1.26.7/go.mod h1:6h2YuIoxaMSCFf5fi1EgZAwdfkGMgDY+DVfa61uLe4U=
github.com/aws/smithy-go v1.19.0 h1:KWFKQV80DpP3vJrrA9sVAHQ5gc2z8i4EzrLhLlWXcBM=
github.com/aws/smithy-go v1.19.0/go.mod h1:NukqUGpCZIILqqiV0NIjeFh24kd/FAa4beRb6nbIUPE=
github.com/aws/aws-sdk-go-v2/service/sso v1.20.5 h1:vN8hEbpRnL7+Hopy9dzmRle1xmDc7o8tmY0klsr175w=
github.com/aws/aws-sdk-go-v2/service/sso v1.20.5/go.mod h1:qGzynb/msuZIE8I75DVRCUXw3o3ZyBmUvMwQ2t/BrGM=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.4 h1:Jux+gDDyi1Lruk+KHF91tK2KCuY61kzoCpvtvJJBtOE=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.4/go.mod h1:mUYPBhaF2lGiukDEjJX2BLRRKTmoUSitGDUgM4tRxak=
github.com/aws/aws-sdk-go-v2/service/sts v1.28.6 h1:cwIxeBttqPN3qkaAjcEcsh8NYr8n2HZPkcKgPAi1phU=
github.com/aws/aws-sdk-go-v2/service/sts v1.28.6/go.mod h1:FZf1/nKNEkHdGGJP/cI2MoIMquumuRK6ol3QQJNDxmw=
github.com/aws/smithy-go v1.20.2 h1:tbp628ireGtzcHDDmLT/6ADHidqnwgF57XOXZe6tp4Q=
github.com/aws/smithy-go v1.20.2/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
@@ -90,8 +90,8 @@ github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyY
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chasefleming/elem-go v0.29.0 h1:WwrjQcVn6xldhexluvl2Z3sgKi9HTMuzWeEXO4PHsmg=
github.com/chasefleming/elem-go v0.29.0/go.mod h1:hz73qILBIKnTgOujnSMtEj20/epI+f6vg71RUilJAA4=
github.com/chasefleming/elem-go v0.30.0 h1:BlhV1ekv1RbFiM8XZUQeln1Ikb4D+bu2eDO4agREvok=
github.com/chasefleming/elem-go v0.30.0/go.mod h1:hz73qILBIKnTgOujnSMtEj20/epI+f6vg71RUilJAA4=
github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
github.com/chromedp/chromedp v0.9.2/go.mod h1:LkSXJKONWTCHAfQasKFUZI+mxqS4tZqhmtGzzhLsnLs=
github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
@@ -118,8 +118,8 @@ github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creachadair/mds v0.14.5 h1:2amuO4yCbQkaAyDoLO5iCbwbTRQZz4EpRhOejQbf4+8=
github.com/creachadair/mds v0.14.5/go.mod h1:4vrFYUzTXMJpMBU+OA292I6IUxKWCCfZkgXg+/kBZMo=
github.com/creachadair/mds v0.20.0 h1:bXQO154c2TDgCY+rRmdIfUqjeqGYejmZ/QayeTNwbp8=
github.com/creachadair/mds v0.20.0/go.mod h1:4b//mUiL8YldH6TImXjmW45myzTLNS1LLjOmrk888eg=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/creack/pty v1.1.23 h1:4M6+isWdcStXEf15G/RbrMPOQj1dZ7HPZCGwE4kOeP0=
@@ -134,10 +134,10 @@ github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yez
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A=
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
github.com/docker/cli v27.3.1+incompatible h1:qEGdFBF3Xu6SCvCYhc7CzaQTlBmqDuzxPDpigSyeKQQ=
github.com/docker/cli v27.3.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI=
github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/cli v27.4.1+incompatible h1:VzPiUlRJ/xh+otB75gva3r05isHMo5wXDfPRi5/b4hI=
github.com/docker/cli v27.4.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/docker v27.4.1+incompatible h1:ZJvcY7gfwHn1JF48PfbyXg7Jyt9ZCWDW+GGXOIxEwp4=
github.com/docker/docker v27.4.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
@@ -155,12 +155,10 @@ github.com/felixge/fgprof v0.9.5 h1:8+vR6yu2vvSKn08urWyEuxx75NWPEvybbkBirEpsbVY=
github.com/felixge/fgprof v0.9.5/go.mod h1:yKl+ERSa++RYOs32d8K6WEXCB4uXdLls4ZaZPpayhMM=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fxamacker/cbor/v2 v2.6.0 h1:sU6J2usfADwWlYDAFhZBQ6TnLFBHxgesMrQfQgk1tWA=
github.com/fxamacker/cbor/v2 v2.6.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/gaissmai/bart v0.11.1 h1:5Uv5XwsaFBRo4E5VBcb9TzY8B7zxFf+U7isDxqOrRfc=
github.com/gaissmai/bart v0.11.1/go.mod h1:KHeYECXQiBjTzQz/om2tqn3sZF1J7hw9m6z41ftj3fg=
github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I=
@@ -169,16 +167,20 @@ github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-gormigrate/gormigrate/v2 v2.1.2 h1:F/d1hpHbRAvKezziV2CC5KUE82cVe9zTgHSBoOOZ4CY=
github.com/go-gormigrate/gormigrate/v2 v2.1.2/go.mod h1:9nHVX6z3FCMCQPA7PThGcA55t22yKQfK/Dnsf5i7hUo=
github.com/go-gormigrate/gormigrate/v2 v2.1.3 h1:ei3Vq/rpPI/jCJY9mRHJAKg5vU+EhZyWhBAkaAomQuw=
github.com/go-gormigrate/gormigrate/v2 v2.1.3/go.mod h1:VJ9FIOBAur+NmQ8c4tDVwOuiJcgupTG105FexPFrXzA=
github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k=
github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk=
github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
github.com/go-json-experiment/json v0.0.0-20231102232822-2e55bd4e08b0 h1:ymLjT4f35nQbASLnvxEde4XOBL+Sn7rFuV+FOJqkljg=
github.com/go-json-experiment/json v0.0.0-20231102232822-2e55bd4e08b0/go.mod h1:6daplAwHHGbUGib4990V3Il26O0OC4aRyvewaaAihaA=
github.com/go-json-experiment/json v0.0.0-20250103232110-6a9a0fde9288 h1:KbX3Z3CgiYlbaavUq3Cj9/MjpO+88S7/AGXzynVDv84=
github.com/go-json-experiment/json v0.0.0-20250103232110-6a9a0fde9288/go.mod h1:BWmvoE1Xia34f3l/ibJweyhrT+aROb/FQ6d+37F0e2s=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
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=
@@ -224,8 +226,8 @@ github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdF
github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4=
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg=
github.com/google/pprof v0.0.0-20240227163752-401108e1b7e7/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik=
github.com/google/pprof v0.0.0-20240829160300-da1f7e9f2b25 h1:sEDPKUw6iPjczdu33njxFjO6tYa9bfc0z/QyB/zSsBw=
github.com/google/pprof v0.0.0-20240829160300-da1f7e9f2b25/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg=
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -234,20 +236,18 @@ github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQ
github.com/gookit/color v1.5.0/go.mod h1:43aQb+Zerm/BWh2GnrgOQm7ffz7tvQXEKV6BFMl7wAo=
github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0=
github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w=
github.com/gorilla/csrf v1.7.2 h1:oTUjx0vyf2T+wkrx09Trsev1TE+/EbDAeHtSTbtC2eI=
github.com/gorilla/csrf v1.7.2/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
github.com/gorilla/csrf v1.7.3-0.20250123201450-9dd6af1f6d30 h1:fiJdrgVBkjZ5B1HJ2WQwNOaXB+QyYcNXTA3t1XYLz0M=
github.com/gorilla/csrf v1.7.3-0.20250123201450-9dd6af1f6d30/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI=
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 h1:TmHmbvxPmaegwhDubVz0lICL0J5Ka2vwTzhoePEXsGE=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0/go.mod h1:qztMSjm835F2bXf+5HKAPIS5qsmQDqZna/PgVt4rWtI=
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU=
github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo=
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
@@ -264,10 +264,10 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs=
github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jagottsicher/termcolor v1.0.2 h1:fo0c51pQSuLBN1+yVX2ZE+hE+P7ULb/TY8eRowJnrsM=
github.com/jagottsicher/termcolor v1.0.2/go.mod h1:RcH8uFwF/0wbEdQmi83rjmlJ+QOKdMSE9Rc1BEB7zFo=
github.com/jellydator/ttlcache/v3 v3.1.0 h1:0gPFG0IHHP6xyUyXq+JaD8fwkDCqgqwohXNJBcYE71g=
@@ -281,17 +281,14 @@ github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHW
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/josharian/native v1.0.1-0.20221213033349-c1e37c09b531/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86 h1:elKwZS1OcdQ0WwEDBeqxKwb7WB62QX8bvZ/FJnVXIfk=
github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86/go.mod h1:aFAMtuldEgx/4q7iSGazk22+IcgvtiC+HIimFO9XlS8=
github.com/jsimonetti/rtnetlink v1.4.1 h1:JfD4jthWBqZMEffc5RjgmlzpYttAVw1sdnmiNaPO3hE=
github.com/jsimonetti/rtnetlink v1.4.1/go.mod h1:xJjT7t59UIZ62GLZbv6PLLo8VFrostJMPBAheR6OM8w=
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/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
@@ -332,8 +329,8 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw=
github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o=
github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g=
github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw=
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg=
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o=
github.com/mdlayher/sdnotify v1.0.0 h1:Ma9XeLVN/l0qpyx1tNeMSeTjCPH6NtuD6/N9XdTlQ3c=
github.com/mdlayher/sdnotify v1.0.0/go.mod h1:HQUmpM4XgYkhDLtd+Uad8ZFK1T9D5+pNxnXQjCeJlGE=
github.com/mdlayher/socket v0.5.0 h1:ilICZmJcQz70vrWVes1MFera4jGiWNocSkykwwoy3XI=
@@ -363,19 +360,19 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/opencontainers/runc v1.2.2 h1:jTg3Vw2A5f0N9PoxFTEwUhvpANGaNPT3689Yfd/zaX0=
github.com/opencontainers/runc v1.2.2/go.mod h1:/PXzF0h531HTMsYQnmxXkBD7YaGShm/2zcRB79dksUc=
github.com/opencontainers/runc v1.2.3 h1:fxE7amCzfZflJO2lHXf4y/y8M1BoAqp+FVmG19oYB80=
github.com/opencontainers/runc v1.2.3/go.mod h1:nSxcWUydXrsBZVYNSkTjoQ/N6rcyTtn+1SD5D4+kRIM=
github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/ory/dockertest/v3 v3.11.0 h1:OiHcxKAvSDUwsEVh2BjxQQc/5EHz9n0va9awCtNGuyA=
github.com/ory/dockertest/v3 v3.11.0/go.mod h1:VIPxS1gwT9NpPOrfD3rACs8Y9Z7yhzO4SB194iUDnUI=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 h1:Dx7Ovyv/SFnMFw3fD4oEoeorXc6saIiQ23LrGLth0Gw=
github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/petermattis/goid v0.0.0-20241211131331-93ee7e083c43 h1:ah1dvbqPMN5+ocrg/ZSgZ6k8bOk+kcZQ7fnyx6UvOm4=
github.com/petermattis/goid v0.0.0-20241211131331-93ee7e083c43/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/philip-bui/grpc-zerolog v1.0.1 h1:EMacvLRUd2O1K0eWod27ZP5CY1iTNkhBDLSN+Q4JEvA=
github.com/philip-bui/grpc-zerolog v1.0.1/go.mod h1:qXbiq/2X4ZUMMshsqlWyTHOcw7ns+GZmlqZZN05ZHcQ=
github.com/pierrec/lz4/v4 v4.1.14/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
@@ -391,13 +388,13 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus-community/pro-bing v0.4.0 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyfyrrjEaAchdy3R4=
github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4=
github.com/prometheus/client_golang v1.20.2 h1:5ctymQzZlyOON1666svgwn3s6IKWgfbjsejTMiXIyjg=
github.com/prometheus/client_golang v1.20.2/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.58.0 h1:N+N8vY4/23r6iYfD3UQZUoJPnUYAo7v6LG5XZxjZTXo=
github.com/prometheus/common v0.58.0/go.mod h1:GpWM7dewqmVYcd7SmRaiWVe9SSqjf0UrwnYnpEZNuT0=
github.com/prometheus/common v0.61.0 h1:3gv/GThfX0cV2lpO7gkTUwZru38mxevy90Bj8YFSRQQ=
github.com/prometheus/common v0.61.0/go.mod h1:zr29OCN/2BsJRaFwG8QOBr41D6kkchKbpeNH7pAjb/s=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/pterm/pterm v0.12.27/go.mod h1:PhQ89w4i95rhgE+xedAoqous6K9X+r6aSOI2eFF7DZI=
@@ -407,8 +404,8 @@ github.com/pterm/pterm v0.12.31/go.mod h1:32ZAWZVXD7ZfG0s8qqHXePte42kdz8ECtRyEej
github.com/pterm/pterm v0.12.33/go.mod h1:x+h2uL+n7CP/rel9+bImHD5lF3nM9vJj80k9ybiiTTE=
github.com/pterm/pterm v0.12.36/go.mod h1:NjiL09hFhT/vWjQHSj1athJpx6H8cjpHXNAK5bUw8T8=
github.com/pterm/pterm v0.12.40/go.mod h1:ffwPLwlbXxP+rxT0GsgDTzS3y3rmpAO1NMjUkGTYf8s=
github.com/pterm/pterm v0.12.79 h1:lH3yrYMhdpeqX9y5Ep1u7DejyHy7NSQg9qrBjF9dFT4=
github.com/pterm/pterm v0.12.79/go.mod h1:1v/gzOF1N0FsjbgTHZ1wVycRkKiatFvJSJC4IGaQAAo=
github.com/pterm/pterm v0.12.80 h1:mM55B+GnKUnLMUSqhdINe4s6tOuVQIetQ3my8JGyAIg=
github.com/pterm/pterm v0.12.80/go.mod h1:c6DeF9bSnOSeFPZlfs4ZRAFcf5SCoTwvwQ5xaKGQlHo=
github.com/puzpuzpuz/xsync/v3 v3.4.0 h1:DuVBAdXuGFHv8adVXjWWZ63pJq+NRXOWVXlKDBZ+mJ4=
github.com/puzpuzpuz/xsync/v3 v3.4.0/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@@ -417,8 +414,8 @@ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
@@ -432,8 +429,8 @@ github.com/samber/lo v1.47.0/go.mod h1:RmDH9Ct32Qy3gduHQuKJ3gW1fMHAnE/fAzQuf6He5
github.com/sasha-s/go-deadlock v0.3.5 h1:tNCOEEDG6tBqrNDOX35j/7hL5FcFViG6awUGROb2NsU=
github.com/sasha-s/go-deadlock v0.3.5/go.mod h1:bugP6EGbdGYObIlx7pUZtWqlvo8k9H6vCBBsiChJQ5U=
github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
@@ -461,8 +458,8 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
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.20231202035212-d3fa0460f47e h1:PtWT87weP5LWHEY//SWsYkSO3RWRZo4OSWagh3YD2vQ=
@@ -473,38 +470,36 @@ github.com/tailscale/golang-x-crypto v0.0.0-20240604161659-3fde5e568aa4 h1:rXZGg
github.com/tailscale/golang-x-crypto v0.0.0-20240604161659-3fde5e568aa4/go.mod h1:ikbF+YT089eInTp9f2vmvy4+ZVnW5hzX1q2WknxSprQ=
github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 h1:4chzWmimtJPxRs2O36yuGRW3f9SYV+bMTTvMBI0EKio=
github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05/go.mod h1:PdCqy9JzfWMJf1H5UJW2ip33/d4YkoKN0r67yKH1mG8=
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a h1:SJy1Pu0eH1C29XwJucQo73FrleVK6t4kYz4NVhp34Yw=
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8=
github.com/tailscale/hujson v0.0.0-20241010212012-29efb4a0184b h1:MNaGusDfB1qxEsl6iVb33Gbe777IKzPP5PDta0xGC8M=
github.com/tailscale/hujson v0.0.0-20241010212012-29efb4a0184b/go.mod h1:EbW0wDK/qEUYI0A5bqq0C2kF8JTQwWONmGDBbzsxxHo=
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU=
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0=
github.com/tailscale/peercred v0.0.0-20240214030740-b535050b2aa4 h1:Gz0rz40FvFVLTBk/K8UNAenb36EbDSnh+q7Z9ldcC8w=
github.com/tailscale/peercred v0.0.0-20240214030740-b535050b2aa4/go.mod h1:phI29ccmHQBc+wvroosENp1IF9195449VDnFDhJ4rJU=
github.com/tailscale/setec v0.0.0-20240314234648-9da8e7407257 h1:6WsbDYsikRNmmbfZoRoyIEA9tfl0aspPAE0t7nBj2B4=
github.com/tailscale/setec v0.0.0-20240314234648-9da8e7407257/go.mod h1:hrq01/0LUDZf4mMkcZ7Ovmy33jvCi4RpESpb9kPxV6E=
github.com/tailscale/squibble v0.0.0-20240418235321-9ee0eeb78185 h1:zT+qB+2Ghulj50d5Wq6h6vQYqD2sPdhy4FF6+FHedVE=
github.com/tailscale/squibble v0.0.0-20240418235321-9ee0eeb78185/go.mod h1:LoIjI6z/6efr9ebISQ5l2vjQmjc8QJrAYZdy3Ec3sVs=
github.com/tailscale/tailsql v0.0.0-20240418235827-820559f382c1 h1:wmsnxEEuRlgK7Bhdkmm0JGrjjc0JoHZThLLo0WXXbLs=
github.com/tailscale/tailsql v0.0.0-20240418235827-820559f382c1/go.mod h1:XN193fbz9RR/5stlWPMMIZR+TTa1BUkDJm5Azwzxwgw=
github.com/tailscale/web-client-prebuilt v0.0.0-20240226180453-5db17b287bf1 h1:tdUdyPqJ0C97SJfjB9tW6EylTtreyee9C44de+UBG0g=
github.com/tailscale/web-client-prebuilt v0.0.0-20240226180453-5db17b287bf1/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ=
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+yfntqhI3oAu9i27nEojcQ4NuBQOo5ZFA=
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc=
github.com/tailscale/setec v0.0.0-20240930150730-e6eb93658ed3 h1:Zk341hE1rcVUcDwA9XKmed2acHGGlbeFQzje6gvkuFo=
github.com/tailscale/setec v0.0.0-20240930150730-e6eb93658ed3/go.mod h1:nexjfRM8veJVJ5PTbqYI2YrUj/jbk3deffEHO3DH9Q4=
github.com/tailscale/squibble v0.0.0-20240909231413-32a80b9743f7 h1:nfklwaP8uNz2IbUygSKOQ1aDzzRRRLaIbPpnQWUUMGc=
github.com/tailscale/squibble v0.0.0-20240909231413-32a80b9743f7/go.mod h1:YH/J7n7jNZOq10nTxxPANv2ha/Eg47/6J5b7NnOYAhQ=
github.com/tailscale/tailsql v0.0.0-20241211062219-bf96884c6a49 h1:QFXXdoiYFiUS7a6DH7zE6Uacz3wMzH/1/VvWLnR9To4=
github.com/tailscale/tailsql v0.0.0-20241211062219-bf96884c6a49/go.mod h1:IX3F8T6iILmg94hZGkkOf6rmjIHJCXNVqxOpiSUwHQQ=
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:UBPHPtv8+nEAy2PD8RyAhOYvau1ek0HDJqLS/Pysi14=
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/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-20240905161824-799c1978fafc h1:cezaQN9pvKVaw56Ma5qr/G646uKIYP0yQf+OyWN/okc=
github.com/tailscale/wireguard-go v0.0.0-20240905161824-799c1978fafc/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
github.com/tailscale/wireguard-go v0.0.0-20250107165329-0b8b35511f19 h1:BcEJP2ewTIK2ZCsqgl6YGpuO6+oKqqag5HHb7ehljKw=
github.com/tailscale/wireguard-go v0.0.0-20250107165329-0b8b35511f19/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek=
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg=
github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA=
github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk=
github.com/tcnksm/go-httpstat v0.2.0 h1:rP7T5e5U2HfmOBmZzGgGZjBQ5/GluWUylujl0tJ04I0=
github.com/tcnksm/go-httpstat v0.2.0/go.mod h1:s3JVJFtQxtBEBC9dwcdTTXS9xFnM3SXAZwPG41aurT8=
github.com/tcnksm/go-latest v0.0.0-20170313132115-e3007ae9052e h1:IWllFTiDjjLIf2oeKxpIUmtiDV5sn71VgeQgg6vcE7k=
github.com/tcnksm/go-latest v0.0.0-20170313132115-e3007ae9052e/go.mod h1:d7u6HkTYKSv5m6MCKkOQlHwaShTMl3HjqSGW3XtVhXM=
github.com/tink-crypto/tink-go/v2 v2.1.0 h1:QXFBguwMwTIaU17EgZpEJWsUSc60b1BAGTzBIoMdmok=
github.com/tink-crypto/tink-go/v2 v2.1.0/go.mod h1:y1TnYFt1i2eZVfx4OGc+C+EMp4CoKWAw2VSEuoicHHI=
github.com/u-root/u-root v0.12.0 h1:K0AuBFriwr0w/PGS3HawiAw89e3+MU7ks80GpghAsNs=
github.com/u-root/u-root v0.12.0/go.mod h1:FYjTOh4IkIZHhjsd17lb8nYW6udgXdJhG1c0r6u0arI=
github.com/u-root/uio v0.0.0-20240118234441-a3c409a6018e h1:BA9O3BmlTmpjbvajAwzWx4Wo2TRVdpPXZEeemGQcajw=
github.com/u-root/uio v0.0.0-20240118234441-a3c409a6018e/go.mod h1:eLL9Nub3yfAho7qB0MzZizFhTU2QkLeoVsWdHtDW264=
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/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0=
github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8=
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
@@ -523,14 +518,26 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJu
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.33.0 h1:/FerN9bax5LoK51X/sI0SVYrjSE0/yUL7DpxW4K3FWw=
go.opentelemetry.io/otel v1.33.0/go.mod h1:SUUkR6csvUQl+yjReHu5uM3EtVV7MBm5FHKRlNx4I8I=
go.opentelemetry.io/otel/metric v1.33.0 h1:r+JOocAyeRVXD8lZpjdQjzMadVZp2M4WmQ+5WtEnklQ=
go.opentelemetry.io/otel/metric v1.33.0/go.mod h1:L9+Fyctbp6HFTddIxClbQkjtubW6O9QS3Ann/M82u6M=
go.opentelemetry.io/otel/sdk v1.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk=
go.opentelemetry.io/otel/sdk v1.31.0/go.mod h1:TfRbMdhvxIIr/B2N2LQW2S5v9m3gOQ/08KsbbO5BPT0=
go.opentelemetry.io/otel/sdk/metric v1.31.0 h1:i9hxxLJF/9kkvfHppyLL55aW7iIJz4JjxTeYusH7zMc=
go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8=
go.opentelemetry.io/otel/trace v1.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qqW2d/s=
go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.18.1/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI=
go4.org/mem v0.0.0-20220726221520-4f986261bf13 h1:CbZeCBZ0aZj8EfVgnqQcYZgf0lpZ3H9rmp5nkDTAst8=
go4.org/mem v0.0.0-20220726221520-4f986261bf13/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek=
go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@@ -538,15 +545,15 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
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.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 h1:kx6Ds3MlpiUHKj7syVnbp57++8WpuKPcR5yjLBjvLEA=
golang.org/x/exp v0.0.0-20240823005443-9b4947da3948/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ=
golang.org/x/exp/typeparams v0.0.0-20240119083558-1b970713d09a h1:8qmSSA8Gz/1kTrCe0nqR0R3Gb/NDhykzWw2q2mWZydM=
golang.org/x/exp/typeparams v0.0.0-20240119083558-1b970713d09a/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA=
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f h1:phY1HzDcf18Aq9A8KkmRtY9WvOFIxN8wgfvy6Zm1DV8=
golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
@@ -555,8 +562,8 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
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.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0=
golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -569,11 +576,11 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.22.0 h1:BzDx2FehcG7jJwgWLELCdmLuxk2i+x9UDpSiss2u0ZA=
golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -582,8 +589,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ
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.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -603,20 +610,18 @@ golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.4.1-0.20230131160137-e7d7f63158de/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.1-0.20250107080300-1c14dcadc3ab h1:BMkEEWYOjkvOX7+YKOGbp6jCyQ5pR2j0Ah47p1Vdsx4=
golang.org/x/sys v0.29.1-0.20250107080300-1c14dcadc3ab/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -624,8 +629,8 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU=
golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk=
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
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=
@@ -633,10 +638,10 @@ golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
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.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
@@ -648,8 +653,8 @@ golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roY
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
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.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24=
golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ=
golang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE=
golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -663,19 +668,19 @@ google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 h1:hjSy6tcFQZ171igDaN5QHOw2n6vx40juYbC/x67CEhc=
google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:qpvKtACPCQhAdu3PyQgV4l3LMXZEtft7y8QcarRsp9I=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
google.golang.org/genproto/googleapis/api v0.0.0-20241216192217-9240e9c98484 h1:ChAdCYNQFDk5fYvFZMywKLIijG7TC2m1C2CMEu11G3o=
google.golang.org/genproto/googleapis/api v0.0.0-20241216192217-9240e9c98484/go.mod h1:KRUmxRI4JmbpAm8gcZM4Jsffi859fo5LQjILwuqj9z8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20241216192217-9240e9c98484 h1:Z7FRVJPSMaHQxD0uXU8WdgFh8PseLM8Q8NzhnpMrBhQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20241216192217-9240e9c98484/go.mod h1:lcTa1sDdWEIHMWlITnIczmw5w60CF9ffkb8Z+DVmmjA=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
google.golang.org/grpc v1.66.0 h1:DibZuoBznOxbDQxRINckZcUvnCEvrW9pcWIE2yF9r1c=
google.golang.org/grpc v1.66.0/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y=
google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
google.golang.org/grpc v1.69.0 h1:quSiOM1GJPmPH5XtU+BCoVXcDVJJAzNcoyfC2cCjGkI=
google.golang.org/grpc v1.69.0/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4=
google.golang.org/protobuf v1.36.0 h1:mjIs9gYtt56AzC4ZaffQuh88TZurBGhIJMBZGSxNerQ=
google.golang.org/protobuf v1.36.0/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -690,10 +695,10 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.5.9 h1:DkegyItji119OlcaLjqN11kHoUgZ/j13E0jkJZgD6A8=
gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
gorm.io/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg=
gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
gvisor.dev/gvisor v0.0.0-20240722211153-64c016c92987 h1:TU8z2Lh3Bbq77w0t1eG8yRlLcNHzZu3x6mhoH2Mk0c8=
@@ -712,8 +717,6 @@ modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
@@ -724,16 +727,16 @@ modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM=
modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
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.75.0-pre.0.20240926101731-7d1160ddaab7 h1:nfRWV6ECxwNvvXKtbqSVstjlEi1BWktzv3FuxWpyyx0=
tailscale.com v1.75.0-pre.0.20240926101731-7d1160ddaab7/go.mod h1:xKxYf3B3PuezFlRaMT+VhuVu8XTFUTLy+VCzLPMJVmg=
tailscale.com v1.80.0 h1:7joWtDtdHEHJvGmOag10RNITKp1I4Ts7Hrn6pU33/1I=
tailscale.com v1.80.0/go.mod h1:4tasV1xjJAMHuX2xWMWAnXEmlrAA6M3w1xnc32DlpMk=
zgo.at/zcache/v2 v2.1.0 h1:USo+ubK+R4vtjw4viGzTe/zjXyPw6R7SK/RL3epBBxs=
zgo.at/zcache/v2 v2.1.0/go.mod h1:gyCeoLVo01QjDZynjime8xUGHHMbsLiPyUTBpDGd4Gk=
zombiezen.com/go/postgrestest v1.0.1 h1:aXoADQAJmZDU3+xilYVut0pHhgc0sF8ZspPW9gFNwP4=

View File

@@ -24,6 +24,7 @@ import (
grpcRuntime "github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"github.com/juanfont/headscale"
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
"github.com/juanfont/headscale/hscontrol/capver"
"github.com/juanfont/headscale/hscontrol/db"
"github.com/juanfont/headscale/hscontrol/derp"
derpServer "github.com/juanfont/headscale/hscontrol/derp/server"
@@ -96,7 +97,7 @@ type Headscale struct {
mapper *mapper.Mapper
nodeNotifier *notifier.Notifier
registrationCache *zcache.Cache[string, types.Node]
registrationCache *zcache.Cache[types.RegistrationID, types.RegisterNode]
authProvider AuthProvider
@@ -123,7 +124,7 @@ func NewHeadscale(cfg *types.Config) (*Headscale, error) {
return nil, fmt.Errorf("failed to read or create Noise protocol private key: %w", err)
}
registrationCache := zcache.New[string, types.Node](
registrationCache := zcache.New[types.RegistrationID, types.RegisterNode](
registerCacheExpiration,
registerCacheCleanup,
)
@@ -245,11 +246,11 @@ func (h *Headscale) scheduledTasks(ctx context.Context) {
lastExpiryCheck := time.Unix(0, 0)
derpTicker := time.NewTicker(h.cfg.DERP.UpdateFrequency)
defer derpTicker.Stop()
// If we dont want auto update, just stop the ticker
if !h.cfg.DERP.AutoUpdate {
derpTicker.Stop()
derpTickerChan := make(<-chan time.Time)
if h.cfg.DERP.AutoUpdate && h.cfg.DERP.UpdateFrequency != 0 {
derpTicker := time.NewTicker(h.cfg.DERP.UpdateFrequency)
defer derpTicker.Stop()
derpTickerChan = derpTicker.C
}
var extraRecordsUpdate <-chan []tailcfg.DNSRecord
@@ -285,7 +286,7 @@ func (h *Headscale) scheduledTasks(ctx context.Context) {
h.nodeNotifier.NotifyAll(ctx, update)
}
case <-derpTicker.C:
case <-derpTickerChan:
log.Info().Msg("Fetching DERPMap updates")
h.DERPMap = derp.GetDERPMap(h.cfg.DERP)
if h.cfg.DERP.ServerEnabled && h.cfg.DERP.AutomaticallyAddEmbeddedDerpRegion {
@@ -462,7 +463,7 @@ func (h *Headscale) createRouter(grpcMux *grpcRuntime.ServeMux) *mux.Router {
router.HandleFunc("/health", h.HealthHandler).Methods(http.MethodGet)
router.HandleFunc("/key", h.KeyHandler).Methods(http.MethodGet)
router.HandleFunc("/register/{mkey}", h.authProvider.RegisterHandler).Methods(http.MethodGet)
router.HandleFunc("/register/{registration_id}", h.authProvider.RegisterHandler).Methods(http.MethodGet)
if provider, ok := h.authProvider.(*AuthProviderOIDC); ok {
router.HandleFunc("/oidc/callback", provider.OIDCCallbackHandler).Methods(http.MethodGet)
@@ -520,29 +521,34 @@ func usersChangedHook(db *db.HSDatabase, polMan policy.PolicyManager, notif *not
// TODO(kradalby): Do a variant of this, and polman which only updates the node that has changed.
// Maybe we should attempt a new in memory state and not go via the DB?
func nodesChangedHook(db *db.HSDatabase, polMan policy.PolicyManager, notif *notifier.Notifier) error {
// A bool is returned indicating if a full update was sent to all nodes
func nodesChangedHook(db *db.HSDatabase, polMan policy.PolicyManager, notif *notifier.Notifier) (bool, error) {
nodes, err := db.ListNodes()
if err != nil {
return err
return false, err
}
changed, err := polMan.SetNodes(nodes)
filterChanged, err := polMan.SetNodes(nodes)
if err != nil {
return err
return false, err
}
if changed {
if filterChanged {
ctx := types.NotifyCtx(context.Background(), "acl-nodes-change", "all")
notif.NotifyAll(ctx, types.StateUpdate{
Type: types.StateFullUpdate,
})
return true, nil
}
return nil
return false, nil
}
// Serve launches the HTTP and gRPC server service Headscale and the API.
func (h *Headscale) Serve() error {
capver.CanOldCodeBeCleanedUp()
if profilingEnabled {
if profilingPath != "" {
err := os.MkdirAll(profilingPath, os.ModePerm)
@@ -560,6 +566,11 @@ func (h *Headscale) Serve() error {
spew.Dump(h.cfg)
}
log.Info().
Caller().
Str("minimum_version", capver.TailscaleVersion(capver.MinSupportedCapabilityVersion)).
Msg("Clients with a lower minimum version will be rejected")
// Fetch an initial DERP Map before we start serving
h.DERPMap = derp.GetDERPMap(h.cfg.DERP)
h.mapper = mapper.NewMapper(h.db, h.cfg, h.DERPMap, h.nodeNotifier, h.polMan)
@@ -838,6 +849,10 @@ func (h *Headscale) Serve() error {
Str("signal", sig.String()).
Msg("Received SIGHUP, reloading ACL and Config")
if h.cfg.Policy.IsEmpty() {
continue
}
if err := h.loadPolicyManager(); err != nil {
log.Error().Err(err).Msg("failed to reload Policy")
}
@@ -1102,6 +1117,10 @@ func (h *Headscale) policyBytes() ([]byte, error) {
return nil, err
}
if p.Data == "" {
return nil, nil
}
return []byte(p.Data), err
}

View File

@@ -2,16 +2,16 @@ package hscontrol
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/juanfont/headscale/hscontrol/db"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/util"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
@@ -20,692 +20,276 @@ import (
type AuthProvider interface {
RegisterHandler(http.ResponseWriter, *http.Request)
AuthURL(key.MachinePublic) string
AuthURL(types.RegistrationID) string
}
func logAuthFunc(
registerRequest tailcfg.RegisterRequest,
machineKey key.MachinePublic,
) (func(string), func(string), func(error, string)) {
return func(msg string) {
log.Info().
Caller().
Str("machine_key", machineKey.ShortString()).
Str("node_key", registerRequest.NodeKey.ShortString()).
Str("node_key_old", registerRequest.OldNodeKey.ShortString()).
Str("node", registerRequest.Hostinfo.Hostname).
Str("followup", registerRequest.Followup).
Time("expiry", registerRequest.Expiry).
Msg(msg)
},
func(msg string) {
log.Trace().
Caller().
Str("machine_key", machineKey.ShortString()).
Str("node_key", registerRequest.NodeKey.ShortString()).
Str("node_key_old", registerRequest.OldNodeKey.ShortString()).
Str("node", registerRequest.Hostinfo.Hostname).
Str("followup", registerRequest.Followup).
Time("expiry", registerRequest.Expiry).
Msg(msg)
},
func(err error, msg string) {
log.Error().
Caller().
Str("machine_key", machineKey.ShortString()).
Str("node_key", registerRequest.NodeKey.ShortString()).
Str("node_key_old", registerRequest.OldNodeKey.ShortString()).
Str("node", registerRequest.Hostinfo.Hostname).
Str("followup", registerRequest.Followup).
Time("expiry", registerRequest.Expiry).
Err(err).
Msg(msg)
}
}
// handleRegister is the logic for registering a client.
func (h *Headscale) handleRegister(
writer http.ResponseWriter,
req *http.Request,
ctx context.Context,
regReq tailcfg.RegisterRequest,
machineKey key.MachinePublic,
) {
logInfo, logTrace, _ := logAuthFunc(regReq, machineKey)
now := time.Now().UTC()
logTrace("handleRegister called, looking up machine in DB")
node, err := h.db.GetNodeByAnyKey(machineKey, regReq.NodeKey, regReq.OldNodeKey)
logTrace("handleRegister database lookup has returned")
if errors.Is(err, gorm.ErrRecordNotFound) {
// If the node has AuthKey set, handle registration via PreAuthKeys
if regReq.Auth != nil && regReq.Auth.AuthKey != "" {
h.handleAuthKey(writer, regReq, machineKey)
) (*tailcfg.RegisterResponse, error) {
node, err := h.db.GetNodeByNodeKey(regReq.NodeKey)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("looking up node in database: %w", err)
}
return
if node != nil {
resp, err := h.handleExistingNode(node, regReq, machineKey)
if err != nil {
return nil, fmt.Errorf("handling existing node: %w", err)
}
// Check if the node is waiting for interactive login.
//
// TODO(juan): We could use this field to improve our protocol implementation,
// and hold the request until the client closes it, or the interactive
// login is completed (i.e., the user registers the node).
// This is not implemented yet, as it is no strictly required. The only side-effect
// is that the client will hammer headscale with requests until it gets a
// successful RegisterResponse.
if regReq.Followup != "" {
logTrace("register request is a followup")
if _, ok := h.registrationCache.Get(machineKey.String()); ok {
logTrace("Node is waiting for interactive login")
return resp, nil
}
select {
case <-req.Context().Done():
return
case <-time.After(registrationHoldoff):
h.handleNewNode(writer, regReq, machineKey)
if regReq.Followup != "" {
// TODO(kradalby): Does this need to return an error of some sort?
// Maybe if the registration fails down the line it can be sent
// on the channel and returned here?
h.waitForFollowup(ctx, regReq)
}
return
if regReq.Auth != nil && regReq.Auth.AuthKey != "" {
resp, err := h.handleRegisterWithAuthKey(regReq, machineKey)
if err != nil {
return nil, fmt.Errorf("handling register with auth key: %w", err)
}
return resp, nil
}
resp, err := h.handleRegisterInteractive(regReq, machineKey)
if err != nil {
return nil, fmt.Errorf("handling register interactive: %w", err)
}
return resp, nil
}
func (h *Headscale) handleExistingNode(
node *types.Node,
regReq tailcfg.RegisterRequest,
machineKey key.MachinePublic,
) (*tailcfg.RegisterResponse, error) {
if node.MachineKey != machineKey {
return nil, NewHTTPError(http.StatusUnauthorized, "node exist with different machine key", nil)
}
expired := node.IsExpired()
if !expired && !regReq.Expiry.IsZero() {
requestExpiry := regReq.Expiry
// The client is trying to extend their key, this is not allowed.
if requestExpiry.After(time.Now()) {
return nil, NewHTTPError(http.StatusBadRequest, "extending key is not allowed", nil)
}
// If the request expiry is in the past, we consider it a logout.
if requestExpiry.Before(time.Now()) {
if node.IsEphemeral() {
changedNodes, err := h.db.DeleteNode(node, h.nodeNotifier.LikelyConnectedMap())
if err != nil {
return nil, fmt.Errorf("deleting ephemeral node: %w", err)
}
ctx := types.NotifyCtx(context.Background(), "logout-ephemeral", "na")
h.nodeNotifier.NotifyAll(ctx, types.StateUpdate{
Type: types.StatePeerRemoved,
Removed: []types.NodeID{node.ID},
})
if changedNodes != nil {
h.nodeNotifier.NotifyAll(ctx, types.StateUpdate{
Type: types.StatePeerChanged,
ChangeNodes: changedNodes,
})
}
}
expired = true
}
logInfo("Node not found in database, creating new")
// The node did not have a key to authenticate, which means
// that we rely on a method that calls back some how (OpenID or CLI)
// We create the node and then keep it around until a callback
// happens
newNode := types.Node{
MachineKey: machineKey,
Hostname: regReq.Hostinfo.Hostname,
NodeKey: regReq.NodeKey,
LastSeen: &now,
Expiry: &time.Time{},
err := h.db.NodeSetExpiry(node.ID, requestExpiry)
if err != nil {
return nil, fmt.Errorf("setting node expiry: %w", err)
}
if !regReq.Expiry.IsZero() {
logTrace("Non-zero expiry time requested")
newNode.Expiry = &regReq.Expiry
}
ctx := types.NotifyCtx(context.Background(), "logout-expiry", "na")
h.nodeNotifier.NotifyWithIgnore(ctx, types.StateUpdateExpire(node.ID, requestExpiry), node.ID)
}
h.registrationCache.Set(
machineKey.String(),
newNode,
)
return &tailcfg.RegisterResponse{
// TODO(kradalby): Only send for user-owned nodes
// and not tagged nodes when tags is working.
User: *node.User.TailscaleUser(),
Login: *node.User.TailscaleLogin(),
NodeKeyExpired: expired,
h.handleNewNode(writer, regReq, machineKey)
// Headscale does not implement the concept of machine authorization
// so we always return true here.
// Revisit this if #2176 gets implemented.
MachineAuthorized: true,
}, nil
}
func (h *Headscale) waitForFollowup(
ctx context.Context,
regReq tailcfg.RegisterRequest,
) {
fu, err := url.Parse(regReq.Followup)
if err != nil {
return
}
// The node is already in the DB. This could mean one of the following:
// - The node is authenticated and ready to /map
// - We are doing a key refresh
// - The node is logged out (or expired) and pending to be authorized. TODO(juan): We need to keep alive the connection here
if node != nil {
// (juan): For a while we had a bug where we were not storing the MachineKey for the nodes using the TS2021,
// due to a misunderstanding of the protocol https://github.com/juanfont/headscale/issues/1054
// So if we have a not valid MachineKey (but we were able to fetch the node with the NodeKeys), we update it.
if err != nil || node.MachineKey.IsZero() {
if err := h.db.NodeSetMachineKey(node, machineKey); err != nil {
log.Error().
Caller().
Str("func", "RegistrationHandler").
Str("node", node.Hostname).
Err(err).
Msg("Error saving machine key to database")
return
}
}
// If the NodeKey stored in headscale is the same as the key presented in a registration
// request, then we have a node that is either:
// - Trying to log out (sending a expiry in the past)
// - A valid, registered node, looking for /map
// - Expired node wanting to reauthenticate
if node.NodeKey.String() == regReq.NodeKey.String() {
// The client sends an Expiry in the past if the client is requesting to expire the key (aka logout)
// https://github.com/tailscale/tailscale/blob/main/tailcfg/tailcfg.go#L648
if !regReq.Expiry.IsZero() &&
regReq.Expiry.UTC().Before(now) {
h.handleNodeLogOut(writer, *node)
return
}
// If node is not expired, and it is register, we have a already accepted this node,
// let it proceed with a valid registration
if !node.IsExpired() {
h.handleNodeWithValidRegistration(writer, *node)
return
}
}
// The NodeKey we have matches OldNodeKey, which means this is a refresh after a key expiration
if node.NodeKey.String() == regReq.OldNodeKey.String() &&
!node.IsExpired() {
h.handleNodeKeyRefresh(
writer,
regReq,
*node,
)
return
}
// When logged out and reauthenticating with OIDC, the OldNodeKey is not passed, but the NodeKey has changed
if node.NodeKey.String() != regReq.NodeKey.String() &&
regReq.OldNodeKey.IsZero() && !node.IsExpired() {
h.handleNodeKeyRefresh(
writer,
regReq,
*node,
)
return
}
if regReq.Followup != "" {
select {
case <-req.Context().Done():
return
case <-time.After(registrationHoldoff):
}
}
// The node has expired or it is logged out
h.handleNodeExpiredOrLoggedOut(writer, regReq, *node, machineKey)
// TODO(juan): RegisterRequest includes an Expiry time, that we could optionally use
node.Expiry = &time.Time{}
// If we are here it means the client needs to be reauthorized,
// we need to make sure the NodeKey matches the one in the request
// TODO(juan): What happens when using fast user switching between two
// headscale-managed tailnets?
node.NodeKey = regReq.NodeKey
h.registrationCache.Set(
machineKey.String(),
*node,
)
followupReg, err := types.RegistrationIDFromString(strings.ReplaceAll(fu.Path, "/register/", ""))
if err != nil {
return
}
if reg, ok := h.registrationCache.Get(followupReg); ok {
select {
case <-ctx.Done():
return
case <-reg.Registered:
return
}
}
}
// handleAuthKey contains the logic to manage auth key client registration
// When using Noise, the machineKey is Zero.
func (h *Headscale) handleAuthKey(
writer http.ResponseWriter,
registerRequest tailcfg.RegisterRequest,
machineKey key.MachinePublic,
) {
log.Debug().
Caller().
Str("node", registerRequest.Hostinfo.Hostname).
Msgf("Processing auth key for %s", registerRequest.Hostinfo.Hostname)
resp := tailcfg.RegisterResponse{}
pak, err := h.db.ValidatePreAuthKey(registerRequest.Auth.AuthKey)
if err != nil {
log.Error().
Caller().
Str("node", registerRequest.Hostinfo.Hostname).
Err(err).
Msg("Failed authentication via AuthKey")
resp.MachineAuthorized = false
respBody, err := json.Marshal(resp)
if err != nil {
log.Error().
Caller().
Str("node", registerRequest.Hostinfo.Hostname).
Err(err).
Msg("Cannot encode message")
http.Error(writer, "Internal server error", http.StatusInternalServerError)
return
}
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
writer.WriteHeader(http.StatusUnauthorized)
_, err = writer.Write(respBody)
if err != nil {
log.Error().
Caller().
Err(err).
Msg("Failed to write response")
}
log.Error().
Caller().
Str("node", registerRequest.Hostinfo.Hostname).
Msg("Failed authentication via AuthKey")
return
// canUsePreAuthKey checks if a pre auth key can be used.
func canUsePreAuthKey(pak *types.PreAuthKey) error {
if pak == nil {
return NewHTTPError(http.StatusUnauthorized, "invalid authkey", nil)
}
if pak.Expiration != nil && pak.Expiration.Before(time.Now()) {
return NewHTTPError(http.StatusUnauthorized, "authkey expired", nil)
}
log.Debug().
Caller().
Str("node", registerRequest.Hostinfo.Hostname).
Msg("Authentication key was valid, proceeding to acquire IP addresses")
// we don't need to check if has been used before
if pak.Reusable {
return nil
}
nodeKey := registerRequest.NodeKey
if pak.Used {
return NewHTTPError(http.StatusUnauthorized, "authkey already used", nil)
}
// retrieve node information if it exist
// The error is not important, because if it does not
// exist, then this is a new node and we will move
// on to registration.
node, _ := h.db.GetNodeByAnyKey(machineKey, registerRequest.NodeKey, registerRequest.OldNodeKey)
if node != nil {
log.Trace().
Caller().
Str("node", node.Hostname).
Msg("node was already registered before, refreshing with new auth key")
return nil
}
node.NodeKey = nodeKey
if pak.ID != 0 {
node.AuthKeyID = ptr.To(pak.ID)
func (h *Headscale) handleRegisterWithAuthKey(
regReq tailcfg.RegisterRequest,
machineKey key.MachinePublic,
) (*tailcfg.RegisterResponse, error) {
pak, err := h.db.GetPreAuthKey(regReq.Auth.AuthKey)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, NewHTTPError(http.StatusUnauthorized, "invalid pre auth key", nil)
}
return nil, err
}
node.Expiry = &registerRequest.Expiry
node.User = pak.User
node.UserID = pak.UserID
err := h.db.DB.Save(node).Error
if err != nil {
log.Error().
Caller().
Str("node", node.Hostname).
Err(err).
Msg("failed to save node after logging in with auth key")
err = canUsePreAuthKey(pak)
if err != nil {
return nil, err
}
return
}
nodeToRegister := types.Node{
Hostname: regReq.Hostinfo.Hostname,
UserID: pak.User.ID,
User: pak.User,
MachineKey: machineKey,
NodeKey: regReq.NodeKey,
Hostinfo: regReq.Hostinfo,
LastSeen: ptr.To(time.Now()),
RegisterMethod: util.RegisterMethodAuthKey,
aclTags := pak.Proto().GetAclTags()
if len(aclTags) > 0 {
// This conditional preserves the existing behaviour, although SaaS would reset the tags on auth-key login
err = h.db.SetTags(node.ID, aclTags)
if err != nil {
log.Error().
Caller().
Str("node", node.Hostname).
Strs("aclTags", aclTags).
Err(err).
Msg("Failed to set tags after refreshing node")
// TODO(kradalby): This should not be set on the node,
// they should be looked up through the key, which is
// attached to the node.
ForcedTags: pak.Proto().GetAclTags(),
AuthKey: pak,
AuthKeyID: &pak.ID,
}
return
}
}
if !regReq.Expiry.IsZero() {
nodeToRegister.Expiry = &regReq.Expiry
}
ctx := types.NotifyCtx(context.Background(), "handle-authkey", "na")
h.nodeNotifier.NotifyAll(ctx, types.StateUpdate{Type: types.StatePeerChanged, ChangeNodes: []types.NodeID{node.ID}})
} else {
now := time.Now().UTC()
ipv4, ipv6, err := h.ipAlloc.Next()
if err != nil {
return nil, fmt.Errorf("allocating IPs: %w", err)
}
nodeToRegister := types.Node{
Hostname: registerRequest.Hostinfo.Hostname,
UserID: pak.User.ID,
User: pak.User,
MachineKey: machineKey,
RegisterMethod: util.RegisterMethodAuthKey,
Expiry: &registerRequest.Expiry,
NodeKey: nodeKey,
LastSeen: &now,
ForcedTags: pak.Proto().GetAclTags(),
}
ipv4, ipv6, err := h.ipAlloc.Next()
if err != nil {
log.Error().
Caller().
Str("func", "RegistrationHandler").
Str("hostinfo.name", registerRequest.Hostinfo.Hostname).
Err(err).
Msg("failed to allocate IP ")
return
}
pakID := uint(pak.ID)
if pakID != 0 {
nodeToRegister.AuthKeyID = ptr.To(pak.ID)
}
node, err = h.db.RegisterNode(
node, err := db.Write(h.db.DB, func(tx *gorm.DB) (*types.Node, error) {
node, err := db.RegisterNode(tx,
nodeToRegister,
ipv4, ipv6,
)
if err != nil {
log.Error().
Caller().
Err(err).
Msg("could not register node")
http.Error(writer, "Internal server error", http.StatusInternalServerError)
return
return nil, fmt.Errorf("registering node: %w", err)
}
err = nodesChangedHook(h.db, h.polMan, h.nodeNotifier)
if err != nil {
http.Error(writer, "Internal server error", http.StatusInternalServerError)
return
if !pak.Reusable {
err = db.UsePreAuthKey(tx, pak)
if err != nil {
return nil, fmt.Errorf("using pre auth key: %w", err)
}
}
}
err = h.db.Write(func(tx *gorm.DB) error {
return db.UsePreAuthKey(tx, pak)
return node, nil
})
if err != nil {
log.Error().
Caller().
Err(err).
Msg("Failed to use pre-auth key")
http.Error(writer, "Internal server error", http.StatusInternalServerError)
return
return nil, err
}
resp.MachineAuthorized = true
resp.User = *pak.User.TailscaleUser()
// Provide LoginName when registering with pre-auth key
// Otherwise it will need to exec `tailscale up` twice to fetch the *LoginName*
resp.Login = *pak.User.TailscaleLogin()
respBody, err := json.Marshal(resp)
updateSent, err := nodesChangedHook(h.db, h.polMan, h.nodeNotifier)
if err != nil {
log.Error().
Caller().
Str("node", registerRequest.Hostinfo.Hostname).
Err(err).
Msg("Cannot encode message")
http.Error(writer, "Internal server error", http.StatusInternalServerError)
return
}
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
writer.WriteHeader(http.StatusOK)
_, err = writer.Write(respBody)
if err != nil {
log.Error().
Caller().
Err(err).
Msg("Failed to write response")
return
return nil, fmt.Errorf("nodes changed hook: %w", err)
}
log.Info().
Str("node", registerRequest.Hostinfo.Hostname).
Msg("Successfully authenticated via AuthKey")
if !updateSent {
ctx := types.NotifyCtx(context.Background(), "node updated", node.Hostname)
h.nodeNotifier.NotifyAll(ctx, types.StateUpdatePeerAdded(node.ID))
}
return &tailcfg.RegisterResponse{
MachineAuthorized: true,
NodeKeyExpired: node.IsExpired(),
User: *pak.User.TailscaleUser(),
Login: *pak.User.TailscaleLogin(),
}, nil
}
// handleNewNode returns the authorisation URL to the client based on what type
// of registration headscale is configured with.
// This url is then showed to the user by the local Tailscale client.
func (h *Headscale) handleNewNode(
writer http.ResponseWriter,
registerRequest tailcfg.RegisterRequest,
machineKey key.MachinePublic,
) {
logInfo, logTrace, logErr := logAuthFunc(registerRequest, machineKey)
resp := tailcfg.RegisterResponse{}
// The node registration is new, redirect the client to the registration URL
logTrace("The node seems to be new, sending auth url")
resp.AuthURL = h.authProvider.AuthURL(machineKey)
respBody, err := json.Marshal(resp)
if err != nil {
logErr(err, "Cannot encode message")
http.Error(writer, "Internal server error", http.StatusInternalServerError)
return
}
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
writer.WriteHeader(http.StatusOK)
_, err = writer.Write(respBody)
if err != nil {
logErr(err, "Failed to write response")
}
logInfo(fmt.Sprintf("Successfully sent auth url: %s", resp.AuthURL))
}
func (h *Headscale) handleNodeLogOut(
writer http.ResponseWriter,
node types.Node,
) {
resp := tailcfg.RegisterResponse{}
log.Info().
Str("node", node.Hostname).
Msg("Client requested logout")
now := time.Now()
err := h.db.NodeSetExpiry(node.ID, now)
if err != nil {
log.Error().
Caller().
Err(err).
Msg("Failed to expire node")
http.Error(writer, "Internal server error", http.StatusInternalServerError)
return
}
ctx := types.NotifyCtx(context.Background(), "logout-expiry", "na")
h.nodeNotifier.NotifyWithIgnore(ctx, types.StateUpdateExpire(node.ID, now), node.ID)
resp.AuthURL = ""
resp.MachineAuthorized = false
resp.NodeKeyExpired = true
resp.User = *node.User.TailscaleUser()
respBody, err := json.Marshal(resp)
if err != nil {
log.Error().
Caller().
Err(err).
Msg("Cannot encode message")
http.Error(writer, "Internal server error", http.StatusInternalServerError)
return
}
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
writer.WriteHeader(http.StatusOK)
_, err = writer.Write(respBody)
if err != nil {
log.Error().
Caller().
Err(err).
Msg("Failed to write response")
return
}
if node.IsEphemeral() {
changedNodes, err := h.db.DeleteNode(&node, h.nodeNotifier.LikelyConnectedMap())
if err != nil {
log.Error().
Err(err).
Str("node", node.Hostname).
Msg("Cannot delete ephemeral node from the database")
}
ctx := types.NotifyCtx(context.Background(), "logout-ephemeral", "na")
h.nodeNotifier.NotifyAll(ctx, types.StateUpdate{
Type: types.StatePeerRemoved,
Removed: []types.NodeID{node.ID},
})
if changedNodes != nil {
h.nodeNotifier.NotifyAll(ctx, types.StateUpdate{
Type: types.StatePeerChanged,
ChangeNodes: changedNodes,
})
}
return
}
log.Info().
Caller().
Str("node", node.Hostname).
Msg("Successfully logged out")
}
func (h *Headscale) handleNodeWithValidRegistration(
writer http.ResponseWriter,
node types.Node,
) {
resp := tailcfg.RegisterResponse{}
// The node registration is valid, respond with redirect to /map
log.Debug().
Caller().
Str("node", node.Hostname).
Msg("Client is registered and we have the current NodeKey. All clear to /map")
resp.AuthURL = ""
resp.MachineAuthorized = true
resp.User = *node.User.TailscaleUser()
resp.Login = *node.User.TailscaleLogin()
respBody, err := json.Marshal(resp)
if err != nil {
log.Error().
Caller().
Err(err).
Msg("Cannot encode message")
http.Error(writer, "Internal server error", http.StatusInternalServerError)
return
}
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
writer.WriteHeader(http.StatusOK)
_, err = writer.Write(respBody)
if err != nil {
log.Error().
Caller().
Err(err).
Msg("Failed to write response")
}
log.Info().
Caller().
Str("node", node.Hostname).
Msg("Node successfully authorized")
}
func (h *Headscale) handleNodeKeyRefresh(
writer http.ResponseWriter,
registerRequest tailcfg.RegisterRequest,
node types.Node,
) {
resp := tailcfg.RegisterResponse{}
log.Info().
Caller().
Str("node", node.Hostname).
Msg("We have the OldNodeKey in the database. This is a key refresh")
err := h.db.Write(func(tx *gorm.DB) error {
return db.NodeSetNodeKey(tx, &node, registerRequest.NodeKey)
})
if err != nil {
log.Error().
Caller().
Err(err).
Msg("Failed to update machine key in the database")
http.Error(writer, "Internal server error", http.StatusInternalServerError)
return
}
resp.AuthURL = ""
resp.User = *node.User.TailscaleUser()
respBody, err := json.Marshal(resp)
if err != nil {
log.Error().
Caller().
Err(err).
Msg("Cannot encode message")
http.Error(writer, "Internal server error", http.StatusInternalServerError)
return
}
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
writer.WriteHeader(http.StatusOK)
_, err = writer.Write(respBody)
if err != nil {
log.Error().
Caller().
Err(err).
Msg("Failed to write response")
}
log.Info().
Caller().
Str("node_key", registerRequest.NodeKey.ShortString()).
Str("old_node_key", registerRequest.OldNodeKey.ShortString()).
Str("node", node.Hostname).
Msg("Node key successfully refreshed")
}
func (h *Headscale) handleNodeExpiredOrLoggedOut(
writer http.ResponseWriter,
func (h *Headscale) handleRegisterInteractive(
regReq tailcfg.RegisterRequest,
node types.Node,
machineKey key.MachinePublic,
) {
resp := tailcfg.RegisterResponse{}
if regReq.Auth != nil && regReq.Auth.AuthKey != "" {
h.handleAuthKey(writer, regReq, machineKey)
return
}
// The client has registered before, but has expired or logged out
log.Trace().
Caller().
Str("node", node.Hostname).
Str("machine_key", machineKey.ShortString()).
Str("node_key", regReq.NodeKey.ShortString()).
Str("node_key_old", regReq.OldNodeKey.ShortString()).
Msg("Node registration has expired or logged out. Sending a auth url to register")
resp.AuthURL = h.authProvider.AuthURL(machineKey)
respBody, err := json.Marshal(resp)
) (*tailcfg.RegisterResponse, error) {
registrationId, err := types.NewRegistrationID()
if err != nil {
log.Error().
Caller().
Err(err).
Msg("Cannot encode message")
http.Error(writer, "Internal server error", http.StatusInternalServerError)
return
return nil, fmt.Errorf("generating registration ID: %w", err)
}
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
writer.WriteHeader(http.StatusOK)
_, err = writer.Write(respBody)
if err != nil {
log.Error().
Caller().
Err(err).
Msg("Failed to write response")
newNode := types.RegisterNode{
Node: types.Node{
Hostname: regReq.Hostinfo.Hostname,
MachineKey: machineKey,
NodeKey: regReq.NodeKey,
Hostinfo: regReq.Hostinfo,
LastSeen: ptr.To(time.Now()),
},
Registered: make(chan struct{}),
}
log.Trace().
Caller().
Str("machine_key", machineKey.ShortString()).
Str("node_key", regReq.NodeKey.ShortString()).
Str("node_key_old", regReq.OldNodeKey.ShortString()).
Str("node", node.Hostname).
Msg("Node logged out. Sent AuthURL for reauthentication")
if !regReq.Expiry.IsZero() {
newNode.Node.Expiry = &regReq.Expiry
}
h.registrationCache.Set(
registrationId,
newNode,
)
return &tailcfg.RegisterResponse{
AuthURL: h.authProvider.AuthURL(registrationId),
}, nil
}

View File

@@ -1,56 +0,0 @@
package hscontrol
import (
"encoding/json"
"io"
"net/http"
"github.com/rs/zerolog/log"
"tailscale.com/tailcfg"
)
// // NoiseRegistrationHandler handles the actual registration process of a node.
func (ns *noiseServer) NoiseRegistrationHandler(
writer http.ResponseWriter,
req *http.Request,
) {
log.Trace().Caller().Msgf("Noise registration handler for client %s", req.RemoteAddr)
if req.Method != http.MethodPost {
http.Error(writer, "Wrong method", http.StatusMethodNotAllowed)
return
}
log.Trace().
Any("headers", req.Header).
Caller().
Msg("Headers")
body, _ := io.ReadAll(req.Body)
registerRequest := tailcfg.RegisterRequest{}
if err := json.Unmarshal(body, &registerRequest); err != nil {
log.Error().
Caller().
Err(err).
Msg("Cannot parse RegisterRequest")
http.Error(writer, "Internal error", http.StatusInternalServerError)
return
}
// Reject unsupported versions
if registerRequest.Version < MinimumCapVersion {
log.Info().
Caller().
Int("min_version", int(MinimumCapVersion)).
Int("client_version", int(registerRequest.Version)).
Msg("unsupported client connected")
http.Error(writer, "Internal error", http.StatusBadRequest)
return
}
ns.nodeKey = registerRequest.NodeKey
ns.headscale.handleRegister(writer, req, registerRequest, ns.conn.Peer())
}

130
hscontrol/auth_test.go Normal file
View File

@@ -0,0 +1,130 @@
package hscontrol
import (
"net/http"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/juanfont/headscale/hscontrol/types"
)
func TestCanUsePreAuthKey(t *testing.T) {
now := time.Now()
past := now.Add(-time.Hour)
future := now.Add(time.Hour)
tests := []struct {
name string
pak *types.PreAuthKey
wantErr bool
err HTTPError
}{
{
name: "valid reusable key",
pak: &types.PreAuthKey{
Reusable: true,
Used: false,
Expiration: &future,
},
wantErr: false,
},
{
name: "valid non-reusable key",
pak: &types.PreAuthKey{
Reusable: false,
Used: false,
Expiration: &future,
},
wantErr: false,
},
{
name: "expired key",
pak: &types.PreAuthKey{
Reusable: false,
Used: false,
Expiration: &past,
},
wantErr: true,
err: NewHTTPError(http.StatusUnauthorized, "authkey expired", nil),
},
{
name: "used non-reusable key",
pak: &types.PreAuthKey{
Reusable: false,
Used: true,
Expiration: &future,
},
wantErr: true,
err: NewHTTPError(http.StatusUnauthorized, "authkey already used", nil),
},
{
name: "used reusable key",
pak: &types.PreAuthKey{
Reusable: true,
Used: true,
Expiration: &future,
},
wantErr: false,
},
{
name: "no expiration date",
pak: &types.PreAuthKey{
Reusable: false,
Used: false,
Expiration: nil,
},
wantErr: false,
},
{
name: "nil preauth key",
pak: nil,
wantErr: true,
err: NewHTTPError(http.StatusUnauthorized, "invalid authkey", nil),
},
{
name: "expired and used key",
pak: &types.PreAuthKey{
Reusable: false,
Used: true,
Expiration: &past,
},
wantErr: true,
err: NewHTTPError(http.StatusUnauthorized, "authkey expired", nil),
},
{
name: "no expiration and used key",
pak: &types.PreAuthKey{
Reusable: false,
Used: true,
Expiration: nil,
},
wantErr: true,
err: NewHTTPError(http.StatusUnauthorized, "authkey already used", nil),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := canUsePreAuthKey(tt.pak)
if tt.wantErr {
if err == nil {
t.Errorf("expected error but got none")
} else {
httpErr, ok := err.(HTTPError)
if !ok {
t.Errorf("expected HTTPError but got %T", err)
} else {
if diff := cmp.Diff(tt.err, httpErr); diff != "" {
t.Errorf("unexpected error (-want +got):\n%s", diff)
}
}
}
} else {
if err != nil {
t.Errorf("expected no error but got %v", err)
}
}
})
}
}

106
hscontrol/capver/capver.go Normal file
View File

@@ -0,0 +1,106 @@
package capver
import (
"sort"
"strings"
xmaps "golang.org/x/exp/maps"
"tailscale.com/tailcfg"
"tailscale.com/util/set"
)
const MinSupportedCapabilityVersion tailcfg.CapabilityVersion = 88
// CanOldCodeBeCleanedUp is intended to be called on startup to see if
// there are old code that can ble cleaned up, entries should contain
// a CapVer where something can be cleaned up and a panic if it can.
// This is only intended to catch things in tests.
//
// All uses of Capability version checks should be listed here.
func CanOldCodeBeCleanedUp() {
if MinSupportedCapabilityVersion >= 111 {
panic("LegacyDERP can be cleaned up in tail.go")
}
}
func tailscaleVersSorted() []string {
vers := xmaps.Keys(tailscaleToCapVer)
sort.Strings(vers)
return vers
}
func capVersSorted() []tailcfg.CapabilityVersion {
capVers := xmaps.Keys(capVerToTailscaleVer)
sort.Slice(capVers, func(i, j int) bool {
return capVers[i] < capVers[j]
})
return capVers
}
// TailscaleVersion returns the Tailscale version for the given CapabilityVersion.
func TailscaleVersion(ver tailcfg.CapabilityVersion) string {
return capVerToTailscaleVer[ver]
}
// CapabilityVersion returns the CapabilityVersion for the given Tailscale version.
func CapabilityVersion(ver string) tailcfg.CapabilityVersion {
if !strings.HasPrefix(ver, "v") {
ver = "v" + ver
}
return tailscaleToCapVer[ver]
}
// TailscaleLatest returns the n latest Tailscale versions.
func TailscaleLatest(n int) []string {
if n <= 0 {
return nil
}
tsSorted := tailscaleVersSorted()
if n > len(tsSorted) {
return tsSorted
}
return tsSorted[len(tsSorted)-n:]
}
// TailscaleLatestMajorMinor returns the n latest Tailscale versions (e.g. 1.80).
func TailscaleLatestMajorMinor(n int, stripV bool) []string {
if n <= 0 {
return nil
}
majors := set.Set[string]{}
for _, vers := range tailscaleVersSorted() {
if stripV {
vers = strings.TrimPrefix(vers, "v")
}
v := strings.Split(vers, ".")
majors.Add(v[0] + "." + v[1])
}
majorSl := majors.Slice()
sort.Strings(majorSl)
if n > len(majorSl) {
return majorSl
}
return majorSl[len(majorSl)-n:]
}
// CapVerLatest returns the n latest CapabilityVersions.
func CapVerLatest(n int) []tailcfg.CapabilityVersion {
if n <= 0 {
return nil
}
s := capVersSorted()
if n > len(s) {
return s
}
return s[len(s)-n:]
}

View File

@@ -0,0 +1,56 @@
package capver
//Generated DO NOT EDIT
import "tailscale.com/tailcfg"
var tailscaleToCapVer = map[string]tailcfg.CapabilityVersion{
"v1.44.3": 63,
"v1.56.1": 82,
"v1.58.0": 85,
"v1.58.1": 85,
"v1.58.2": 85,
"v1.60.0": 87,
"v1.60.1": 87,
"v1.62.0": 88,
"v1.62.1": 88,
"v1.64.0": 90,
"v1.64.1": 90,
"v1.64.2": 90,
"v1.66.0": 95,
"v1.66.1": 95,
"v1.66.2": 95,
"v1.66.3": 95,
"v1.66.4": 95,
"v1.68.0": 97,
"v1.68.1": 97,
"v1.68.2": 97,
"v1.70.0": 102,
"v1.72.0": 104,
"v1.72.1": 104,
"v1.74.0": 106,
"v1.74.1": 106,
"v1.76.0": 106,
"v1.76.1": 106,
"v1.76.6": 106,
"v1.78.0": 109,
"v1.78.1": 109,
"v1.80.0": 113,
}
var capVerToTailscaleVer = map[tailcfg.CapabilityVersion]string{
63: "v1.44.3",
82: "v1.56.1",
85: "v1.58.0",
87: "v1.60.0",
88: "v1.62.0",
90: "v1.64.0",
95: "v1.66.0",
97: "v1.68.0",
102: "v1.70.0",
104: "v1.72.0",
106: "v1.74.0",
109: "v1.78.0",
113: "v1.80.0",
}

View File

@@ -0,0 +1,66 @@
package capver
import (
"testing"
"github.com/google/go-cmp/cmp"
"tailscale.com/tailcfg"
)
func TestTailscaleLatestMajorMinor(t *testing.T) {
tests := []struct {
n int
stripV bool
expected []string
}{
{3, false, []string{"v1.76", "v1.78", "v1.80"}},
{2, true, []string{"1.78", "1.80"}},
// Lazy way to see all supported versions
{10, true, []string{
"1.62",
"1.64",
"1.66",
"1.68",
"1.70",
"1.72",
"1.74",
"1.76",
"1.78",
"1.80",
}},
{0, false, nil},
}
for _, test := range tests {
t.Run("", func(t *testing.T) {
output := TailscaleLatestMajorMinor(test.n, test.stripV)
if diff := cmp.Diff(output, test.expected); diff != "" {
t.Errorf("TailscaleLatestMajorMinor(%d, %v) mismatch (-want +got):\n%s", test.n, test.stripV, diff)
}
})
}
}
func TestCapVerMinimumTailscaleVersion(t *testing.T) {
tests := []struct {
input tailcfg.CapabilityVersion
expected string
}{
{85, "v1.58.0"},
{90, "v1.64.0"},
{95, "v1.66.0"},
{106, "v1.74.0"},
{109, "v1.78.0"},
{9001, ""}, // Test case for a version higher than any in the map
{60, ""}, // Test case for a version lower than any in the map
}
for _, test := range tests {
t.Run("", func(t *testing.T) {
output := TailscaleVersion(test.input)
if output != test.expected {
t.Errorf("CapVerFromTailscaleVersion(%d) = %s; want %s", test.input, output, test.expected)
}
})
}
}

View File

@@ -0,0 +1,157 @@
package main
//go:generate go run main.go
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"regexp"
"sort"
"strconv"
"strings"
xmaps "golang.org/x/exp/maps"
"tailscale.com/tailcfg"
)
const (
releasesURL = "https://api.github.com/repos/tailscale/tailscale/releases"
rawFileURL = "https://github.com/tailscale/tailscale/raw/refs/tags/%s/tailcfg/tailcfg.go"
outputFile = "../capver_generated.go"
)
type Release struct {
Name string `json:"name"`
}
func getCapabilityVersions() (map[string]tailcfg.CapabilityVersion, error) {
// Fetch the releases
resp, err := http.Get(releasesURL)
if err != nil {
return nil, fmt.Errorf("error fetching releases: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading response body: %w", err)
}
var releases []Release
err = json.Unmarshal(body, &releases)
if err != nil {
return nil, fmt.Errorf("error unmarshalling JSON: %w", err)
}
// Regular expression to find the CurrentCapabilityVersion line
re := regexp.MustCompile(`const CurrentCapabilityVersion CapabilityVersion = (\d+)`)
versions := make(map[string]tailcfg.CapabilityVersion)
for _, release := range releases {
version := strings.TrimSpace(release.Name)
if !strings.HasPrefix(version, "v") {
version = "v" + version
}
// Fetch the raw Go file
rawURL := fmt.Sprintf(rawFileURL, version)
resp, err := http.Get(rawURL)
if err != nil {
fmt.Printf("Error fetching raw file for version %s: %v\n", version, err)
continue
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Printf("Error reading raw file for version %s: %v\n", version, err)
continue
}
// Find the CurrentCapabilityVersion
matches := re.FindStringSubmatch(string(body))
if len(matches) > 1 {
capabilityVersionStr := matches[1]
capabilityVersion, _ := strconv.Atoi(capabilityVersionStr)
versions[version] = tailcfg.CapabilityVersion(capabilityVersion)
} else {
fmt.Printf("Version: %s, CurrentCapabilityVersion not found\n", version)
}
}
return versions, nil
}
func writeCapabilityVersionsToFile(versions map[string]tailcfg.CapabilityVersion) error {
// Open the output file
file, err := os.Create(outputFile)
if err != nil {
return fmt.Errorf("error creating file: %w", err)
}
defer file.Close()
// Write the package declaration and variable
file.WriteString("package capver\n\n")
file.WriteString("//Generated DO NOT EDIT\n\n")
file.WriteString(`import "tailscale.com/tailcfg"`)
file.WriteString("\n\n")
file.WriteString("var tailscaleToCapVer = map[string]tailcfg.CapabilityVersion{\n")
sortedVersions := xmaps.Keys(versions)
sort.Strings(sortedVersions)
for _, version := range sortedVersions {
file.WriteString(fmt.Sprintf("\t\"%s\": %d,\n", version, versions[version]))
}
file.WriteString("}\n")
file.WriteString("\n\n")
file.WriteString("var capVerToTailscaleVer = map[tailcfg.CapabilityVersion]string{\n")
capVarToTailscaleVer := make(map[tailcfg.CapabilityVersion]string)
for _, v := range sortedVersions {
cap := versions[v]
log.Printf("cap for v: %d, %s", cap, v)
// If it is already set, skip and continue,
// we only want the first tailscale vsion per
// capability vsion.
if _, ok := capVarToTailscaleVer[cap]; ok {
log.Printf("Skipping %d, %s", cap, v)
continue
}
log.Printf("Storing %d, %s", cap, v)
capVarToTailscaleVer[cap] = v
}
capsSorted := xmaps.Keys(capVarToTailscaleVer)
sort.Slice(capsSorted, func(i, j int) bool {
return capsSorted[i] < capsSorted[j]
})
for _, capVer := range capsSorted {
file.WriteString(fmt.Sprintf("\t%d:\t\t\"%s\",\n", capVer, capVarToTailscaleVer[capVer]))
}
file.WriteString("}\n")
return nil
}
func main() {
versions, err := getCapabilityVersions()
if err != nil {
fmt.Println("Error:", err)
return
}
err = writeCapabilityVersionsToFile(versions)
if err != nil {
fmt.Println("Error writing to file:", err)
return
}
fmt.Println("Capability versions written to", outputFile)
}

View File

@@ -41,7 +41,7 @@ type KV struct {
type HSDatabase struct {
DB *gorm.DB
cfg *types.DatabaseConfig
regCache *zcache.Cache[string, types.Node]
regCache *zcache.Cache[types.RegistrationID, types.RegisterNode]
baseDomain string
}
@@ -51,7 +51,7 @@ type HSDatabase struct {
func NewHeadscaleDatabase(
cfg types.DatabaseConfig,
baseDomain string,
regCache *zcache.Cache[string, types.Node],
regCache *zcache.Cache[types.RegistrationID, types.RegisterNode],
) (*HSDatabase, error) {
dbConn, err := openDB(cfg)
if err != nil {
@@ -103,7 +103,7 @@ func NewHeadscaleDatabase(
dbConn.Model(&types.Node{}).Where("auth_key_id = ?", 0).Update("auth_key_id", nil)
// If the Node table has a column for registered,
// find all occourences of "false" and drop them. Then
// find all occurrences of "false" and drop them. Then
// remove the column.
if tx.Migrator().HasColumn(&types.Node{}, "registered") {
log.Info().
@@ -478,9 +478,41 @@ func NewHeadscaleDatabase(
// populate the user with more interesting information.
ID: "202407191627",
Migrate: func(tx *gorm.DB) error {
// Fix an issue where the automigration in GORM expected a constraint to
// exists that didnt, and add the one it wanted.
// Fixes https://github.com/juanfont/headscale/issues/2351
if cfg.Type == types.DatabasePostgres {
err := tx.Exec(`
BEGIN;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'uni_users_name'
) THEN
ALTER TABLE users ADD CONSTRAINT uni_users_name UNIQUE (name);
END IF;
END $$;
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'users_name_key'
) THEN
ALTER TABLE users DROP CONSTRAINT users_name_key;
END IF;
END $$;
COMMIT;
`).Error
if err != nil {
return fmt.Errorf("failed to rename constraint: %w", err)
}
}
err := tx.AutoMigrate(&types.User{})
if err != nil {
return err
return fmt.Errorf("automigrating types.User: %w", err)
}
return nil
@@ -495,7 +527,7 @@ func NewHeadscaleDatabase(
Migrate: func(tx *gorm.DB) error {
err := tx.AutoMigrate(&types.User{})
if err != nil {
return err
return fmt.Errorf("automigrating types.User: %w", err)
}
// Set up indexes and unique constraints outside of GORM, it does not support
@@ -521,6 +553,75 @@ func NewHeadscaleDatabase(
},
Rollback: func(db *gorm.DB) error { return nil },
},
{
// Add a constraint to routes ensuring they cannot exist without a node.
ID: "202501221827",
Migrate: func(tx *gorm.DB) error {
// Remove any invalid routes associated with a node that does not exist.
if tx.Migrator().HasTable(&types.Route{}) && tx.Migrator().HasTable(&types.Node{}) {
err := tx.Exec("delete from routes where node_id not in (select id from nodes)").Error
if err != nil {
return err
}
}
// Remove any invalid routes without a node_id.
if tx.Migrator().HasTable(&types.Route{}) {
err := tx.Exec("delete from routes where node_id is null").Error
if err != nil {
return err
}
}
err := tx.AutoMigrate(&types.Route{})
if err != nil {
return fmt.Errorf("automigrating types.Route: %w", err)
}
return nil
},
Rollback: func(db *gorm.DB) error { return nil },
},
// Add back constraint so you cannot delete preauth keys that
// is still used by a node.
{
ID: "202501311657",
Migrate: func(tx *gorm.DB) error {
err := tx.AutoMigrate(&types.PreAuthKey{})
if err != nil {
return fmt.Errorf("automigrating types.PreAuthKey: %w", err)
}
err = tx.AutoMigrate(&types.Node{})
if err != nil {
return fmt.Errorf("automigrating types.Node: %w", err)
}
return nil
},
Rollback: func(db *gorm.DB) error { return nil },
},
// Ensure there are no nodes refering to a deleted preauthkey.
{
ID: "202502070949",
Migrate: func(tx *gorm.DB) error {
if tx.Migrator().HasTable(&types.PreAuthKey{}) {
err := tx.Exec(`
UPDATE nodes
SET auth_key_id = NULL
WHERE auth_key_id IS NOT NULL
AND auth_key_id NOT IN (
SELECT id FROM pre_auth_keys
);
`).Error
if err != nil {
return fmt.Errorf("setting auth_key to null on nodes with non-existing keys: %w", err)
}
}
return nil
},
Rollback: func(db *gorm.DB) error { return nil },
},
},
)
@@ -588,7 +689,7 @@ func openDB(cfg types.DatabaseConfig) (*gorm.DB, error) {
}
// The pure Go SQLite library does not handle locking in
// the same way as the C based one and we cant use the gorm
// the same way as the C based one and we can't use the gorm
// connection pool as of 2022/02/23.
sqlDB, _ := db.DB()
sqlDB.SetMaxIdleConns(1)
@@ -651,7 +752,7 @@ func openDB(cfg types.DatabaseConfig) (*gorm.DB, error) {
}
func runMigrations(cfg types.DatabaseConfig, dbConn *gorm.DB, migrations *gormigrate.Gormigrate) error {
// Turn off foreign keys for the duration of the migration if using sqllite to
// Turn off foreign keys for the duration of the migration if using sqlite to
// prevent data loss due to the way the GORM migrator handles certain schema
// changes.
if cfg.Type == types.DatabaseSqlite {

View File

@@ -6,6 +6,7 @@ import (
"io"
"net/netip"
"os"
"os/exec"
"path/filepath"
"slices"
"sort"
@@ -23,7 +24,10 @@ import (
"zgo.at/zcache/v2"
)
func TestMigrations(t *testing.T) {
// TestMigrationsSQLite is the main function for testing migrations,
// we focus on SQLite correctness as it is the main database used in headscale.
// All migrations that are worth testing should be added here.
func TestMigrationsSQLite(t *testing.T) {
ipp := func(p string) netip.Prefix {
return netip.MustParsePrefix(p)
}
@@ -197,6 +201,26 @@ func TestMigrations(t *testing.T) {
}
},
},
{
dbPath: "testdata/failing-node-preauth-constraint.sqlite",
wantFunc: func(t *testing.T, h *HSDatabase) {
nodes, err := Read(h.DB, func(rx *gorm.DB) (types.Nodes, error) {
return ListNodes(rx)
})
require.NoError(t, err)
for _, node := range nodes {
assert.Falsef(t, node.MachineKey.IsZero(), "expected non zero machinekey")
assert.Contains(t, node.MachineKey.String(), "mkey:")
assert.Falsef(t, node.NodeKey.IsZero(), "expected non zero nodekey")
assert.Contains(t, node.NodeKey.String(), "nodekey:")
assert.Falsef(t, node.DiscoKey.IsZero(), "expected non zero discokey")
assert.Contains(t, node.DiscoKey.String(), "discokey:")
assert.Nil(t, node.AuthKey)
assert.Nil(t, node.AuthKeyID)
}
},
},
}
for _, tt := range tests {
@@ -256,8 +280,8 @@ func testCopyOfDatabase(src string) (string, error) {
return dst, err
}
func emptyCache() *zcache.Cache[string, types.Node] {
return zcache.New[string, types.Node](time.Minute, time.Hour)
func emptyCache() *zcache.Cache[types.RegistrationID, types.RegisterNode] {
return zcache.New[types.RegistrationID, types.RegisterNode](time.Minute, time.Hour)
}
// requireConstraintFailed checks if the error is a constraint failure with
@@ -278,9 +302,9 @@ func TestConstraints(t *testing.T) {
{
name: "no-duplicate-username-if-no-oidc",
run: func(t *testing.T, db *gorm.DB) {
_, err := CreateUser(db, "user1")
_, err := CreateUser(db, types.User{Name: "user1"})
require.NoError(t, err)
_, err = CreateUser(db, "user1")
_, err = CreateUser(db, types.User{Name: "user1"})
requireConstraintFailed(t, err)
},
},
@@ -331,7 +355,7 @@ func TestConstraints(t *testing.T) {
{
name: "allow-duplicate-username-cli-then-oidc",
run: func(t *testing.T, db *gorm.DB) {
_, err := CreateUser(db, "user1") // Create CLI username
_, err := CreateUser(db, types.User{Name: "user1"}) // Create CLI username
require.NoError(t, err)
user := types.User{
@@ -354,7 +378,7 @@ func TestConstraints(t *testing.T) {
err := db.Save(&user).Error
require.NoError(t, err)
_, err = CreateUser(db, "user1") // Create CLI username
_, err = CreateUser(db, types.User{Name: "user1"}) // Create CLI username
require.NoError(t, err)
},
},
@@ -375,3 +399,58 @@ func TestConstraints(t *testing.T) {
})
}
}
func TestMigrationsPostgres(t *testing.T) {
tests := []struct {
name string
dbPath string
wantFunc func(*testing.T, *HSDatabase)
}{
{
name: "user-idx-breaking",
dbPath: "testdata/pre-24-postgresdb.pssql.dump",
wantFunc: func(t *testing.T, h *HSDatabase) {
users, err := Read(h.DB, func(rx *gorm.DB) ([]types.User, error) {
return ListUsers(rx)
})
require.NoError(t, err)
for _, user := range users {
assert.NotEmpty(t, user.Name)
assert.Empty(t, user.ProfilePicURL)
assert.Empty(t, user.Email)
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
u := newPostgresDBForTest(t)
pgRestorePath, err := exec.LookPath("pg_restore")
if err != nil {
t.Fatal("pg_restore not found in PATH. Please install it and ensure it is accessible.")
}
// Construct the pg_restore command
cmd := exec.Command(pgRestorePath, "--verbose", "--if-exists", "--clean", "--no-owner", "--dbname", u.String(), tt.dbPath)
// Set the output streams
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
// Execute the command
err = cmd.Run()
if err != nil {
t.Fatalf("failed to restore postgres database: %s", err)
}
db = newHeadscaleDBFromPostgresURL(t, u)
if tt.wantFunc != nil {
tt.wantFunc(t, db)
}
})
}
}

View File

@@ -158,36 +158,28 @@ func GetNodeByMachineKey(
return &mach, nil
}
func (hsdb *HSDatabase) GetNodeByAnyKey(
machineKey key.MachinePublic,
nodeKey key.NodePublic,
oldNodeKey key.NodePublic,
) (*types.Node, error) {
func (hsdb *HSDatabase) GetNodeByNodeKey(nodeKey key.NodePublic) (*types.Node, error) {
return Read(hsdb.DB, func(rx *gorm.DB) (*types.Node, error) {
return GetNodeByAnyKey(rx, machineKey, nodeKey, oldNodeKey)
return GetNodeByNodeKey(rx, nodeKey)
})
}
// GetNodeByAnyKey finds a Node by its MachineKey, its current NodeKey or the old one, and returns the Node struct.
// TODO(kradalby): see if we can remove this.
func GetNodeByAnyKey(
// GetNodeByNodeKey finds a Node by its NodeKey and returns the Node struct.
func GetNodeByNodeKey(
tx *gorm.DB,
machineKey key.MachinePublic, nodeKey key.NodePublic, oldNodeKey key.NodePublic,
nodeKey key.NodePublic,
) (*types.Node, error) {
node := types.Node{}
mach := types.Node{}
if result := tx.
Preload("AuthKey").
Preload("AuthKey.User").
Preload("User").
Preload("Routes").
First(&node, "machine_key = ? OR node_key = ? OR node_key = ?",
machineKey.String(),
nodeKey.String(),
oldNodeKey.String()); result.Error != nil {
First(&mach, "node_key = ?", nodeKey.String()); result.Error != nil {
return nil, result.Error
}
return &node, nil
return &mach, nil
}
func (hsdb *HSDatabase) SetTags(
@@ -245,7 +237,7 @@ func RenameNode(tx *gorm.DB,
return fmt.Errorf("renaming node: %w", err)
}
uniq, err := isUnqiueName(tx, newName)
uniq, err := isUniqueName(tx, newName)
if err != nil {
return fmt.Errorf("checking if name is unique: %w", err)
}
@@ -319,60 +311,83 @@ func SetLastSeen(tx *gorm.DB, nodeID types.NodeID, lastSeen time.Time) error {
return tx.Model(&types.Node{}).Where("id = ?", nodeID).Update("last_seen", lastSeen).Error
}
func (hsdb *HSDatabase) RegisterNodeFromAuthCallback(
mkey key.MachinePublic,
// HandleNodeFromAuthPath is called from the OIDC or CLI auth path
// with a registrationID to register or reauthenticate a node.
// If the node found in the registration cache is not already registered,
// it will be registered with the user and the node will be removed from the cache.
// If the node is already registered, the expiry will be updated.
// The node, and a boolean indicating if it was a new node or not, will be returned.
func (hsdb *HSDatabase) HandleNodeFromAuthPath(
registrationID types.RegistrationID,
userID types.UserID,
nodeExpiry *time.Time,
registrationMethod string,
ipv4 *netip.Addr,
ipv6 *netip.Addr,
) (*types.Node, error) {
return Write(hsdb.DB, func(tx *gorm.DB) (*types.Node, error) {
if node, ok := hsdb.regCache.Get(mkey.String()); ok {
user, err := GetUserByID(tx, userID)
if err != nil {
return nil, fmt.Errorf(
"failed to find user in register node from auth callback, %w",
err,
) (*types.Node, bool, error) {
var newNode bool
node, err := Write(hsdb.DB, func(tx *gorm.DB) (*types.Node, error) {
if reg, ok := hsdb.regCache.Get(registrationID); ok {
if node, _ := GetNodeByNodeKey(tx, reg.Node.NodeKey); node == nil {
user, err := GetUserByID(tx, userID)
if err != nil {
return nil, fmt.Errorf(
"failed to find user in register node from auth callback, %w",
err,
)
}
log.Debug().
Str("registration_id", registrationID.String()).
Str("username", user.Username()).
Str("registrationMethod", registrationMethod).
Str("expiresAt", fmt.Sprintf("%v", nodeExpiry)).
Msg("Registering node from API/CLI or auth callback")
// TODO(kradalby): This looks quite wrong? why ID 0?
// Why not always?
// Registration of expired node with different user
if reg.Node.ID != 0 &&
reg.Node.UserID != user.ID {
return nil, ErrDifferentRegisteredUser
}
reg.Node.UserID = user.ID
reg.Node.User = *user
reg.Node.RegisterMethod = registrationMethod
if nodeExpiry != nil {
reg.Node.Expiry = nodeExpiry
}
node, err := RegisterNode(
tx,
reg.Node,
ipv4, ipv6,
)
if err == nil {
hsdb.regCache.Delete(registrationID)
}
// Signal to waiting clients that the machine has been registered.
close(reg.Registered)
newNode = true
return node, err
} else {
// If the node is already registered, this is a refresh.
err := NodeSetExpiry(tx, node.ID, *nodeExpiry)
if err != nil {
return nil, err
}
return node, nil
}
log.Debug().
Str("machine_key", mkey.ShortString()).
Str("username", user.Username()).
Str("registrationMethod", registrationMethod).
Str("expiresAt", fmt.Sprintf("%v", nodeExpiry)).
Msg("Registering node from API/CLI or auth callback")
// Registration of expired node with different user
if node.ID != 0 &&
node.UserID != user.ID {
return nil, ErrDifferentRegisteredUser
}
node.UserID = user.ID
node.User = *user
node.RegisterMethod = registrationMethod
if nodeExpiry != nil {
node.Expiry = nodeExpiry
}
node, err := RegisterNode(
tx,
node,
ipv4, ipv6,
)
if err == nil {
hsdb.regCache.Delete(mkey.String())
}
return node, err
}
return nil, ErrNodeNotFoundRegistrationCache
})
return node, newNode, err
}
func (hsdb *HSDatabase) RegisterNode(node types.Node, ipv4 *netip.Addr, ipv6 *netip.Addr) (*types.Node, error) {
@@ -390,6 +405,18 @@ func RegisterNode(tx *gorm.DB, node types.Node, ipv4 *netip.Addr, ipv6 *netip.Ad
Str("user", node.User.Username()).
Msg("Registering node")
// If the a new node is registered with the same machine key, to the same user,
// update the existing node.
// If the same node is registered again, but to a new user, then that is considered
// a new node.
oldNode, _ := GetNodeByMachineKey(tx, node.MachineKey)
if oldNode != nil && oldNode.UserID == node.UserID {
node.ID = oldNode.ID
node.GivenName = oldNode.GivenName
ipv4 = oldNode.IPv4
ipv6 = oldNode.IPv6
}
// If the node exists and it already has IP(s), we just save it
// so we store the node.Expire and node.Nodekey that has been set when
// adding it to the registrationCache
@@ -630,7 +657,7 @@ func generateGivenName(suppliedName string, randomSuffix bool) (string, error) {
return suppliedName, nil
}
func isUnqiueName(tx *gorm.DB, name string) (bool, error) {
func isUniqueName(tx *gorm.DB, name string) (bool, error) {
nodes := types.Nodes{}
if err := tx.
Where("given_name = ?", name).Find(&nodes).Error; err != nil {
@@ -649,7 +676,7 @@ func ensureUniqueGivenName(
return "", err
}
unique, err := isUnqiueName(tx, givenName)
unique, err := isUniqueName(tx, givenName)
if err != nil {
return "", err
}

View File

@@ -27,7 +27,7 @@ import (
)
func (s *Suite) TestGetNode(c *check.C) {
user, err := db.CreateUser("test")
user, err := db.CreateUser(types.User{Name: "test"})
c.Assert(err, check.IsNil)
pak, err := db.CreatePreAuthKey(types.UserID(user.ID), false, false, nil, nil)
@@ -56,7 +56,7 @@ func (s *Suite) TestGetNode(c *check.C) {
}
func (s *Suite) TestGetNodeByID(c *check.C) {
user, err := db.CreateUser("test")
user, err := db.CreateUser(types.User{Name: "test"})
c.Assert(err, check.IsNil)
pak, err := db.CreatePreAuthKey(types.UserID(user.ID), false, false, nil, nil)
@@ -84,39 +84,8 @@ func (s *Suite) TestGetNodeByID(c *check.C) {
c.Assert(err, check.IsNil)
}
func (s *Suite) TestGetNodeByAnyNodeKey(c *check.C) {
user, err := db.CreateUser("test")
c.Assert(err, check.IsNil)
pak, err := db.CreatePreAuthKey(types.UserID(user.ID), false, false, nil, nil)
c.Assert(err, check.IsNil)
_, err = db.GetNodeByID(0)
c.Assert(err, check.NotNil)
nodeKey := key.NewNode()
oldNodeKey := key.NewNode()
machineKey := key.NewMachine()
node := types.Node{
ID: 0,
MachineKey: machineKey.Public(),
NodeKey: nodeKey.Public(),
Hostname: "testnode",
UserID: user.ID,
RegisterMethod: util.RegisterMethodAuthKey,
AuthKeyID: ptr.To(pak.ID),
}
trx := db.DB.Save(&node)
c.Assert(trx.Error, check.IsNil)
_, err = db.GetNodeByAnyKey(machineKey.Public(), nodeKey.Public(), oldNodeKey.Public())
c.Assert(err, check.IsNil)
}
func (s *Suite) TestHardDeleteNode(c *check.C) {
user, err := db.CreateUser("test")
user, err := db.CreateUser(types.User{Name: "test"})
c.Assert(err, check.IsNil)
nodeKey := key.NewNode()
@@ -141,7 +110,7 @@ func (s *Suite) TestHardDeleteNode(c *check.C) {
}
func (s *Suite) TestListPeers(c *check.C) {
user, err := db.CreateUser("test")
user, err := db.CreateUser(types.User{Name: "test"})
c.Assert(err, check.IsNil)
pak, err := db.CreatePreAuthKey(types.UserID(user.ID), false, false, nil, nil)
@@ -188,7 +157,7 @@ func (s *Suite) TestGetACLFilteredPeers(c *check.C) {
stor := make([]base, 0)
for _, name := range []string{"test", "admin"} {
user, err := db.CreateUser(name)
user, err := db.CreateUser(types.User{Name: name})
c.Assert(err, check.IsNil)
pak, err := db.CreatePreAuthKey(types.UserID(user.ID), false, false, nil, nil)
c.Assert(err, check.IsNil)
@@ -279,7 +248,7 @@ func (s *Suite) TestGetACLFilteredPeers(c *check.C) {
}
func (s *Suite) TestExpireNode(c *check.C) {
user, err := db.CreateUser("test")
user, err := db.CreateUser(types.User{Name: "test"})
c.Assert(err, check.IsNil)
pak, err := db.CreatePreAuthKey(types.UserID(user.ID), false, false, nil, nil)
@@ -320,7 +289,7 @@ func (s *Suite) TestExpireNode(c *check.C) {
}
func (s *Suite) TestSetTags(c *check.C) {
user, err := db.CreateUser("test")
user, err := db.CreateUser(types.User{Name: "test"})
c.Assert(err, check.IsNil)
pak, err := db.CreatePreAuthKey(types.UserID(user.ID), false, false, nil, nil)
@@ -565,7 +534,7 @@ func TestAutoApproveRoutes(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, pol)
user, err := adb.CreateUser("test")
user, err := adb.CreateUser(types.User{Name: "test"})
require.NoError(t, err)
pak, err := adb.CreatePreAuthKey(types.UserID(user.ID), false, false, nil, nil)
@@ -706,7 +675,7 @@ func TestListEphemeralNodes(t *testing.T) {
t.Fatalf("creating db: %s", err)
}
user, err := db.CreateUser("test")
user, err := db.CreateUser(types.User{Name: "test"})
require.NoError(t, err)
pak, err := db.CreatePreAuthKey(types.UserID(user.ID), false, false, nil, nil)
@@ -762,10 +731,10 @@ func TestRenameNode(t *testing.T) {
t.Fatalf("creating db: %s", err)
}
user, err := db.CreateUser("test")
user, err := db.CreateUser(types.User{Name: "test"})
require.NoError(t, err)
user2, err := db.CreateUser("test2")
user2, err := db.CreateUser(types.User{Name: "user2"})
require.NoError(t, err)
node := types.Node{

View File

@@ -10,7 +10,6 @@ import (
"github.com/juanfont/headscale/hscontrol/types"
"gorm.io/gorm"
"tailscale.com/types/ptr"
"tailscale.com/util/set"
)
@@ -64,6 +63,7 @@ func CreatePreAuthKey(
}
now := time.Now().UTC()
// TODO(kradalby): unify the key generations spread all over the code.
kstr, err := generateKey()
if err != nil {
return nil, err
@@ -108,18 +108,21 @@ func ListPreAuthKeysByUser(tx *gorm.DB, uid types.UserID) ([]types.PreAuthKey, e
return keys, nil
}
// GetPreAuthKey returns a PreAuthKey for a given key.
func GetPreAuthKey(tx *gorm.DB, user string, key string) (*types.PreAuthKey, error) {
pak, err := ValidatePreAuthKey(tx, key)
if err != nil {
return nil, err
func (hsdb *HSDatabase) GetPreAuthKey(key string) (*types.PreAuthKey, error) {
return Read(hsdb.DB, func(rx *gorm.DB) (*types.PreAuthKey, error) {
return GetPreAuthKey(rx, key)
})
}
// GetPreAuthKey returns a PreAuthKey for a given key. The caller is responsible
// for checking if the key is usable (expired or used).
func GetPreAuthKey(tx *gorm.DB, key string) (*types.PreAuthKey, error) {
pak := types.PreAuthKey{}
if err := tx.Preload("User").First(&pak, "key = ?", key).Error; err != nil {
return nil, ErrPreAuthKeyNotFound
}
if pak.User.Name != user {
return nil, ErrUserMismatch
}
return pak, nil
return &pak, nil
}
// DestroyPreAuthKey destroys a preauthkey. Returns error if the PreAuthKey
@@ -140,15 +143,6 @@ func (hsdb *HSDatabase) ExpirePreAuthKey(k *types.PreAuthKey) error {
})
}
// MarkExpirePreAuthKey marks a PreAuthKey as expired.
func ExpirePreAuthKey(tx *gorm.DB, k *types.PreAuthKey) error {
if err := tx.Model(&k).Update("Expiration", time.Now()).Error; err != nil {
return err
}
return nil
}
// UsePreAuthKey marks a PreAuthKey as used.
func UsePreAuthKey(tx *gorm.DB, k *types.PreAuthKey) error {
k.Used = true
@@ -159,44 +153,13 @@ func UsePreAuthKey(tx *gorm.DB, k *types.PreAuthKey) error {
return nil
}
func (hsdb *HSDatabase) ValidatePreAuthKey(k string) (*types.PreAuthKey, error) {
return Read(hsdb.DB, func(rx *gorm.DB) (*types.PreAuthKey, error) {
return ValidatePreAuthKey(rx, k)
})
}
// ValidatePreAuthKey does the heavy lifting for validation of the PreAuthKey coming from a node
// If returns no error and a PreAuthKey, it can be used.
func ValidatePreAuthKey(tx *gorm.DB, k string) (*types.PreAuthKey, error) {
pak := types.PreAuthKey{}
if result := tx.Preload("User").First(&pak, "key = ?", k); errors.Is(
result.Error,
gorm.ErrRecordNotFound,
) {
return nil, ErrPreAuthKeyNotFound
// MarkExpirePreAuthKey marks a PreAuthKey as expired.
func ExpirePreAuthKey(tx *gorm.DB, k *types.PreAuthKey) error {
if err := tx.Model(&k).Update("Expiration", time.Now()).Error; err != nil {
return err
}
if pak.Expiration != nil && pak.Expiration.Before(time.Now()) {
return nil, ErrPreAuthKeyExpired
}
if pak.Reusable { // we don't need to check if has been used before
return &pak, nil
}
nodes := types.Nodes{}
if err := tx.
Preload("AuthKey").
Where(&types.Node{AuthKeyID: ptr.To(pak.ID)}).
Find(&nodes).Error; err != nil {
return nil, err
}
if len(nodes) != 0 || pak.Used {
return nil, ErrSingleUseAuthKeyHasBeenUsed
}
return &pak, nil
return nil
}
func generateKey() (string, error) {

View File

@@ -2,12 +2,15 @@ package db
import (
"sort"
"time"
"testing"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/util"
"gopkg.in/check.v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"tailscale.com/types/ptr"
"gopkg.in/check.v1"
)
func (*Suite) TestCreatePreAuthKey(c *check.C) {
@@ -15,7 +18,7 @@ func (*Suite) TestCreatePreAuthKey(c *check.C) {
_, err := db.CreatePreAuthKey(12345, true, false, nil, nil)
c.Assert(err, check.NotNil)
user, err := db.CreateUser("test")
user, err := db.CreateUser(types.User{Name: "test"})
c.Assert(err, check.IsNil)
key, err := db.CreatePreAuthKey(types.UserID(user.ID), true, false, nil, nil)
@@ -40,125 +43,8 @@ func (*Suite) TestCreatePreAuthKey(c *check.C) {
c.Assert((keys)[0].User.ID, check.Equals, user.ID)
}
func (*Suite) TestExpiredPreAuthKey(c *check.C) {
user, err := db.CreateUser("test2")
c.Assert(err, check.IsNil)
now := time.Now().Add(-5 * time.Second)
pak, err := db.CreatePreAuthKey(types.UserID(user.ID), true, false, &now, nil)
c.Assert(err, check.IsNil)
key, err := db.ValidatePreAuthKey(pak.Key)
c.Assert(err, check.Equals, ErrPreAuthKeyExpired)
c.Assert(key, check.IsNil)
}
func (*Suite) TestPreAuthKeyDoesNotExist(c *check.C) {
key, err := db.ValidatePreAuthKey("potatoKey")
c.Assert(err, check.Equals, ErrPreAuthKeyNotFound)
c.Assert(key, check.IsNil)
}
func (*Suite) TestValidateKeyOk(c *check.C) {
user, err := db.CreateUser("test3")
c.Assert(err, check.IsNil)
pak, err := db.CreatePreAuthKey(types.UserID(user.ID), true, false, nil, nil)
c.Assert(err, check.IsNil)
key, err := db.ValidatePreAuthKey(pak.Key)
c.Assert(err, check.IsNil)
c.Assert(key.ID, check.Equals, pak.ID)
}
func (*Suite) TestAlreadyUsedKey(c *check.C) {
user, err := db.CreateUser("test4")
c.Assert(err, check.IsNil)
pak, err := db.CreatePreAuthKey(types.UserID(user.ID), false, false, nil, nil)
c.Assert(err, check.IsNil)
node := types.Node{
ID: 0,
Hostname: "testest",
UserID: user.ID,
RegisterMethod: util.RegisterMethodAuthKey,
AuthKeyID: ptr.To(pak.ID),
}
trx := db.DB.Save(&node)
c.Assert(trx.Error, check.IsNil)
key, err := db.ValidatePreAuthKey(pak.Key)
c.Assert(err, check.Equals, ErrSingleUseAuthKeyHasBeenUsed)
c.Assert(key, check.IsNil)
}
func (*Suite) TestReusableBeingUsedKey(c *check.C) {
user, err := db.CreateUser("test5")
c.Assert(err, check.IsNil)
pak, err := db.CreatePreAuthKey(types.UserID(user.ID), true, false, nil, nil)
c.Assert(err, check.IsNil)
node := types.Node{
ID: 1,
Hostname: "testest",
UserID: user.ID,
RegisterMethod: util.RegisterMethodAuthKey,
AuthKeyID: ptr.To(pak.ID),
}
trx := db.DB.Save(&node)
c.Assert(trx.Error, check.IsNil)
key, err := db.ValidatePreAuthKey(pak.Key)
c.Assert(err, check.IsNil)
c.Assert(key.ID, check.Equals, pak.ID)
}
func (*Suite) TestNotReusableNotBeingUsedKey(c *check.C) {
user, err := db.CreateUser("test6")
c.Assert(err, check.IsNil)
pak, err := db.CreatePreAuthKey(types.UserID(user.ID), false, false, nil, nil)
c.Assert(err, check.IsNil)
key, err := db.ValidatePreAuthKey(pak.Key)
c.Assert(err, check.IsNil)
c.Assert(key.ID, check.Equals, pak.ID)
}
func (*Suite) TestExpirePreauthKey(c *check.C) {
user, err := db.CreateUser("test3")
c.Assert(err, check.IsNil)
pak, err := db.CreatePreAuthKey(types.UserID(user.ID), true, false, nil, nil)
c.Assert(err, check.IsNil)
c.Assert(pak.Expiration, check.IsNil)
err = db.ExpirePreAuthKey(pak)
c.Assert(err, check.IsNil)
c.Assert(pak.Expiration, check.NotNil)
key, err := db.ValidatePreAuthKey(pak.Key)
c.Assert(err, check.Equals, ErrPreAuthKeyExpired)
c.Assert(key, check.IsNil)
}
func (*Suite) TestNotReusableMarkedAsUsed(c *check.C) {
user, err := db.CreateUser("test6")
c.Assert(err, check.IsNil)
pak, err := db.CreatePreAuthKey(types.UserID(user.ID), false, false, nil, nil)
c.Assert(err, check.IsNil)
pak.Used = true
db.DB.Save(&pak)
_, err = db.ValidatePreAuthKey(pak.Key)
c.Assert(err, check.Equals, ErrSingleUseAuthKeyHasBeenUsed)
}
func (*Suite) TestPreAuthKeyACLTags(c *check.C) {
user, err := db.CreateUser("test8")
user, err := db.CreateUser(types.User{Name: "test8"})
c.Assert(err, check.IsNil)
_, err = db.CreatePreAuthKey(types.UserID(user.ID), false, false, nil, []string{"badtag"})
@@ -175,3 +61,25 @@ func (*Suite) TestPreAuthKeyACLTags(c *check.C) {
sort.Sort(sort.StringSlice(gotTags))
c.Assert(gotTags, check.DeepEquals, tags)
}
func TestCannotDeleteAssignedPreAuthKey(t *testing.T) {
db, err := newSQLiteTestDB()
require.NoError(t, err)
user, err := db.CreateUser(types.User{Name: "test8"})
assert.NoError(t, err)
key, err := db.CreatePreAuthKey(types.UserID(user.ID), false, false, nil, []string{"tag:good"})
assert.NoError(t, err)
node := types.Node{
ID: 0,
Hostname: "testest",
UserID: user.ID,
RegisterMethod: util.RegisterMethodAuthKey,
AuthKeyID: ptr.To(key.ID),
}
db.DB.Save(&node)
err = db.DB.Delete(key).Error
require.ErrorContains(t, err, "constraint failed: FOREIGN KEY constraint failed")
}

View File

@@ -417,10 +417,10 @@ func SaveNodeRoutes(tx *gorm.DB, node *types.Node) (bool, error) {
return sendUpdate, nil
}
// FailoverNodeRoutesIfNeccessary takes a node and checks if the node's route
// FailoverNodeRoutesIfNecessary takes a node and checks if the node's route
// need to be failed over to another host.
// If needed, the failover will be attempted.
func FailoverNodeRoutesIfNeccessary(
func FailoverNodeRoutesIfNecessary(
tx *gorm.DB,
isLikelyConnected *xsync.MapOf[types.NodeID, bool],
node *types.Node,
@@ -473,7 +473,7 @@ nodeRouteLoop:
return &types.StateUpdate{
Type: types.StatePeerChanged,
ChangeNodes: chng,
Message: "called from db.FailoverNodeRoutesIfNeccessary",
Message: "called from db.FailoverNodeRoutesIfNecessary",
}, nil
}

View File

@@ -32,7 +32,7 @@ var mp = func(p string) netip.Prefix {
}
func (s *Suite) TestGetRoutes(c *check.C) {
user, err := db.CreateUser("test")
user, err := db.CreateUser(types.User{Name: "test"})
c.Assert(err, check.IsNil)
pak, err := db.CreatePreAuthKey(types.UserID(user.ID), false, false, nil, nil)
@@ -76,7 +76,7 @@ func (s *Suite) TestGetRoutes(c *check.C) {
}
func (s *Suite) TestGetEnableRoutes(c *check.C) {
user, err := db.CreateUser("test")
user, err := db.CreateUser(types.User{Name: "test"})
c.Assert(err, check.IsNil)
pak, err := db.CreatePreAuthKey(types.UserID(user.ID), false, false, nil, nil)
@@ -150,7 +150,7 @@ func (s *Suite) TestGetEnableRoutes(c *check.C) {
}
func (s *Suite) TestIsUniquePrefix(c *check.C) {
user, err := db.CreateUser("test")
user, err := db.CreateUser(types.User{Name: "test"})
c.Assert(err, check.IsNil)
pak, err := db.CreatePreAuthKey(types.UserID(user.ID), false, false, nil, nil)
@@ -231,7 +231,7 @@ func (s *Suite) TestIsUniquePrefix(c *check.C) {
}
func (s *Suite) TestDeleteRoutes(c *check.C) {
user, err := db.CreateUser("test")
user, err := db.CreateUser(types.User{Name: "test"})
c.Assert(err, check.IsNil)
pak, err := db.CreatePreAuthKey(types.UserID(user.ID), false, false, nil, nil)
@@ -342,7 +342,7 @@ func dbForTest(t *testing.T, testName string) *HSDatabase {
return db
}
func TestFailoverNodeRoutesIfNeccessary(t *testing.T) {
func TestFailoverNodeRoutesIfNecessary(t *testing.T) {
su := func(nids ...types.NodeID) *types.StateUpdate {
return &types.StateUpdate{
ChangeNodes: nids,
@@ -648,7 +648,7 @@ func TestFailoverNodeRoutesIfNeccessary(t *testing.T) {
want := tt.want[step]
got, err := Write(db.DB, func(tx *gorm.DB) (*types.StateUpdate, error) {
return FailoverNodeRoutesIfNeccessary(tx, smap(isConnected), node)
return FailoverNodeRoutesIfNecessary(tx, smap(isConnected), node)
})
if (err != nil) != tt.wantErr {

View File

@@ -78,13 +78,11 @@ func newSQLiteTestDB() (*HSDatabase, error) {
func newPostgresTestDB(t *testing.T) *HSDatabase {
t.Helper()
var err error
tmpDir, err = os.MkdirTemp("", "headscale-db-test-*")
if err != nil {
t.Fatal(err)
}
return newHeadscaleDBFromPostgresURL(t, newPostgresDBForTest(t))
}
log.Printf("database path: %s", tmpDir+"/headscale_test.db")
func newPostgresDBForTest(t *testing.T) *url.URL {
t.Helper()
ctx := context.Background()
srv, err := postgrestest.Start(ctx)
@@ -100,10 +98,16 @@ func newPostgresTestDB(t *testing.T) *HSDatabase {
t.Logf("created local postgres: %s", u)
pu, _ := url.Parse(u)
return pu
}
func newHeadscaleDBFromPostgresURL(t *testing.T, pu *url.URL) *HSDatabase {
t.Helper()
pass, _ := pu.User.Password()
port, _ := strconv.Atoi(pu.Port())
db, err = NewHeadscaleDatabase(
db, err := NewHeadscaleDatabase(
types.DatabaseConfig{
Type: types.DatabasePostgres,
Postgres: types.PostgresConfig{

Binary file not shown.

Binary file not shown.

View File

@@ -15,22 +15,19 @@ var (
ErrUserStillHasNodes = errors.New("user not empty: node(s) found")
)
func (hsdb *HSDatabase) CreateUser(name string) (*types.User, error) {
func (hsdb *HSDatabase) CreateUser(user types.User) (*types.User, error) {
return Write(hsdb.DB, func(tx *gorm.DB) (*types.User, error) {
return CreateUser(tx, name)
return CreateUser(tx, user)
})
}
// CreateUser creates a new User. Returns error if could not be created
// or another user already exists.
func CreateUser(tx *gorm.DB, name string) (*types.User, error) {
err := util.CheckForFQDNRules(name)
func CreateUser(tx *gorm.DB, user types.User) (*types.User, error) {
err := util.ValidateUsername(user.Name)
if err != nil {
return nil, err
}
user := types.User{
Name: name,
}
if err := tx.Create(&user).Error; err != nil {
return nil, fmt.Errorf("creating user: %w", err)
}
@@ -84,6 +81,8 @@ func (hsdb *HSDatabase) RenameUser(uid types.UserID, newName string) error {
})
}
var ErrCannotChangeOIDCUser = errors.New("cannot edit OIDC user")
// RenameUser renames a User. Returns error if the User does
// not exist or if another User exists with the new name.
func RenameUser(tx *gorm.DB, uid types.UserID, newName string) error {
@@ -92,11 +91,15 @@ func RenameUser(tx *gorm.DB, uid types.UserID, newName string) error {
if err != nil {
return err
}
err = util.CheckForFQDNRules(newName)
err = util.ValidateUsername(newName)
if err != nil {
return err
}
if oldUser.Provider == util.RegisterMethodOIDC {
return ErrCannotChangeOIDCUser
}
oldUser.Name = newName
if err := tx.Save(&oldUser).Error; err != nil {

View File

@@ -11,7 +11,7 @@ import (
)
func (s *Suite) TestCreateAndDestroyUser(c *check.C) {
user, err := db.CreateUser("test")
user, err := db.CreateUser(types.User{Name: "test"})
c.Assert(err, check.IsNil)
c.Assert(user.Name, check.Equals, "test")
@@ -30,7 +30,7 @@ func (s *Suite) TestDestroyUserErrors(c *check.C) {
err := db.DestroyUser(9998)
c.Assert(err, check.Equals, ErrUserNotFound)
user, err := db.CreateUser("test")
user, err := db.CreateUser(types.User{Name: "test"})
c.Assert(err, check.IsNil)
pak, err := db.CreatePreAuthKey(types.UserID(user.ID), false, false, nil, nil)
@@ -43,7 +43,7 @@ func (s *Suite) TestDestroyUserErrors(c *check.C) {
// destroying a user also deletes all associated preauthkeys
c.Assert(result.Error, check.Equals, gorm.ErrRecordNotFound)
user, err = db.CreateUser("test")
user, err = db.CreateUser(types.User{Name: "test"})
c.Assert(err, check.IsNil)
pak, err = db.CreatePreAuthKey(types.UserID(user.ID), false, false, nil, nil)
@@ -64,7 +64,7 @@ func (s *Suite) TestDestroyUserErrors(c *check.C) {
}
func (s *Suite) TestRenameUser(c *check.C) {
userTest, err := db.CreateUser("test")
userTest, err := db.CreateUser(types.User{Name: "test"})
c.Assert(err, check.IsNil)
c.Assert(userTest.Name, check.Equals, "test")
@@ -86,7 +86,7 @@ func (s *Suite) TestRenameUser(c *check.C) {
err = db.RenameUser(99988, "test")
c.Assert(err, check.Equals, ErrUserNotFound)
userTest2, err := db.CreateUser("test2")
userTest2, err := db.CreateUser(types.User{Name: "test2"})
c.Assert(err, check.IsNil)
c.Assert(userTest2.Name, check.Equals, "test2")
@@ -98,10 +98,10 @@ func (s *Suite) TestRenameUser(c *check.C) {
}
func (s *Suite) TestSetMachineUser(c *check.C) {
oldUser, err := db.CreateUser("old")
oldUser, err := db.CreateUser(types.User{Name: "old"})
c.Assert(err, check.IsNil)
newUser, err := db.CreateUser("new")
newUser, err := db.CreateUser(types.User{Name: "new"})
c.Assert(err, check.IsNil)
pak, err := db.CreatePreAuthKey(types.UserID(oldUser.ID), false, false, nil, nil)

View File

@@ -7,6 +7,7 @@ import (
"os"
"sync"
"github.com/cenkalti/backoff/v4"
"github.com/fsnotify/fsnotify"
"github.com/rs/zerolog/log"
"tailscale.com/tailcfg"
@@ -83,12 +84,39 @@ func (e *ExtraRecordsMan) Run() {
log.Error().Caller().Msgf("file watcher event channel closing")
return
}
switch event.Op {
case fsnotify.Create, fsnotify.Write, fsnotify.Chmod:
log.Trace().Caller().Str("path", event.Name).Str("op", event.Op.String()).Msg("extra records received filewatch event")
if event.Name != e.path {
continue
}
e.updateRecords()
log.Trace().Caller().Str("path", event.Name).Str("op", event.Op.String()).Msg("extra records received filewatch event")
if event.Name != e.path {
continue
// If a file is removed or renamed, fsnotify will loose track of it
// and not watch it. We will therefore attempt to re-add it with a backoff.
case fsnotify.Remove, fsnotify.Rename:
err := backoff.Retry(func() error {
if _, err := os.Stat(e.path); err != nil {
return err
}
return nil
}, backoff.NewExponentialBackOff())
if err != nil {
log.Error().Caller().Err(err).Msgf("extra records filewatcher retrying to find file after delete")
continue
}
err = e.watcher.Add(e.path)
if err != nil {
log.Error().Caller().Err(err).Msgf("extra records filewatcher re-adding file after delete failed, giving up.")
return
} else {
log.Trace().Caller().Str("path", e.path).Msg("extra records file re-added after delete")
e.updateRecords()
}
}
e.updateRecords()
case err, ok := <-e.watcher.Errors:
if !ok {
@@ -116,6 +144,11 @@ func (e *ExtraRecordsMan) updateRecords() {
return
}
// If there are no records, ignore the update.
if records == nil {
return
}
e.mu.Lock()
defer e.mu.Unlock()
@@ -143,6 +176,12 @@ func readExtraRecordsFromPath(path string) ([]tailcfg.DNSRecord, [32]byte, error
return nil, [32]byte{}, fmt.Errorf("reading path: %s, err: %w", path, err)
}
// If the read was triggered too fast, and the file is not complete, ignore the update
// if the file is empty. A consecutive update will be triggered when the file is complete.
if len(b) == 0 {
return nil, [32]byte{}, nil
}
var records []tailcfg.DNSRecord
err = json.Unmarshal(b, &records)
if err != nil {

View File

@@ -11,7 +11,9 @@ import (
"strings"
"time"
"github.com/puzpuzpuz/xsync/v3"
"github.com/rs/zerolog/log"
"github.com/samber/lo"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
@@ -21,6 +23,7 @@ import (
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
"github.com/juanfont/headscale/hscontrol/db"
"github.com/juanfont/headscale/hscontrol/policy"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/util"
)
@@ -40,7 +43,13 @@ func (api headscaleV1APIServer) CreateUser(
ctx context.Context,
request *v1.CreateUserRequest,
) (*v1.CreateUserResponse, error) {
user, err := api.h.db.CreateUser(request.GetName())
newUser := types.User{
Name: request.GetName(),
DisplayName: request.GetDisplayName(),
Email: request.GetEmail(),
ProfilePicURL: request.GetPictureUrl(),
}
user, err := api.h.db.CreateUser(newUser)
if err != nil {
return nil, err
}
@@ -172,11 +181,15 @@ func (api headscaleV1APIServer) ExpirePreAuthKey(
request *v1.ExpirePreAuthKeyRequest,
) (*v1.ExpirePreAuthKeyResponse, error) {
err := api.h.db.Write(func(tx *gorm.DB) error {
preAuthKey, err := db.GetPreAuthKey(tx, request.GetUser(), request.Key)
preAuthKey, err := db.GetPreAuthKey(tx, request.Key)
if err != nil {
return err
}
if preAuthKey.User.Name != request.GetUser() {
return fmt.Errorf("preauth key does not belong to user")
}
return db.ExpirePreAuthKey(tx, preAuthKey)
})
if err != nil {
@@ -218,11 +231,10 @@ func (api headscaleV1APIServer) RegisterNode(
) (*v1.RegisterNodeResponse, error) {
log.Trace().
Str("user", request.GetUser()).
Str("machine_key", request.GetKey()).
Str("registration_id", request.GetKey()).
Msg("Registering node")
var mkey key.MachinePublic
err := mkey.UnmarshalText([]byte(request.GetKey()))
registrationId, err := types.RegistrationIDFromString(request.GetKey())
if err != nil {
return nil, err
}
@@ -237,8 +249,8 @@ func (api headscaleV1APIServer) RegisterNode(
return nil, fmt.Errorf("looking up user: %w", err)
}
node, err := api.h.db.RegisterNodeFromAuthCallback(
mkey,
node, _, err := api.h.db.HandleNodeFromAuthPath(
registrationId,
types.UserID(user.ID),
nil,
util.RegisterMethodCLI,
@@ -248,10 +260,17 @@ func (api headscaleV1APIServer) RegisterNode(
return nil, err
}
err = nodesChangedHook(api.h.db, api.h.polMan, api.h.nodeNotifier)
updateSent, err := nodesChangedHook(api.h.db, api.h.polMan, api.h.nodeNotifier)
if err != nil {
return nil, fmt.Errorf("updating resources using node: %w", err)
}
if !updateSent {
ctx = types.NotifyCtx(context.Background(), "web-node-login", node.Hostname)
api.h.nodeNotifier.NotifyAll(ctx, types.StateUpdate{
Type: types.StatePeerChanged,
ChangeNodes: []types.NodeID{node.ID},
})
}
return &v1.RegisterNodeResponse{Node: node.Proto()}, nil
}
@@ -457,19 +476,7 @@ func (api headscaleV1APIServer) ListNodes(
return nil, err
}
response := make([]*v1.Node, len(nodes))
for index, node := range nodes {
resp := node.Proto()
// Populate the online field based on
// currently connected nodes.
if val, ok := isLikelyConnected.Load(node.ID); ok && val {
resp.Online = true
}
response[index] = resp
}
response := nodesToProto(api.h.polMan, isLikelyConnected, nodes)
return &v1.ListNodesResponse{Nodes: response}, nil
}
@@ -482,6 +489,11 @@ func (api headscaleV1APIServer) ListNodes(
return nodes[i].ID < nodes[j].ID
})
response := nodesToProto(api.h.polMan, isLikelyConnected, nodes)
return &v1.ListNodesResponse{Nodes: response}, nil
}
func nodesToProto(polMan policy.PolicyManager, isLikelyConnected *xsync.MapOf[types.NodeID, bool], nodes types.Nodes) []*v1.Node {
response := make([]*v1.Node, len(nodes))
for index, node := range nodes {
resp := node.Proto()
@@ -492,12 +504,12 @@ func (api headscaleV1APIServer) ListNodes(
resp.Online = true
}
validTags := api.h.polMan.Tags(node)
resp.ValidTags = validTags
tags := polMan.Tags(node)
resp.ValidTags = lo.Uniq(append(tags, node.ForcedTags...))
response[index] = resp
}
return &v1.ListNodesResponse{Nodes: response}, nil
return response
}
func (api headscaleV1APIServer) MoveNode(
@@ -837,36 +849,36 @@ func (api headscaleV1APIServer) DebugCreateNode(
Hostname: "DebugTestNode",
}
var mkey key.MachinePublic
err = mkey.UnmarshalText([]byte(request.GetKey()))
registrationId, err := types.RegistrationIDFromString(request.GetKey())
if err != nil {
return nil, err
}
nodeKey := key.NewNode()
newNode := types.RegisterNode{
Node: types.Node{
NodeKey: key.NewNode().Public(),
MachineKey: key.NewMachine().Public(),
Hostname: request.GetName(),
User: *user,
newNode := types.Node{
MachineKey: mkey,
NodeKey: nodeKey.Public(),
Hostname: request.GetName(),
User: *user,
Expiry: &time.Time{},
LastSeen: &time.Time{},
Expiry: &time.Time{},
LastSeen: &time.Time{},
Hostinfo: &hostinfo,
Hostinfo: &hostinfo,
},
Registered: make(chan struct{}),
}
log.Debug().
Str("machine_key", mkey.ShortString()).
Str("registration_id", registrationId.String()).
Msg("adding debug machine via CLI, appending to registration cache")
api.h.registrationCache.Set(
mkey.String(),
registrationId,
newNode,
)
return &v1.DebugCreateNodeResponse{Node: newNode.Proto()}, nil
return &v1.DebugCreateNodeResponse{Node: newNode.Node.Proto()}, nil
}
func (api headscaleV1APIServer) mustEmbedUnimplementedHeadscaleServiceServer() {}

View File

@@ -8,16 +8,13 @@ import (
"net/http"
"strconv"
"strings"
"time"
"github.com/chasefleming/elem-go"
"github.com/chasefleming/elem-go/attrs"
"github.com/chasefleming/elem-go/styles"
"github.com/gorilla/mux"
"github.com/juanfont/headscale/hscontrol/templates"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/rs/zerolog/log"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
)
const (
@@ -32,11 +29,38 @@ const (
// See also https://github.com/tailscale/tailscale/blob/main/tailcfg/tailcfg.go
NoiseCapabilityVersion = 39
// TODO(juan): remove this once https://github.com/juanfont/headscale/issues/727 is fixed.
registrationHoldoff = time.Second * 5
reservedResponseHeaderSize = 4
)
// httpError logs an error and sends an HTTP error response with the given
func httpError(w http.ResponseWriter, err error) {
var herr HTTPError
if errors.As(err, &herr) {
http.Error(w, herr.Msg, herr.Code)
log.Error().Err(herr.Err).Int("code", herr.Code).Msgf("user msg: %s", herr.Msg)
} else {
http.Error(w, "internal server error", http.StatusInternalServerError)
log.Error().Err(err).Int("code", http.StatusInternalServerError).Msg("http internal server error")
}
}
// HTTPError represents an error that is surfaced to the user via web.
type HTTPError struct {
Code int // HTTP response code to send to client; 0 means 500
Msg string // Response body to send to client
Err error // Detailed error to log on the server
}
func (e HTTPError) Error() string { return fmt.Sprintf("http error[%d]: %s, %s", e.Code, e.Msg, e.Err) }
func (e HTTPError) Unwrap() error { return e.Err }
// Error returns an HTTPError containing the given information.
func NewHTTPError(code int, msg string, err error) HTTPError {
return HTTPError{Code: code, Msg: msg, Err: err}
}
var errMethodNotAllowed = NewHTTPError(http.StatusMethodNotAllowed, "method not allowed", nil)
var ErrRegisterMethodCLIDoesNotSupportExpire = errors.New(
"machines registered with CLI does not support expire",
)
@@ -46,18 +70,18 @@ func parseCabailityVersion(req *http.Request) (tailcfg.CapabilityVersion, error)
clientCapabilityStr := req.URL.Query().Get("v")
if clientCapabilityStr == "" {
return 0, ErrNoCapabilityVersion
return 0, NewHTTPError(http.StatusBadRequest, "capability version must be set", nil)
}
clientCapabilityVersion, err := strconv.Atoi(clientCapabilityStr)
if err != nil {
return 0, fmt.Errorf("failed to parse capability version: %w", err)
return 0, NewHTTPError(http.StatusBadRequest, "invalid capability version", fmt.Errorf("failed to parse capability version: %w", err))
}
return tailcfg.CapabilityVersion(clientCapabilityVersion), nil
}
func (h *Headscale) handleVerifyRequest(
func (h *Headscale) derpRequestIsAllowed(
req *http.Request,
) (bool, error) {
body, err := io.ReadAll(req.Body)
@@ -84,21 +108,14 @@ func (h *Headscale) VerifyHandler(
req *http.Request,
) {
if req.Method != http.MethodPost {
http.Error(writer, "Wrong method", http.StatusMethodNotAllowed)
httpError(writer, errMethodNotAllowed)
return
}
log.Debug().
Str("handler", "/verify").
Msg("verify client")
allow, err := h.handleVerifyRequest(req)
allow, err := h.derpRequestIsAllowed(req)
if err != nil {
log.Error().
Caller().
Err(err).
Msg("Failed to verify client")
http.Error(writer, "Internal error", http.StatusInternalServerError)
httpError(writer, err)
return
}
resp := tailcfg.DERPAdmitClientResponse{
@@ -106,14 +123,7 @@ func (h *Headscale) VerifyHandler(
}
writer.Header().Set("Content-Type", "application/json")
writer.WriteHeader(http.StatusOK)
err = json.NewEncoder(writer).Encode(resp)
if err != nil {
log.Error().
Caller().
Err(err).
Msg("Failed to write response")
}
json.NewEncoder(writer).Encode(resp)
}
// KeyHandler provides the Headscale pub key
@@ -125,35 +135,17 @@ func (h *Headscale) KeyHandler(
// New Tailscale clients send a 'v' parameter to indicate the CurrentCapabilityVersion
capVer, err := parseCabailityVersion(req)
if err != nil {
log.Error().
Caller().
Err(err).
Msg("could not get capability version")
writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
writer.WriteHeader(http.StatusInternalServerError)
httpError(writer, err)
return
}
log.Debug().
Str("handler", "/key").
Int("cap_ver", int(capVer)).
Msg("New noise client")
// 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")
writer.WriteHeader(http.StatusOK)
err = json.NewEncoder(writer).Encode(resp)
if err != nil {
log.Error().
Caller().
Err(err).
Msg("Failed to write response")
}
json.NewEncoder(writer).Encode(resp)
return
}
@@ -174,18 +166,10 @@ func (h *Headscale) HealthHandler(
if err != nil {
writer.WriteHeader(http.StatusInternalServerError)
log.Error().Caller().Err(err).Msg("health check failed")
res.Status = "fail"
}
buf, err := json.Marshal(res)
if err != nil {
log.Error().Caller().Err(err).Msg("marshal failed")
}
_, err = writer.Write(buf)
if err != nil {
log.Error().Caller().Err(err).Msg("write failed")
}
json.NewEncoder(writer).Encode(res)
}
if err := h.db.PingDB(req.Context()); err != nil {
@@ -204,31 +188,6 @@ var codeStyleRegisterWebAPI = styles.Props{
styles.BackgroundColor: "#eee",
}
func registerWebHTML(key string) *elem.Element {
return elem.Html(nil,
elem.Head(
nil,
elem.Title(nil, elem.Text("Registration - Headscale")),
elem.Meta(attrs.Props{
attrs.Name: "viewport",
attrs.Content: "width=device-width, initial-scale=1",
}),
),
elem.Body(attrs.Props{
attrs.Style: styles.Props{
styles.FontFamily: "sans",
}.ToInline(),
},
elem.H1(nil, elem.Text("headscale")),
elem.H2(nil, elem.Text("Machine registration")),
elem.P(nil, elem.Text("Run the command below in the headscale server to add this machine to your network:")),
elem.Code(attrs.Props{attrs.Style: codeStyleRegisterWebAPI.ToInline()},
elem.Text(fmt.Sprintf("headscale nodes register --user USERNAME --key %s", key)),
),
),
)
}
type AuthProviderWeb struct {
serverURL string
}
@@ -239,15 +198,15 @@ func NewAuthProviderWeb(serverURL string) *AuthProviderWeb {
}
}
func (a *AuthProviderWeb) AuthURL(mKey key.MachinePublic) string {
func (a *AuthProviderWeb) AuthURL(registrationId types.RegistrationID) string {
return fmt.Sprintf(
"%s/register/%s",
strings.TrimSuffix(a.serverURL, "/"),
mKey.String())
registrationId.String())
}
// RegisterWebAPI shows a simple message in the browser to point to the CLI
// Listens in /register/:nkey.
// Listens in /register/:registration_id.
//
// This is not part of the Tailscale control API, as we could send whatever URL
// in the RegisterResponse.AuthURL field.
@@ -256,39 +215,18 @@ func (a *AuthProviderWeb) RegisterHandler(
req *http.Request,
) {
vars := mux.Vars(req)
machineKeyStr := vars["mkey"]
registrationIdStr := vars["registration_id"]
// We need to make sure we dont open for XSS style injections, if the parameter that
// is passed as a key is not parsable/validated as a NodePublic key, then fail to render
// the template and log an error.
var machineKey key.MachinePublic
err := machineKey.UnmarshalText(
[]byte(machineKeyStr),
)
registrationId, err := types.RegistrationIDFromString(registrationIdStr)
if err != nil {
log.Warn().Err(err).Msg("Failed to parse incoming machinekey")
writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
writer.WriteHeader(http.StatusBadRequest)
_, err := writer.Write([]byte("Wrong params"))
if err != nil {
log.Error().
Caller().
Err(err).
Msg("Failed to write response")
}
httpError(writer, NewHTTPError(http.StatusBadRequest, "invalid registration id", err))
return
}
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
writer.WriteHeader(http.StatusOK)
if _, err := writer.Write([]byte(registerWebHTML(machineKey.String()).Render())); err != nil {
if _, err := writer.Write([]byte(templates.RegisterWeb(machineKey.String()).Render())); err != nil {
log.Error().
Caller().
Err(err).
Msg("Failed to write response")
}
}
writer.Write([]byte(templates.RegisterWeb(registrationId).Render()))
}

View File

@@ -105,8 +105,7 @@ func generateUserProfiles(
var profiles []tailcfg.UserProfile
for _, user := range userMap {
profiles = append(profiles,
user.TailscaleUserProfile())
profiles = append(profiles, user.TailscaleUserProfile())
}
return profiles
@@ -455,7 +454,7 @@ func (m *Mapper) baseWithConfigMapResponse(
resp.DERPMap = m.derpMap
resp.Domain = m.cfg.BaseDomain
resp.Domain = m.cfg.Domain()
// Do not instruct clients to collect services we do not
// support or do anything with them

View File

@@ -227,7 +227,8 @@ func Test_fullMapResponse(t *testing.T) {
tsaddr.AllIPv4(),
netip.MustParsePrefix("192.168.0.0/24"),
},
DERP: "127.3.3.40:0",
HomeDERP: 0,
LegacyDERPString: "127.3.3.40:0",
Hostinfo: hiview(tailcfg.Hostinfo{}),
Created: created,
Tags: []string{},
@@ -282,7 +283,8 @@ func Test_fullMapResponse(t *testing.T) {
),
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.2/32")},
AllowedIPs: []netip.Prefix{netip.MustParsePrefix("100.64.0.2/32")},
DERP: "127.3.3.40:0",
HomeDERP: 0,
LegacyDERPString: "127.3.3.40:0",
Hostinfo: hiview(tailcfg.Hostinfo{}),
Created: created,
Tags: []string{},

View File

@@ -62,11 +62,16 @@ func tailNode(
}
}
var derp string
var derp int
// TODO(kradalby): legacyDERP was removed in tailscale/tailscale@2fc4455e6dd9ab7f879d4e2f7cffc2be81f14077
// and should be removed after 111 is the minimum capver.
var legacyDERP string
if node.Hostinfo != nil && node.Hostinfo.NetInfo != nil {
derp = fmt.Sprintf("127.3.3.40:%d", node.Hostinfo.NetInfo.PreferredDERP)
legacyDERP = fmt.Sprintf("127.3.3.40:%d", node.Hostinfo.NetInfo.PreferredDERP)
derp = node.Hostinfo.NetInfo.PreferredDERP
} else {
derp = "127.3.3.40:0" // Zero means disconnected or unknown.
legacyDERP = "127.3.3.40:0" // Zero means disconnected or unknown.
}
var keyExpiry time.Time
@@ -95,14 +100,15 @@ func tailNode(
Key: node.NodeKey,
KeyExpiry: keyExpiry.UTC(),
Machine: node.MachineKey,
DiscoKey: node.DiscoKey,
Addresses: addrs,
AllowedIPs: allowedIPs,
Endpoints: node.Endpoints,
DERP: derp,
Hostinfo: node.Hostinfo.View(),
Created: node.CreatedAt.UTC(),
Machine: node.MachineKey,
DiscoKey: node.DiscoKey,
Addresses: addrs,
AllowedIPs: allowedIPs,
Endpoints: node.Endpoints,
HomeDERP: derp,
LegacyDERPString: legacyDERP,
Hostinfo: node.Hostinfo.View(),
Created: node.CreatedAt.UTC(),
Online: node.IsOnline,

View File

@@ -68,7 +68,8 @@ func TestTailNode(t *testing.T) {
StableID: "0",
Addresses: []netip.Prefix{},
AllowedIPs: []netip.Prefix{},
DERP: "127.3.3.40:0",
HomeDERP: 0,
LegacyDERPString: "127.3.3.40:0",
Hostinfo: hiview(tailcfg.Hostinfo{}),
Tags: []string{},
PrimaryRoutes: []netip.Prefix{},
@@ -156,9 +157,10 @@ func TestTailNode(t *testing.T) {
tsaddr.AllIPv4(),
netip.MustParsePrefix("192.168.0.0/24"),
},
DERP: "127.3.3.40:0",
Hostinfo: hiview(tailcfg.Hostinfo{}),
Created: created,
HomeDERP: 0,
LegacyDERPString: "127.3.3.40:0",
Hostinfo: hiview(tailcfg.Hostinfo{}),
Created: created,
Tags: []string{},

View File

@@ -3,15 +3,19 @@ package hscontrol
import (
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"github.com/gorilla/mux"
"github.com/juanfont/headscale/hscontrol/capver"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/rs/zerolog/log"
"golang.org/x/net/http2"
"gorm.io/gorm"
"tailscale.com/control/controlbase"
"tailscale.com/control/controlhttp"
"tailscale.com/control/controlhttp/controlhttpserver"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
)
@@ -71,7 +75,7 @@ func (h *Headscale) NoiseUpgradeHandler(
challenge: key.NewChallenge(),
}
noiseConn, err := controlhttp.AcceptHTTP(
noiseConn, err := controlhttpserver.AcceptHTTP(
req.Context(),
writer,
req,
@@ -79,9 +83,7 @@ func (h *Headscale) NoiseUpgradeHandler(
noiseServer.earlyNoise,
)
if err != nil {
log.Error().Err(err).Msg("noise upgrade failed")
http.Error(writer, err.Error(), http.StatusInternalServerError)
httpError(writer, fmt.Errorf("noise upgrade failed: %w", err))
return
}
@@ -115,18 +117,8 @@ func (h *Headscale) NoiseUpgradeHandler(
}
func (ns *noiseServer) earlyNoise(protocolVersion int, writer io.Writer) error {
log.Trace().
Caller().
Int("protocol_version", protocolVersion).
Str("challenge", ns.challenge.Public().String()).
Msg("earlyNoise called")
if protocolVersion < earlyNoiseCapabilityVersion {
log.Trace().
Caller().
Msgf("protocol version %d does not support early noise", protocolVersion)
return nil
if !isSupportedVersion(tailcfg.CapabilityVersion(protocolVersion)) {
return fmt.Errorf("unsupported client version: %d", protocolVersion)
}
earlyJSON, err := json.Marshal(&tailcfg.EarlyNoise{
@@ -158,9 +150,34 @@ func (ns *noiseServer) earlyNoise(protocolVersion int, writer io.Writer) error {
return nil
}
const (
MinimumCapVersion tailcfg.CapabilityVersion = 82
)
func isSupportedVersion(version tailcfg.CapabilityVersion) bool {
return version >= capver.MinSupportedCapabilityVersion
}
func rejectUnsupported(
writer http.ResponseWriter,
version tailcfg.CapabilityVersion,
mkey key.MachinePublic,
nkey key.NodePublic,
) bool {
// Reject unsupported versions
if !isSupportedVersion(version) {
log.Error().
Caller().
Int("minimum_cap_ver", int(capver.MinSupportedCapabilityVersion)).
Int("client_cap_ver", int(version)).
Str("minimum_version", capver.TailscaleVersion(capver.MinSupportedCapabilityVersion)).
Str("client_version", capver.TailscaleVersion(version)).
Str("node_key", nkey.ShortString()).
Str("machine_key", mkey.ShortString()).
Msg("unsupported client connected")
http.Error(writer, "unsupported client version", http.StatusBadRequest)
return true
}
return false
}
// NoisePollNetMapHandler takes care of /machine/:id/map using the Noise protocol
//
@@ -177,50 +194,26 @@ func (ns *noiseServer) NoisePollNetMapHandler(
) {
body, _ := io.ReadAll(req.Body)
mapRequest := tailcfg.MapRequest{}
var mapRequest tailcfg.MapRequest
if err := json.Unmarshal(body, &mapRequest); err != nil {
log.Error().
Caller().
Err(err).
Msg("Cannot parse MapRequest")
http.Error(writer, "Internal error", http.StatusInternalServerError)
httpError(writer, err)
return
}
log.Trace().
Caller().
Str("handler", "NoisePollNetMap").
Any("headers", req.Header).
Str("node", mapRequest.Hostinfo.Hostname).
Int("capver", int(mapRequest.Version)).
Msg("PollNetMapHandler called")
// Reject unsupported versions
if mapRequest.Version < MinimumCapVersion {
log.Info().
Caller().
Int("min_version", int(MinimumCapVersion)).
Int("client_version", int(mapRequest.Version)).
Msg("unsupported client connected")
http.Error(writer, "Internal error", http.StatusBadRequest)
if rejectUnsupported(writer, mapRequest.Version, ns.machineKey, mapRequest.NodeKey) {
return
}
ns.nodeKey = mapRequest.NodeKey
node, err := ns.headscale.db.GetNodeByAnyKey(
ns.conn.Peer(),
mapRequest.NodeKey,
key.NodePublic{},
)
node, err := ns.headscale.db.GetNodeByNodeKey(mapRequest.NodeKey)
if err != nil {
log.Error().
Str("handler", "NoisePollNetMap").
Msgf("Failed to fetch node from the database with node key: %s", mapRequest.NodeKey.String())
http.Error(writer, "Internal error", http.StatusInternalServerError)
if errors.Is(err, gorm.ErrRecordNotFound) {
httpError(writer, NewHTTPError(http.StatusNotFound, "node not found", nil))
return
}
httpError(writer, err)
return
}
@@ -232,3 +225,64 @@ func (ns *noiseServer) NoisePollNetMapHandler(
sess.serveLongPoll()
}
}
// NoiseRegistrationHandler handles the actual registration process of a node.
func (ns *noiseServer) NoiseRegistrationHandler(
writer http.ResponseWriter,
req *http.Request,
) {
if req.Method != http.MethodPost {
httpError(writer, errMethodNotAllowed)
return
}
registerRequest, registerResponse, err := func() (*tailcfg.RegisterRequest, []byte, error) {
body, err := io.ReadAll(req.Body)
if err != nil {
return nil, nil, err
}
var registerRequest tailcfg.RegisterRequest
if err := json.Unmarshal(body, &registerRequest); err != nil {
return nil, nil, err
}
ns.nodeKey = registerRequest.NodeKey
resp, err := ns.headscale.handleRegister(req.Context(), registerRequest, ns.conn.Peer())
// TODO(kradalby): Here we could have two error types, one that is surfaced to the client
// and one that returns 500.
if err != nil {
return nil, nil, err
}
respBody, err := json.Marshal(resp)
if err != nil {
return nil, nil, err
}
return &registerRequest, respBody, nil
}()
if err != nil {
log.Error().
Caller().
Err(err).
Msg("Error handling registration")
http.Error(writer, "Internal server error", http.StatusInternalServerError)
}
// Reject unsupported versions
if rejectUnsupported(writer, registerRequest.Version, ns.machineKey, registerRequest.NodeKey) {
return
}
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
writer.WriteHeader(http.StatusOK)
_, err = writer.Write(registerResponse)
if err != nil {
log.Error().
Caller().
Err(err).
Msg("Failed to write response")
}
}

View File

@@ -153,7 +153,7 @@ func (n *Notifier) IsConnected(nodeID types.NodeID) bool {
}
// IsLikelyConnected reports if a node is connected to headscale and has a
// poll session open, but doesnt lock, so might be wrong.
// poll session open, but doesn't lock, so might be wrong.
func (n *Notifier) IsLikelyConnected(nodeID types.NodeID) bool {
if val, ok := n.connected.Load(nodeID); ok {
return val
@@ -243,7 +243,7 @@ func (n *Notifier) sendAll(update types.StateUpdate) {
// has shut down the channel and is waiting for the lock held here in RemoveNode.
// This means that there is potential for a deadlock which would stop all updates
// going out to clients. This timeout prevents that from happening by moving on to the
// next node if the context is cancelled. Afther sendAll releases the lock, the add/remove
// next node if the context is cancelled. After sendAll releases the lock, the add/remove
// call will succeed and the update will go to the correct nodes on the next call.
ctx, cancel := context.WithTimeout(context.Background(), n.cfg.Tuning.NotifierSendTimeout)
defer cancel()

View File

@@ -223,7 +223,7 @@ func TestBatcher(t *testing.T) {
// so do not run the worker.
BatchChangeDelay: time.Hour,
// Since we do not load the config, we wont get the
// Since we do not load the config, we won't get the
// default, so set it manually so we dont time out
// and have flakes.
NotifierSendTimeout: time.Second,

View File

@@ -3,9 +3,7 @@ package hscontrol
import (
"bytes"
"context"
"crypto/rand"
_ "embed"
"encoding/hex"
"errors"
"fmt"
"html/template"
@@ -23,17 +21,18 @@ import (
"github.com/juanfont/headscale/hscontrol/util"
"github.com/rs/zerolog/log"
"golang.org/x/oauth2"
"tailscale.com/types/key"
"zgo.at/zcache/v2"
)
const (
randomByteSize = 16
randomByteSize = 16
defaultOAuthOptionsCount = 3
)
var (
errEmptyOIDCCallbackParams = errors.New("empty OIDC callback params")
errNoOIDCIDToken = errors.New("could not extract ID Token for OIDC callback")
errNoOIDCRegistrationInfo = errors.New("could not get registration info from cache")
errOIDCAllowedDomains = errors.New(
"authenticated principal does not match any allowed domain",
)
@@ -47,11 +46,17 @@ var (
errOIDCNodeKeyMissing = errors.New("could not get node key from cache")
)
// RegistrationInfo contains both machine key and verifier information for OIDC validation.
type RegistrationInfo struct {
RegistrationID types.RegistrationID
Verifier *string
}
type AuthProviderOIDC struct {
serverURL string
cfg *types.OIDCConfig
db *db.HSDatabase
registrationCache *zcache.Cache[string, key.MachinePublic]
registrationCache *zcache.Cache[string, RegistrationInfo]
notifier *notifier.Notifier
ipAlloc *db.IPAllocator
polMan policy.PolicyManager
@@ -87,7 +92,7 @@ func NewAuthProviderOIDC(
Scopes: cfg.Scope,
}
registrationCache := zcache.New[string, key.MachinePublic](
registrationCache := zcache.New[string, RegistrationInfo](
registerCacheExpiration,
registerCacheCleanup,
)
@@ -106,11 +111,11 @@ func NewAuthProviderOIDC(
}, nil
}
func (a *AuthProviderOIDC) AuthURL(mKey key.MachinePublic) string {
func (a *AuthProviderOIDC) AuthURL(registrationID types.RegistrationID) string {
return fmt.Sprintf(
"%s/register/%s",
strings.TrimSuffix(a.serverURL, "/"),
mKey.String())
registrationID.String())
}
func (a *AuthProviderOIDC) determineNodeExpiry(idTokenExpiration time.Time) time.Time {
@@ -123,54 +128,69 @@ func (a *AuthProviderOIDC) determineNodeExpiry(idTokenExpiration time.Time) time
// RegisterOIDC redirects to the OIDC provider for authentication
// Puts NodeKey in cache so the callback can retrieve it using the oidc state param
// Listens in /register/:mKey.
// Listens in /register/:registration_id.
func (a *AuthProviderOIDC) RegisterHandler(
writer http.ResponseWriter,
req *http.Request,
) {
vars := mux.Vars(req)
machineKeyStr, ok := vars["mkey"]
log.Debug().
Caller().
Str("machine_key", machineKeyStr).
Bool("ok", ok).
Msg("Received oidc register call")
registrationIdStr, _ := vars["registration_id"]
// We need to make sure we dont open for XSS style injections, if the parameter that
// is passed as a key is not parsable/validated as a NodePublic key, then fail to render
// the template and log an error.
var machineKey key.MachinePublic
err := machineKey.UnmarshalText(
[]byte(machineKeyStr),
)
registrationId, err := types.RegistrationIDFromString(registrationIdStr)
if err != nil {
http.Error(writer, err.Error(), http.StatusBadRequest)
httpError(writer, NewHTTPError(http.StatusBadRequest, "invalid registration id", err))
return
}
randomBlob := make([]byte, randomByteSize)
if _, err := rand.Read(randomBlob); err != nil {
http.Error(writer, "Internal server error", http.StatusInternalServerError)
// Set the state and nonce cookies to protect against CSRF attacks
state, err := setCSRFCookie(writer, req, "state")
if err != nil {
httpError(writer, err)
return
}
stateStr := hex.EncodeToString(randomBlob)[:32]
// Set the state and nonce cookies to protect against CSRF attacks
nonce, err := setCSRFCookie(writer, req, "nonce")
if err != nil {
httpError(writer, err)
return
}
// place the node key into the state cache, so it can be retrieved later
a.registrationCache.Set(
stateStr,
machineKey,
)
// Initialize registration info with machine key
registrationInfo := RegistrationInfo{
RegistrationID: registrationId,
}
// Add any extra parameter provided in the configuration to the Authorize Endpoint request
extras := make([]oauth2.AuthCodeOption, 0, len(a.cfg.ExtraParams))
extras := make([]oauth2.AuthCodeOption, 0, len(a.cfg.ExtraParams)+defaultOAuthOptionsCount)
// Add PKCE verification if enabled
if a.cfg.PKCE.Enabled {
verifier := oauth2.GenerateVerifier()
registrationInfo.Verifier = &verifier
extras = append(extras, oauth2.AccessTypeOffline)
switch a.cfg.PKCE.Method {
case types.PKCEMethodS256:
extras = append(extras, oauth2.S256ChallengeOption(verifier))
case types.PKCEMethodPlain:
// oauth2 does not have a plain challenge option, so we add it manually
extras = append(extras, oauth2.SetAuthURLParam("code_challenge_method", "plain"), oauth2.SetAuthURLParam("code_challenge", verifier))
}
}
// Add any extra parameters from configuration
for k, v := range a.cfg.ExtraParams {
extras = append(extras, oauth2.SetAuthURLParam(k, v))
}
extras = append(extras, oidc.Nonce(nonce))
authURL := a.oauth2Config.AuthCodeURL(stateStr, extras...)
// Cache the registration info
a.registrationCache.Set(state, registrationInfo)
authURL := a.oauth2Config.AuthCodeURL(state, extras...)
log.Debug().Msgf("Redirecting to %s for authentication", authURL)
http.Redirect(writer, req, authURL, http.StatusFound)
@@ -199,89 +219,89 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
) {
code, state, err := extractCodeAndStateParamFromRequest(req)
if err != nil {
http.Error(writer, err.Error(), http.StatusBadRequest)
httpError(writer, err)
return
}
idToken, err := a.extractIDToken(req.Context(), code)
cookieState, err := req.Cookie("state")
if err != nil {
http.Error(writer, err.Error(), http.StatusBadRequest)
httpError(writer, NewHTTPError(http.StatusBadRequest, "state not found", err))
return
}
if state != cookieState.Value {
httpError(writer, NewHTTPError(http.StatusForbidden, "state did not match", nil))
return
}
idToken, err := a.extractIDToken(req.Context(), code, state)
if err != nil {
httpError(writer, err)
return
}
nonce, err := req.Cookie("nonce")
if err != nil {
httpError(writer, NewHTTPError(http.StatusBadRequest, "nonce not found", err))
return
}
if idToken.Nonce != nonce.Value {
httpError(writer, NewHTTPError(http.StatusForbidden, "nonce did not match", nil))
return
}
nodeExpiry := a.determineNodeExpiry(idToken.Expiry)
var claims types.OIDCClaims
if err := idToken.Claims(&claims); err != nil {
http.Error(writer, fmt.Errorf("failed to decode ID token claims: %w", err).Error(), http.StatusInternalServerError)
httpError(writer, fmt.Errorf("decoding ID token claims: %w", err))
return
}
if err := validateOIDCAllowedDomains(a.cfg.AllowedDomains, &claims); err != nil {
http.Error(writer, err.Error(), http.StatusUnauthorized)
httpError(writer, err)
return
}
if err := validateOIDCAllowedGroups(a.cfg.AllowedGroups, &claims); err != nil {
http.Error(writer, err.Error(), http.StatusUnauthorized)
httpError(writer, err)
return
}
if err := validateOIDCAllowedUsers(a.cfg.AllowedUsers, &claims); err != nil {
http.Error(writer, err.Error(), http.StatusUnauthorized)
httpError(writer, err)
return
}
user, err := a.createOrUpdateUserFromClaim(&claims)
if err != nil {
http.Error(writer, err.Error(), http.StatusInternalServerError)
httpError(writer, err)
return
}
// Retrieve the node and the machine key from the state cache and
// database.
// TODO(kradalby): Is this comment right?
// If the node exists, then the node should be reauthenticated,
// if the node does not exist, and the machine key exists, then
// this is a new node that should be registered.
node, mKey := a.getMachineKeyFromState(state)
registrationId := a.getRegistrationIDFromState(state)
// Reauthenticate the node if it does exists.
if node != nil {
err := a.reauthenticateNode(node, nodeExpiry)
// Register the node if it does not exist.
if registrationId != nil {
verb := "Reauthenticated"
newNode, err := a.handleRegistration(user, *registrationId, nodeExpiry)
if err != nil {
http.Error(writer, err.Error(), http.StatusInternalServerError)
httpError(writer, err)
return
}
if newNode {
verb = "Authenticated"
}
// TODO(kradalby): replace with go-elem
var content bytes.Buffer
if err := oidcCallbackTemplate.Execute(&content, oidcCallbackTemplateConfig{
User: user.DisplayNameOrUsername(),
Verb: "Reauthenticated",
}); err != nil {
http.Error(writer, fmt.Errorf("rendering OIDC callback template: %w", err).Error(), http.StatusInternalServerError)
return
}
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
writer.WriteHeader(http.StatusOK)
_, err = writer.Write(content.Bytes())
content, err := renderOIDCCallbackTemplate(user, verb)
if err != nil {
util.LogErr(err, "Failed to write response")
}
return
}
// Register the node if it does not exist.
if mKey != nil {
if err := a.registerNode(user, mKey, nodeExpiry); err != nil {
http.Error(writer, err.Error(), http.StatusInternalServerError)
return
}
content, err := renderOIDCCallbackTemplate(user)
if err != nil {
http.Error(writer, err.Error(), http.StatusInternalServerError)
httpError(writer, err)
return
}
@@ -296,7 +316,7 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
// Neither node nor machine key was found in the state cache meaning
// that we could not reauth nor register the node.
http.Error(writer, err.Error(), http.StatusInternalServerError)
httpError(writer, NewHTTPError(http.StatusGone, "login session expired, try again", nil))
return
}
@@ -307,7 +327,7 @@ func extractCodeAndStateParamFromRequest(
state := req.URL.Query().Get("state")
if code == "" || state == "" {
return "", "", errEmptyOIDCCallbackParams
return "", "", NewHTTPError(http.StatusBadRequest, "missing code or state parameter", errEmptyOIDCCallbackParams)
}
return code, state, nil
@@ -318,21 +338,34 @@ func extractCodeAndStateParamFromRequest(
func (a *AuthProviderOIDC) extractIDToken(
ctx context.Context,
code string,
state string,
) (*oidc.IDToken, error) {
oauth2Token, err := a.oauth2Config.Exchange(ctx, code)
var exchangeOpts []oauth2.AuthCodeOption
if a.cfg.PKCE.Enabled {
regInfo, ok := a.registrationCache.Get(state)
if !ok {
return nil, NewHTTPError(http.StatusNotFound, "registration not found", errNoOIDCRegistrationInfo)
}
if regInfo.Verifier != nil {
exchangeOpts = []oauth2.AuthCodeOption{oauth2.VerifierOption(*regInfo.Verifier)}
}
}
oauth2Token, err := a.oauth2Config.Exchange(ctx, code, exchangeOpts...)
if err != nil {
return nil, fmt.Errorf("could not exchange code for token: %w", err)
return nil, NewHTTPError(http.StatusForbidden, "invalid code", fmt.Errorf("could not exchange code for token: %w", err))
}
rawIDToken, ok := oauth2Token.Extra("id_token").(string)
if !ok {
return nil, errNoOIDCIDToken
return nil, NewHTTPError(http.StatusBadRequest, "no id_token", errNoOIDCIDToken)
}
verifier := a.oidcProvider.Verifier(&oidc.Config{ClientID: a.cfg.ClientID})
idToken, err := verifier.Verify(ctx, rawIDToken)
if err != nil {
return nil, fmt.Errorf("failed to verify ID token: %w", err)
return nil, NewHTTPError(http.StatusForbidden, "failed to verify id_token", fmt.Errorf("failed to verify ID token: %w", err))
}
return idToken, nil
@@ -347,7 +380,7 @@ func validateOIDCAllowedDomains(
if len(allowedDomains) > 0 {
if at := strings.LastIndex(claims.Email, "@"); at < 0 ||
!slices.Contains(allowedDomains, claims.Email[at+1:]) {
return errOIDCAllowedDomains
return NewHTTPError(http.StatusUnauthorized, "unauthorised domain", errOIDCAllowedDomains)
}
}
@@ -369,7 +402,7 @@ func validateOIDCAllowedGroups(
}
}
return errOIDCAllowedGroups
return NewHTTPError(http.StatusUnauthorized, "unauthorised group", errOIDCAllowedGroups)
}
return nil
@@ -383,56 +416,20 @@ func validateOIDCAllowedUsers(
) error {
if len(allowedUsers) > 0 &&
!slices.Contains(allowedUsers, claims.Email) {
log.Trace().Msg("authenticated principal does not match any allowed user")
return errOIDCAllowedUsers
return NewHTTPError(http.StatusUnauthorized, "unauthorised user", errOIDCAllowedUsers)
}
return nil
}
// getMachineKeyFromState retrieves the machine key from the state
// cache. If the machine key is found, it will try retrieve the
// node information from the database.
func (a *AuthProviderOIDC) getMachineKeyFromState(state string) (*types.Node, *key.MachinePublic) {
machineKey, ok := a.registrationCache.Get(state)
// getRegistrationIDFromState retrieves the registration ID from the state.
func (a *AuthProviderOIDC) getRegistrationIDFromState(state string) *types.RegistrationID {
regInfo, ok := a.registrationCache.Get(state)
if !ok {
return nil, nil
return nil
}
// retrieve node information if it exist
// The error is not important, because if it does not
// exist, then this is a new node and we will move
// on to registration.
node, _ := a.db.GetNodeByMachineKey(machineKey)
return node, &machineKey
}
// reauthenticateNode updates the node expiry in the database
// and notifies the node and its peers about the change.
func (a *AuthProviderOIDC) reauthenticateNode(
node *types.Node,
expiry time.Time,
) error {
err := a.db.NodeSetExpiry(node.ID, expiry)
if err != nil {
return err
}
ctx := types.NotifyCtx(context.Background(), "oidc-expiry-self", node.Hostname)
a.notifier.NotifyByNodeID(
ctx,
types.StateUpdate{
Type: types.StateSelfUpdate,
ChangeNodes: []types.NodeID{node.ID},
},
node.ID,
)
ctx = types.NotifyCtx(context.Background(), "oidc-expiry-peers", node.Hostname)
a.notifier.NotifyWithIgnore(ctx, types.StateUpdateExpire(node.ID, expiry), node.ID)
return nil
return &regInfo.RegistrationID
}
func (a *AuthProviderOIDC) createOrUpdateUserFromClaim(
@@ -490,43 +487,60 @@ func (a *AuthProviderOIDC) createOrUpdateUserFromClaim(
return user, nil
}
func (a *AuthProviderOIDC) registerNode(
func (a *AuthProviderOIDC) handleRegistration(
user *types.User,
machineKey *key.MachinePublic,
registrationID types.RegistrationID,
expiry time.Time,
) error {
) (bool, error) {
ipv4, ipv6, err := a.ipAlloc.Next()
if err != nil {
return err
return false, err
}
if _, err := a.db.RegisterNodeFromAuthCallback(
*machineKey,
node, newNode, err := a.db.HandleNodeFromAuthPath(
registrationID,
types.UserID(user.ID),
&expiry,
util.RegisterMethodOIDC,
ipv4, ipv6,
); err != nil {
return fmt.Errorf("could not register node: %w", err)
}
err = nodesChangedHook(a.db, a.polMan, a.notifier)
)
if err != nil {
return fmt.Errorf("updating resources using node: %w", err)
return false, fmt.Errorf("could not register node: %w", err)
}
return nil
// Send an update to all nodes if this is a new node that they need to know
// about.
// If this is a refresh, just send new expiry updates.
updateSent, err := nodesChangedHook(a.db, a.polMan, a.notifier)
if err != nil {
return false, fmt.Errorf("updating resources using node: %w", err)
}
if !updateSent {
ctx := types.NotifyCtx(context.Background(), "oidc-expiry-self", node.Hostname)
a.notifier.NotifyByNodeID(
ctx,
types.StateSelf(node.ID),
node.ID,
)
ctx = types.NotifyCtx(context.Background(), "oidc-expiry-peers", node.Hostname)
a.notifier.NotifyWithIgnore(ctx, types.StateUpdatePeerAdded(node.ID), node.ID)
}
return newNode, nil
}
// TODO(kradalby):
// Rewrite in elem-go.
func renderOIDCCallbackTemplate(
user *types.User,
verb string,
) (*bytes.Buffer, error) {
var content bytes.Buffer
if err := oidcCallbackTemplate.Execute(&content, oidcCallbackTemplateConfig{
User: user.DisplayNameOrUsername(),
Verb: "Authenticated",
Verb: verb,
}); err != nil {
return nil, fmt.Errorf("rendering OIDC callback template: %w", err)
}
@@ -554,3 +568,22 @@ func getUserName(
return userName, nil
}
func setCSRFCookie(w http.ResponseWriter, r *http.Request, name string) (string, error) {
val, err := util.GenerateRandomStringURLSafe(64)
if err != nil {
return val, err
}
c := &http.Cookie{
Path: "/oidc/callback",
Name: name,
Value: val,
MaxAge: int(time.Hour.Seconds()),
Secure: r.TLS != nil,
HttpOnly: true,
}
http.SetCookie(w, c)
return val, nil
}

View File

@@ -10,7 +10,6 @@ import (
"github.com/gofrs/uuid/v5"
"github.com/gorilla/mux"
"github.com/juanfont/headscale/hscontrol/templates"
"github.com/rs/zerolog/log"
)
// WindowsConfigMessage shows a simple message in the browser for how to configure the Windows Tailscale client.
@@ -20,13 +19,7 @@ func (h *Headscale) WindowsConfigMessage(
) {
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
writer.WriteHeader(http.StatusOK)
if _, err := writer.Write([]byte(templates.Windows(h.cfg.ServerURL).Render())); err != nil {
log.Error().
Caller().
Err(err).
Msg("Failed to write response")
}
writer.Write([]byte(templates.Windows(h.cfg.ServerURL).Render()))
}
// AppleConfigMessage shows a simple message in the browser to point the user to the iOS/MacOS profile and instructions for how to install it.
@@ -36,13 +29,7 @@ func (h *Headscale) AppleConfigMessage(
) {
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
writer.WriteHeader(http.StatusOK)
if _, err := writer.Write([]byte(templates.Apple(h.cfg.ServerURL).Render())); err != nil {
log.Error().
Caller().
Err(err).
Msg("Failed to write response")
}
writer.Write([]byte(templates.Apple(h.cfg.ServerURL).Render()))
}
func (h *Headscale) ApplePlatformConfig(
@@ -52,51 +39,19 @@ func (h *Headscale) ApplePlatformConfig(
vars := mux.Vars(req)
platform, ok := vars["platform"]
if !ok {
log.Error().
Str("handler", "ApplePlatformConfig").
Msg("No platform specified")
http.Error(writer, "No platform specified", http.StatusBadRequest)
httpError(writer, NewHTTPError(http.StatusBadRequest, "no platform specified", nil))
return
}
id, err := uuid.NewV4()
if err != nil {
log.Error().
Str("handler", "ApplePlatformConfig").
Err(err).
Msg("Failed not create UUID")
writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
writer.WriteHeader(http.StatusInternalServerError)
_, err := writer.Write([]byte("Failed to create UUID"))
if err != nil {
log.Error().
Caller().
Err(err).
Msg("Failed to write response")
}
httpError(writer, err)
return
}
contentID, err := uuid.NewV4()
if err != nil {
log.Error().
Str("handler", "ApplePlatformConfig").
Err(err).
Msg("Failed not create UUID")
writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
writer.WriteHeader(http.StatusInternalServerError)
_, err := writer.Write([]byte("Failed to create content UUID"))
if err != nil {
log.Error().
Caller().
Err(err).
Msg("Failed to write response")
}
httpError(writer, err)
return
}
@@ -106,68 +61,25 @@ func (h *Headscale) ApplePlatformConfig(
}
var payload bytes.Buffer
handleMacError := func(ierr error) {
log.Error().
Str("handler", "ApplePlatformConfig").
Err(ierr).
Msg("Could not render Apple macOS template")
writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
writer.WriteHeader(http.StatusInternalServerError)
_, err := writer.Write([]byte("Could not render Apple macOS template"))
if err != nil {
log.Error().
Caller().
Err(err).
Msg("Failed to write response")
}
}
switch platform {
case "macos-standalone":
if err := macosStandaloneTemplate.Execute(&payload, platformConfig); err != nil {
handleMacError(err)
httpError(writer, err)
return
}
case "macos-app-store":
if err := macosAppStoreTemplate.Execute(&payload, platformConfig); err != nil {
handleMacError(err)
httpError(writer, err)
return
}
case "ios":
if err := iosTemplate.Execute(&payload, platformConfig); err != nil {
log.Error().
Str("handler", "ApplePlatformConfig").
Err(err).
Msg("Could not render Apple iOS template")
writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
writer.WriteHeader(http.StatusInternalServerError)
_, err := writer.Write([]byte("Could not render Apple iOS template"))
if err != nil {
log.Error().
Caller().
Err(err).
Msg("Failed to write response")
}
httpError(writer, err)
return
}
default:
writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
writer.WriteHeader(http.StatusBadRequest)
_, err := writer.Write(
[]byte("Invalid platform. Only ios, macos-app-store and macos-standalone are supported"),
)
if err != nil {
log.Error().
Caller().
Err(err).
Msg("Failed to write response")
}
httpError(writer, NewHTTPError(http.StatusBadRequest, "platform must be ios, macos-app-store or macos-standalone", nil))
return
}
@@ -179,34 +91,14 @@ func (h *Headscale) ApplePlatformConfig(
var content bytes.Buffer
if err := commonTemplate.Execute(&content, config); err != nil {
log.Error().
Str("handler", "ApplePlatformConfig").
Err(err).
Msg("Could not render Apple platform template")
writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
writer.WriteHeader(http.StatusInternalServerError)
_, err := writer.Write([]byte("Could not render Apple platform template"))
if err != nil {
log.Error().
Caller().
Err(err).
Msg("Failed to write response")
}
httpError(writer, err)
return
}
writer.Header().
Set("Content-Type", "application/x-apple-aspen-config; charset=utf-8")
writer.WriteHeader(http.StatusOK)
_, err = writer.Write(content.Bytes())
if err != nil {
log.Error().
Caller().
Err(err).
Msg("Failed to write response")
}
writer.Write(content.Bytes())
}
type AppleMobileConfig struct {

View File

@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"io"
"iter"
"net/netip"
"os"
"slices"
@@ -60,8 +61,8 @@ func theInternet() *netipx.IPSet {
internetBuilder.RemovePrefix(tsaddr.TailscaleULARange())
internetBuilder.RemovePrefix(tsaddr.CGNATRange())
// Delete "cant find DHCP networks"
internetBuilder.RemovePrefix(netip.MustParsePrefix("fe80::/10")) // link-loca
// Delete "can't find DHCP networks"
internetBuilder.RemovePrefix(netip.MustParsePrefix("fe80::/10")) // link-local
internetBuilder.RemovePrefix(netip.MustParsePrefix("169.254.0.0/16"))
theInternetSet, _ := internetBuilder.IPSet()
@@ -250,7 +251,7 @@ func ReduceFilterRules(node *types.Node, rules []tailcfg.FilterRule) []tailcfg.F
DEST_LOOP:
for _, dest := range rule.DstPorts {
expanded, err := util.ParseIPSet(dest.IP, nil)
// Fail closed, if we cant parse it, then we should not allow
// Fail closed, if we can't parse it, then we should not allow
// access.
if err != nil {
continue DEST_LOOP
@@ -361,37 +362,67 @@ func (pol *ACLPolicy) CompileSSHPolicy(
)
}
principals := make([]*tailcfg.SSHPrincipal, 0, len(sshACL.Sources))
for innerIndex, rawSrc := range sshACL.Sources {
if isWildcard(rawSrc) {
principals = append(principals, &tailcfg.SSHPrincipal{
var principals []*tailcfg.SSHPrincipal
for innerIndex, srcToken := range sshACL.Sources {
if isWildcard(srcToken) {
principals = []*tailcfg.SSHPrincipal{{
Any: true,
})
} else if isGroup(rawSrc) {
users, err := pol.expandUsersFromGroup(rawSrc)
}}
break
}
// If the token is a group, expand the users and validate
// them. Then use the .Username() to get the login name
// that corresponds with the User info in the netmap.
if isGroup(srcToken) {
usersFromGroup, err := pol.expandUsersFromGroup(srcToken)
if err != nil {
return nil, fmt.Errorf("parsing SSH policy, expanding user from group, index: %d->%d: %w", index, innerIndex, err)
}
for _, user := range users {
for _, userStr := range usersFromGroup {
user, err := findUserFromToken(users, userStr)
if err != nil {
log.Trace().Err(err).Msg("user not found")
continue
}
principals = append(principals, &tailcfg.SSHPrincipal{
UserLogin: user,
})
}
} else {
expandedSrcs, err := pol.ExpandAlias(
peers,
users,
rawSrc,
)
if err != nil {
return nil, fmt.Errorf("parsing SSH policy, expanding alias, index: %d->%d: %w", index, innerIndex, err)
}
for _, expandedSrc := range expandedSrcs.Prefixes() {
principals = append(principals, &tailcfg.SSHPrincipal{
NodeIP: expandedSrc.Addr().String(),
UserLogin: user.Username(),
})
}
continue
}
// Try to check if the token is a user, if it is, then we
// can use the .Username() to get the login name that
// corresponds with the User info in the netmap.
// TODO(kradalby): This is a bit of a hack, and it should go
// away with the new policy where users can be reliably determined.
if user, err := findUserFromToken(users, srcToken); err == nil {
principals = append(principals, &tailcfg.SSHPrincipal{
UserLogin: user.Username(),
})
continue
}
// This is kind of then non-ideal scenario where we dont really know
// what to do with the token, so we expand it to IP addresses of nodes.
// The pro here is that we have a pretty good lockdown on the mapping
// between users and node, but it can explode if a user owns many nodes.
ips, err := pol.ExpandAlias(
peers,
users,
srcToken,
)
if err != nil {
return nil, fmt.Errorf("parsing SSH policy, expanding alias, index: %d->%d: %w", index, innerIndex, err)
}
for addr := range ipSetAll(ips) {
principals = append(principals, &tailcfg.SSHPrincipal{
NodeIP: addr.String(),
})
}
}
@@ -411,6 +442,19 @@ func (pol *ACLPolicy) CompileSSHPolicy(
}, nil
}
// ipSetAll returns a function that iterates over all the IPs in the IPSet.
func ipSetAll(ipSet *netipx.IPSet) iter.Seq[netip.Addr] {
return func(yield func(netip.Addr) bool) {
for _, rng := range ipSet.Ranges() {
for ip := rng.From(); ip.Compare(rng.To()) <= 0; ip = ip.Next() {
if !yield(ip) {
return
}
}
}
}
}
func sshCheckAction(duration string) (*tailcfg.SSHAction, error) {
sessionLength, err := time.ParseDuration(duration)
if err != nil {
@@ -890,7 +934,7 @@ func (pol *ACLPolicy) expandIPsFromIPPrefix(
build.AddPrefix(prefix)
// This is suboptimal and quite expensive, but if we only add the prefix, we will miss all the relevant IPv6
// addresses for the hosts that belong to tailscale. This doesnt really affect stuff like subnet routers.
// addresses for the hosts that belong to tailscale. This doesn't really affect stuff like subnet routers.
for _, node := range nodes {
for _, ip := range node.IPs() {
// log.Trace().
@@ -934,6 +978,7 @@ func isAutoGroup(str string) bool {
// Invalid tags are tags added by a user on a node, and that user doesn't have authority to add this tag.
// Valid tags are tags added by a user that is allowed in the ACL policy to add this tag.
func (pol *ACLPolicy) TagsOfNode(
users []types.User,
node *types.Node,
) ([]string, []string) {
var validTags []string
@@ -956,7 +1001,12 @@ func (pol *ACLPolicy) TagsOfNode(
}
var found bool
for _, owner := range owners {
if node.User.Username() == owner {
user, err := findUserFromToken(users, owner)
if err != nil {
log.Trace().Caller().Err(err).Msg("could not determine user to filter tags by")
}
if node.User.ID == user.ID {
found = true
}
}
@@ -988,30 +1038,12 @@ func (pol *ACLPolicy) TagsOfNode(
func filterNodesByUser(nodes types.Nodes, users []types.User, userToken string) types.Nodes {
var out types.Nodes
var potentialUsers []types.User
for _, user := range users {
if user.ProviderIdentifier.Valid && user.ProviderIdentifier.String == userToken {
// If a user is matching with a known unique field,
// disgard all other users and only keep the current
// user.
potentialUsers = []types.User{user}
break
}
if user.Email == userToken {
potentialUsers = append(potentialUsers, user)
}
if user.Name == userToken {
potentialUsers = append(potentialUsers, user)
}
user, err := findUserFromToken(users, userToken)
if err != nil {
log.Trace().Caller().Err(err).Msg("could not determine user to filter nodes by")
return out
}
if len(potentialUsers) != 1 {
return nil
}
user := potentialUsers[0]
for _, node := range nodes {
if node.User.ID == user.ID {
out = append(out, node)
@@ -1021,6 +1053,39 @@ func filterNodesByUser(nodes types.Nodes, users []types.User, userToken string)
return out
}
var (
ErrorNoUserMatching = errors.New("no user matching")
ErrorMultipleUserMatching = errors.New("multiple users matching")
)
// findUserFromToken finds and returns a user based on the given token, prioritizing matches by ProviderIdentifier, followed by email or name.
// If no matching user is found, it returns an error of type ErrorNoUserMatching.
// If multiple users match the token, it returns an error indicating multiple matches.
func findUserFromToken(users []types.User, token string) (types.User, error) {
var potentialUsers []types.User
for _, user := range users {
if user.ProviderIdentifier.Valid && user.ProviderIdentifier.String == token {
// Prioritize ProviderIdentifier match and exit early
return user, nil
}
if user.Email == token || user.Name == token {
potentialUsers = append(potentialUsers, user)
}
}
if len(potentialUsers) == 0 {
return types.User{}, fmt.Errorf("user with token %q not found: %w", token, ErrorNoUserMatching)
}
if len(potentialUsers) > 1 {
return types.User{}, fmt.Errorf("multiple users with token %q found: %w", token, ErrorNoUserMatching)
}
return potentialUsers[0], nil
}
// FilterNodesByACL returns the list of peers authorized to be accessed from a given node.
func FilterNodesByACL(
node *types.Node,

View File

@@ -2735,6 +2735,12 @@ func TestReduceFilterRules(t *testing.T) {
}
func Test_getTags(t *testing.T) {
users := []types.User{
{
Model: gorm.Model{ID: 1},
Name: "joe",
},
}
type args struct {
aclPolicy *ACLPolicy
node *types.Node
@@ -2754,9 +2760,7 @@ func Test_getTags(t *testing.T) {
},
},
node: &types.Node{
User: types.User{
Name: "joe",
},
User: users[0],
Hostinfo: &tailcfg.Hostinfo{
RequestTags: []string{"tag:valid"},
},
@@ -2774,9 +2778,7 @@ func Test_getTags(t *testing.T) {
},
},
node: &types.Node{
User: types.User{
Name: "joe",
},
User: users[0],
Hostinfo: &tailcfg.Hostinfo{
RequestTags: []string{"tag:valid", "tag:invalid"},
},
@@ -2794,9 +2796,7 @@ func Test_getTags(t *testing.T) {
},
},
node: &types.Node{
User: types.User{
Name: "joe",
},
User: users[0],
Hostinfo: &tailcfg.Hostinfo{
RequestTags: []string{
"tag:invalid",
@@ -2818,9 +2818,7 @@ func Test_getTags(t *testing.T) {
},
},
node: &types.Node{
User: types.User{
Name: "joe",
},
User: users[0],
Hostinfo: &tailcfg.Hostinfo{
RequestTags: []string{"tag:invalid", "very-invalid"},
},
@@ -2834,9 +2832,7 @@ func Test_getTags(t *testing.T) {
args: args{
aclPolicy: &ACLPolicy{},
node: &types.Node{
User: types.User{
Name: "joe",
},
User: users[0],
Hostinfo: &tailcfg.Hostinfo{
RequestTags: []string{"tag:invalid", "very-invalid"},
},
@@ -2849,6 +2845,7 @@ func Test_getTags(t *testing.T) {
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
gotValid, gotInvalid := test.args.aclPolicy.TagsOfNode(
users,
test.args.node,
)
for _, valid := range gotValid {
@@ -3542,6 +3539,11 @@ func Test_getFilteredByACLPeers(t *testing.T) {
}
func TestSSHRules(t *testing.T) {
users := []types.User{
{
Name: "user1",
},
}
tests := []struct {
name string
node types.Node
@@ -3555,18 +3557,14 @@ func TestSSHRules(t *testing.T) {
Hostname: "testnodes",
IPv4: iap("100.64.99.42"),
UserID: 0,
User: types.User{
Name: "user1",
},
User: users[0],
},
peers: types.Nodes{
&types.Node{
Hostname: "testnodes2",
IPv4: iap("100.64.0.1"),
UserID: 0,
User: types.User{
Name: "user1",
},
User: users[0],
},
},
pol: ACLPolicy{
@@ -3679,18 +3677,14 @@ func TestSSHRules(t *testing.T) {
Hostname: "testnodes",
IPv4: iap("100.64.0.1"),
UserID: 0,
User: types.User{
Name: "user1",
},
User: users[0],
},
peers: types.Nodes{
&types.Node{
Hostname: "testnodes2",
IPv4: iap("100.64.99.42"),
UserID: 0,
User: types.User{
Name: "user1",
},
User: users[0],
},
},
pol: ACLPolicy{
@@ -3728,7 +3722,7 @@ func TestSSHRules(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := tt.pol.CompileSSHPolicy(&tt.node, []types.User{}, tt.peers)
got, err := tt.pol.CompileSSHPolicy(&tt.node, users, tt.peers)
require.NoError(t, err)
if diff := cmp.Diff(tt.want, got); diff != "" {
@@ -4052,3 +4046,315 @@ func TestValidTagInvalidUser(t *testing.T) {
t.Errorf("TestValidTagInvalidUser() unexpected result (-want +got):\n%s", diff)
}
}
func TestFindUserByToken(t *testing.T) {
tests := []struct {
name string
users []types.User
token string
want types.User
wantErr bool
}{
{
name: "exact match by ProviderIdentifier",
users: []types.User{
{ProviderIdentifier: sql.NullString{Valid: true, String: "token1"}},
{Email: "user2@example.com"},
},
token: "token1",
want: types.User{ProviderIdentifier: sql.NullString{Valid: true, String: "token1"}},
wantErr: false,
},
{
name: "no matches found",
users: []types.User{
{Email: "user1@example.com"},
{Name: "username"},
},
token: "nonexistent-token",
want: types.User{},
wantErr: true,
},
{
name: "multiple matches by email and name",
users: []types.User{
{Email: "token2", Name: "notoken"},
{Name: "token2", Email: "notoken@example.com"},
},
token: "token2",
want: types.User{},
wantErr: true,
},
{
name: "match by email",
users: []types.User{
{Email: "token3@example.com"},
{ProviderIdentifier: sql.NullString{Valid: true, String: "othertoken"}},
},
token: "token3@example.com",
want: types.User{Email: "token3@example.com"},
wantErr: false,
},
{
name: "match by name",
users: []types.User{
{Name: "token4"},
{Email: "user5@example.com"},
},
token: "token4",
want: types.User{Name: "token4"},
wantErr: false,
},
{
name: "provider identifier takes precedence over email and name matches",
users: []types.User{
{ProviderIdentifier: sql.NullString{Valid: true, String: "token5"}},
{Email: "token5@example.com", Name: "token5"},
},
token: "token5",
want: types.User{ProviderIdentifier: sql.NullString{Valid: true, String: "token5"}},
wantErr: false,
},
{
name: "empty token finds no users",
users: []types.User{
{Email: "user6@example.com"},
{Name: "username6"},
},
token: "",
want: types.User{},
wantErr: true,
},
// Test case 1: Duplicate Emails with Unique ProviderIdentifiers
{
name: "duplicate emails with unique provider identifiers",
users: []types.User{
{ProviderIdentifier: sql.NullString{Valid: true, String: "pid1"}, Email: "user@example.com"},
{ProviderIdentifier: sql.NullString{Valid: true, String: "pid2"}, Email: "user@example.com"},
},
token: "user@example.com",
want: types.User{},
wantErr: true,
},
// Test case 2: Duplicate Names with Unique ProviderIdentifiers
{
name: "duplicate names with unique provider identifiers",
users: []types.User{
{ProviderIdentifier: sql.NullString{Valid: true, String: "pid3"}, Name: "John Doe"},
{ProviderIdentifier: sql.NullString{Valid: true, String: "pid4"}, Name: "John Doe"},
},
token: "John Doe",
want: types.User{},
wantErr: true,
},
// Test case 3: Duplicate Emails and Names with Unique ProviderIdentifiers
{
name: "duplicate emails and names with unique provider identifiers",
users: []types.User{
{ProviderIdentifier: sql.NullString{Valid: true, String: "pid5"}, Email: "user@example.com", Name: "John Doe"},
{ProviderIdentifier: sql.NullString{Valid: true, String: "pid6"}, Email: "user@example.com", Name: "John Doe"},
},
token: "user@example.com",
want: types.User{},
wantErr: true,
},
// Test case 4: Unique Names without ProviderIdentifiers
{
name: "unique names without provider identifiers",
users: []types.User{
{ProviderIdentifier: sql.NullString{Valid: false, String: ""}, Name: "John Doe", Email: "johndoe@example.com"},
{ProviderIdentifier: sql.NullString{Valid: false, String: ""}, Name: "Jane Smith", Email: "janesmith@example.com"},
},
token: "John Doe",
want: types.User{ProviderIdentifier: sql.NullString{Valid: false, String: ""}, Name: "John Doe", Email: "johndoe@example.com"},
wantErr: false,
},
// Test case 5: Duplicate Emails without ProviderIdentifiers but Unique Names
{
name: "duplicate emails without provider identifiers but unique names",
users: []types.User{
{ProviderIdentifier: sql.NullString{Valid: false, String: ""}, Name: "John Doe", Email: "user@example.com"},
{ProviderIdentifier: sql.NullString{Valid: false, String: ""}, Name: "Jane Smith", Email: "user@example.com"},
},
token: "John Doe",
want: types.User{ProviderIdentifier: sql.NullString{Valid: false, String: ""}, Name: "John Doe", Email: "user@example.com"},
wantErr: false,
},
// Test case 6: Duplicate Names and Emails without ProviderIdentifiers
{
name: "duplicate names and emails without provider identifiers",
users: []types.User{
{ProviderIdentifier: sql.NullString{Valid: false, String: ""}, Name: "John Doe", Email: "user@example.com"},
{ProviderIdentifier: sql.NullString{Valid: false, String: ""}, Name: "John Doe", Email: "user@example.com"},
},
token: "John Doe",
want: types.User{},
wantErr: true,
},
// Test case 7: Multiple Users with the Same Email but Different Names and Unique ProviderIdentifiers
{
name: "multiple users with same email, different names, unique provider identifiers",
users: []types.User{
{ProviderIdentifier: sql.NullString{Valid: true, String: "pid7"}, Email: "user@example.com", Name: "John Doe"},
{ProviderIdentifier: sql.NullString{Valid: true, String: "pid8"}, Email: "user@example.com", Name: "Jane Smith"},
},
token: "user@example.com",
want: types.User{},
wantErr: true,
},
// Test case 8: Multiple Users with the Same Name but Different Emails and Unique ProviderIdentifiers
{
name: "multiple users with same name, different emails, unique provider identifiers",
users: []types.User{
{ProviderIdentifier: sql.NullString{Valid: true, String: "pid9"}, Email: "johndoe@example.com", Name: "John Doe"},
{ProviderIdentifier: sql.NullString{Valid: true, String: "pid10"}, Email: "janedoe@example.com", Name: "John Doe"},
},
token: "John Doe",
want: types.User{},
wantErr: true,
},
// Test case 9: Multiple Users with Same Email and Name but Unique ProviderIdentifiers
{
name: "multiple users with same email and name, unique provider identifiers",
users: []types.User{
{ProviderIdentifier: sql.NullString{Valid: true, String: "pid11"}, Email: "user@example.com", Name: "John Doe"},
{ProviderIdentifier: sql.NullString{Valid: true, String: "pid12"}, Email: "user@example.com", Name: "John Doe"},
},
token: "user@example.com",
want: types.User{},
wantErr: true,
},
// Test case 10: Multiple Users without ProviderIdentifiers but with Unique Names and Emails
{
name: "multiple users without provider identifiers, unique names and emails",
users: []types.User{
{ProviderIdentifier: sql.NullString{Valid: false, String: ""}, Name: "John Doe", Email: "johndoe@example.com"},
{ProviderIdentifier: sql.NullString{Valid: false, String: ""}, Name: "Jane Smith", Email: "janesmith@example.com"},
},
token: "John Doe",
want: types.User{ProviderIdentifier: sql.NullString{Valid: false, String: ""}, Name: "John Doe", Email: "johndoe@example.com"},
wantErr: false,
},
// Test case 11: Multiple Users without ProviderIdentifiers and Duplicate Emails but Unique Names
{
name: "multiple users without provider identifiers, duplicate emails but unique names",
users: []types.User{
{ProviderIdentifier: sql.NullString{Valid: false, String: ""}, Name: "John Doe", Email: "user@example.com"},
{ProviderIdentifier: sql.NullString{Valid: false, String: ""}, Name: "Jane Smith", Email: "user@example.com"},
},
token: "John Doe",
want: types.User{ProviderIdentifier: sql.NullString{Valid: false, String: ""}, Name: "John Doe", Email: "user@example.com"},
wantErr: false,
},
// Test case 12: Multiple Users without ProviderIdentifiers and Duplicate Names but Unique Emails
{
name: "multiple users without provider identifiers, duplicate names but unique emails",
users: []types.User{
{ProviderIdentifier: sql.NullString{Valid: false, String: ""}, Name: "John Doe", Email: "johndoe@example.com"},
{ProviderIdentifier: sql.NullString{Valid: false, String: ""}, Name: "John Doe", Email: "janedoe@example.com"},
},
token: "John Doe",
want: types.User{},
wantErr: true,
},
// Test case 13: Multiple Users without ProviderIdentifiers and Duplicate Both Names and Emails
{
name: "multiple users without provider identifiers, duplicate names and emails",
users: []types.User{
{ProviderIdentifier: sql.NullString{Valid: false, String: ""}, Name: "John Doe", Email: "user@example.com"},
{ProviderIdentifier: sql.NullString{Valid: false, String: ""}, Name: "John Doe", Email: "user@example.com"},
},
token: "John Doe",
want: types.User{},
wantErr: true,
},
// Test case 14: Multiple Users with Same Email Without ProviderIdentifiers
{
name: "multiple users with same email without provider identifiers",
users: []types.User{
{ProviderIdentifier: sql.NullString{Valid: false, String: ""}, Name: "John Doe", Email: "user@example.com"},
{ProviderIdentifier: sql.NullString{Valid: false, String: ""}, Name: "Jane Smith", Email: "user@example.com"},
},
token: "user@example.com",
want: types.User{},
wantErr: true,
},
// Test case 15: Multiple Users with Same Name Without ProviderIdentifiers
{
name: "multiple users with same name without provider identifiers",
users: []types.User{
{ProviderIdentifier: sql.NullString{Valid: false, String: ""}, Name: "John Doe", Email: "johndoe@example.com"},
{ProviderIdentifier: sql.NullString{Valid: false, String: ""}, Name: "John Doe", Email: "janedoe@example.com"},
},
token: "John Doe",
want: types.User{},
wantErr: true,
},
{
name: "Name field used as email address match",
users: []types.User{
{ProviderIdentifier: sql.NullString{Valid: true, String: "pid3"}, Name: "user@example.com", Email: "another@example.com"},
},
token: "user@example.com",
want: types.User{ProviderIdentifier: sql.NullString{Valid: true, String: "pid3"}, Name: "user@example.com", Email: "another@example.com"},
wantErr: false,
},
{
name: "multiple users with same name as email and unique provider identifiers",
users: []types.User{
{ProviderIdentifier: sql.NullString{Valid: true, String: "pid4"}, Name: "user@example.com", Email: "user1@example.com"},
{ProviderIdentifier: sql.NullString{Valid: true, String: "pid5"}, Name: "user@example.com", Email: "user2@example.com"},
},
token: "user@example.com",
want: types.User{},
wantErr: true,
},
{
name: "no provider identifier and duplicate names as emails",
users: []types.User{
{ProviderIdentifier: sql.NullString{Valid: false, String: ""}, Name: "user@example.com", Email: "another1@example.com"},
{ProviderIdentifier: sql.NullString{Valid: false, String: ""}, Name: "user@example.com", Email: "another2@example.com"},
},
token: "user@example.com",
want: types.User{},
wantErr: true,
},
{
name: "name as email with multiple matches when provider identifier is not set",
users: []types.User{
{ProviderIdentifier: sql.NullString{Valid: false, String: ""}, Name: "user@example.com", Email: "another1@example.com"},
{ProviderIdentifier: sql.NullString{Valid: false, String: ""}, Name: "user@example.com", Email: "another2@example.com"},
},
token: "user@example.com",
want: types.User{},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotUser, err := findUserFromToken(tt.users, tt.token)
if (err != nil) != tt.wantErr {
t.Errorf("findUserFromToken() error = %v, wantErr %v", err, tt.wantErr)
return
}
if diff := cmp.Diff(tt.want, gotUser, util.Comparers...); diff != "" {
t.Errorf("findUserFromToken() unexpected result (-want +got):\n%s", diff)
}
})
}
}

View File

@@ -8,6 +8,7 @@ import (
"sync"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/rs/zerolog/log"
"go4.org/netipx"
"tailscale.com/tailcfg"
"tailscale.com/util/deephash"
@@ -122,6 +123,10 @@ func (pm *PolicyManagerV1) SSHPolicy(node *types.Node) (*tailcfg.SSHPolicy, erro
}
func (pm *PolicyManagerV1) SetPolicy(polB []byte) (bool, error) {
if len(polB) == 0 {
return false, nil
}
pol, err := LoadACLPolicyFromBytes(polB)
if err != nil {
return false, fmt.Errorf("parsing policy: %w", err)
@@ -157,7 +162,8 @@ func (pm *PolicyManagerV1) Tags(node *types.Node) []string {
return nil
}
tags, _ := pm.pol.TagsOfNode(node)
tags, invalid := pm.pol.TagsOfNode(pm.users, node)
log.Debug().Strs("authorised_tags", tags).Strs("unauthorised_tags", invalid).Uint64("node.id", node.ID.Uint64()).Msg("tags provided by policy")
return tags
}

View File

@@ -156,7 +156,7 @@ func (m *mapSession) serve() {
// current configuration.
//
// If OmitPeers is true, Stream is false, and ReadOnly is false,
// then te server will let clients update their endpoints without
// then the server will let clients update their endpoints without
// breaking existing long-polling (Stream == true) connections.
// In this case, the server can omit the entire response; the client
// only checks the HTTP response status code.
@@ -387,7 +387,7 @@ func (m *mapSession) serveLongPoll() {
func (m *mapSession) pollFailoverRoutes(where string, node *types.Node) {
update, err := db.Write(m.h.db.DB, func(tx *gorm.DB) (*types.StateUpdate, error) {
return db.FailoverNodeRoutesIfNeccessary(tx, m.h.nodeNotifier.LikelyConnectedMap(), node)
return db.FailoverNodeRoutesIfNecessary(tx, m.h.nodeNotifier.LikelyConnectedMap(), node)
})
if err != nil {
m.errf(err, fmt.Sprintf("failed to ensure failover routes, %s", where))
@@ -453,7 +453,7 @@ func (m *mapSession) handleEndpointUpdate() {
// If there is no NetInfo, keep the previous one.
// From 1.66 the client only sends it if changed:
// https://github.com/tailscale/tailscale/commit/e1011f138737286ecf5123ff887a7a5800d129a2
// TODO(kradalby): evaulate if we need better comparing of hostinfo
// TODO(kradalby): evaluate if we need better comparing of hostinfo
// before we take the changes.
if m.req.Hostinfo.NetInfo == nil && m.node.Hostinfo != nil {
m.req.Hostinfo.NetInfo = m.node.Hostinfo.NetInfo
@@ -691,7 +691,7 @@ func hostInfoChanged(old, new *tailcfg.Hostinfo) (bool, bool) {
}
// Services is mostly useful for discovery and not critical,
// except for peerapi, which is how nodes talk to eachother.
// except for peerapi, which is how nodes talk to each other.
// If peerapi was not part of the initial mapresponse, we
// need to make sure its sent out later as it is needed for
// Taildrop.

View File

@@ -27,50 +27,27 @@ func Apple(url string) *elem.Element {
elem.Text("App store"),
),
),
elem.Li(nil,
elem.Text("Open Tailscale and make sure you are "),
elem.I(nil, elem.Text("not ")),
elem.Text("logged in to any account"),
),
elem.Li(nil,
elem.Text("Open Settings on the iOS device"),
elem.Li(
nil,
elem.Text("Open the Tailscale app"),
),
elem.Li(
nil,
elem.Text(
`Scroll down to the "third party apps" section, under "Game Center" or "TV Provider"`,
),
elem.Text(`Click the account icon in the top-right corner and select "Log in…".`),
),
elem.Li(nil,
elem.Text("Find Tailscale and select it"),
elem.Ul(nil,
elem.Li(
nil,
elem.Text(
`If the iOS device was previously logged into Tailscale, switch the "Reset Keychain" toggle to "on"`,
),
),
),
elem.Li(
nil,
elem.Text(`Tap the top-right options menu button and select "Use custom coordination server".`),
),
elem.Li(
nil,
elem.Text(
fmt.Sprintf(
`Enter "%s" under "Alternate Coordination Server URL"`,
`Enter your instance URL: "%s"`,
url,
),
),
),
elem.Li(
nil,
elem.Text(
"Restart the app by closing it from the iOS app switcher, open the app and select the regular sign in option ",
),
elem.I(nil, elem.Text("(non-SSO)")),
elem.Text(
". It should open up to the headscale authentication page.",
),
),
elem.Li(
nil,
elem.Text(
@@ -93,7 +70,7 @@ func Apple(url string) *elem.Element {
elem.Li(
nil,
elem.Text(
"ALT + Click the Tailscale icon in the menu and hover over the Debug menu",
"Option + Click the Tailscale icon in the menu and hover over the Debug menu",
),
),
elem.Li(nil,

View File

@@ -6,6 +6,7 @@ import (
"github.com/chasefleming/elem-go"
"github.com/chasefleming/elem-go/attrs"
"github.com/chasefleming/elem-go/styles"
"github.com/juanfont/headscale/hscontrol/types"
)
var codeStyleRegisterWebAPI = styles.Props{
@@ -15,7 +16,7 @@ var codeStyleRegisterWebAPI = styles.Props{
styles.BackgroundColor: "#eee",
}
func RegisterWeb(key string) *elem.Element {
func RegisterWeb(registrationID types.RegistrationID) *elem.Element {
return HtmlStructure(
elem.Title(nil, elem.Text("Registration - Headscale")),
elem.Body(attrs.Props{
@@ -27,7 +28,7 @@ func RegisterWeb(key string) *elem.Element {
elem.H2(nil, elem.Text("Machine registration")),
elem.P(nil, elem.Text("Run the command below in the headscale server to add this machine to your network: ")),
elem.Code(attrs.Props{attrs.Style: codeStyleRegisterWebAPI.ToInline()},
elem.Text(fmt.Sprintf("headscale nodes register --user USERNAME --key %s", key)),
elem.Text(fmt.Sprintf("headscale nodes register --user USERNAME --key %s", registrationID.String())),
),
),
)

View File

@@ -3,8 +3,10 @@ package types
import (
"context"
"errors"
"fmt"
"time"
"github.com/juanfont/headscale/hscontrol/util"
"tailscale.com/tailcfg"
"tailscale.com/util/ctxkey"
)
@@ -100,6 +102,20 @@ func (su *StateUpdate) Empty() bool {
return false
}
func StateSelf(nodeID NodeID) StateUpdate {
return StateUpdate{
Type: StateSelfUpdate,
ChangeNodes: []NodeID{nodeID},
}
}
func StateUpdatePeerAdded(nodeIDs ...NodeID) StateUpdate {
return StateUpdate{
Type: StatePeerChanged,
ChangeNodes: nodeIDs,
}
}
func StateUpdateExpire(nodeID NodeID, expiry time.Time) StateUpdate {
return StateUpdate{
Type: StatePeerChangedPatch,
@@ -123,3 +139,40 @@ func NotifyCtx(ctx context.Context, origin, hostname string) context.Context {
ctx2 = NotifyHostnameKey.WithValue(ctx2, hostname)
return ctx2
}
const RegistrationIDLength = 24
type RegistrationID string
func NewRegistrationID() (RegistrationID, error) {
rid, err := util.GenerateRandomStringURLSafe(RegistrationIDLength)
if err != nil {
return "", err
}
return RegistrationID(rid), nil
}
func MustRegistrationID() RegistrationID {
rid, err := NewRegistrationID()
if err != nil {
panic(err)
}
return rid
}
func RegistrationIDFromString(str string) (RegistrationID, error) {
if len(str) != RegistrationIDLength {
return "", fmt.Errorf("registration ID must be %d characters long", RegistrationIDLength)
}
return RegistrationID(str), nil
}
func (r RegistrationID) String() string {
return string(r)
}
type RegisterNode struct {
Node Node
Registered chan struct{}
}

View File

@@ -26,11 +26,14 @@ import (
const (
defaultOIDCExpiryTime = 180 * 24 * time.Hour // 180 Days
maxDuration time.Duration = 1<<63 - 1
PKCEMethodPlain string = "plain"
PKCEMethodS256 string = "S256"
)
var (
errOidcMutuallyExclusive = errors.New("oidc_client_secret and oidc_client_secret_path are mutually exclusive")
errServerURLSuffix = errors.New("server_url cannot be part of base_domain in a way that could make the DERP and headscale server unreachable")
errInvalidPKCEMethod = errors.New("pkce.method must be either 'plain' or 'S256'")
)
type IPAllocationStrategy string
@@ -162,6 +165,11 @@ type LetsEncryptConfig struct {
ChallengeType string
}
type PKCEConfig struct {
Enabled bool
Method string
}
type OIDCConfig struct {
OnlyStartIfOIDCIsAvailable bool
Issuer string
@@ -176,6 +184,7 @@ type OIDCConfig struct {
Expiry time.Duration
UseExpiryFromToken bool
MapLegacyUsers bool
PKCE PKCEConfig
}
type DERPConfig struct {
@@ -211,6 +220,10 @@ type PolicyConfig struct {
Mode PolicyMode
}
func (p *PolicyConfig) IsEmpty() bool {
return p.Mode == PolicyModeFile && p.Path == ""
}
type LogConfig struct {
Format string
Level zerolog.Level
@@ -222,6 +235,24 @@ type Tuning struct {
NodeMapSessionBufferedChanSize int
}
func validatePKCEMethod(method string) error {
if method != PKCEMethodPlain && method != PKCEMethodS256 {
return errInvalidPKCEMethod
}
return nil
}
// Domain returns the hostname/domain part of the ServerURL.
// If the ServerURL is not a valid URL, it returns the BaseDomain.
func (c *Config) Domain() string {
u, err := url.Parse(c.ServerURL)
if err != nil {
return c.BaseDomain
}
return u.Hostname()
}
// LoadConfig prepares and loads the Headscale configuration into Viper.
// This means it sets the default values, reads the configuration file and
// environment variables, and handles deprecated configuration options.
@@ -288,7 +319,9 @@ func LoadConfig(path string, isFile bool) error {
viper.SetDefault("oidc.only_start_if_oidc_is_available", true)
viper.SetDefault("oidc.expiry", "180d")
viper.SetDefault("oidc.use_expiry_from_token", false)
viper.SetDefault("oidc.map_legacy_users", true)
viper.SetDefault("oidc.map_legacy_users", false)
viper.SetDefault("oidc.pkce.enabled", false)
viper.SetDefault("oidc.pkce.method", "S256")
viper.SetDefault("logtail.enabled", false)
viper.SetDefault("randomize_client_port", false)
@@ -336,6 +369,12 @@ func validateServerConfig() error {
// after #2170 is cleaned up
// depr.fatal("oidc.strip_email_domain")
if viper.GetBool("oidc.enabled") {
if err := validatePKCEMethod(viper.GetString("oidc.pkce.method")); err != nil {
return err
}
}
depr.Log()
for _, removed := range []string{
@@ -589,7 +628,7 @@ func dns() (DNSConfig, error) {
// UnmarshalKey is compatible with Environment Variables.
// err := viper.UnmarshalKey("dns", &dns)
// if err != nil {
// return DNSConfig{}, fmt.Errorf("unmarshaling dns config: %w", err)
// return DNSConfig{}, fmt.Errorf("unmarshalling dns config: %w", err)
// }
dns.MagicDNS = viper.GetBool("dns.magic_dns")
@@ -604,7 +643,7 @@ func dns() (DNSConfig, error) {
err := viper.UnmarshalKey("dns.extra_records", &extraRecords)
if err != nil {
return DNSConfig{}, fmt.Errorf("unmarshaling dns extra records: %w", err)
return DNSConfig{}, fmt.Errorf("unmarshalling dns extra records: %w", err)
}
dns.ExtraRecords = extraRecords
}
@@ -924,6 +963,10 @@ func LoadServerConfig() (*Config, error) {
// after #2170 is cleaned up
StripEmaildomain: viper.GetBool("oidc.strip_email_domain"),
MapLegacyUsers: viper.GetBool("oidc.map_legacy_users"),
PKCE: PKCEConfig{
Enabled: viper.GetBool("oidc.pkce.enabled"),
Method: viper.GetString("oidc.pkce.method"),
},
},
LogTail: logTailConfig,

View File

@@ -77,9 +77,12 @@ type Node struct {
ForcedTags []string `gorm:"serializer:json"`
// TODO(kradalby): This seems like irrelevant information?
AuthKeyID *uint64 `sql:"DEFAULT:NULL"`
AuthKey *PreAuthKey `gorm:"constraint:OnDelete:SET NULL;"`
// When a node has been created with a PreAuthKey, we need to
// prevent the preauthkey from being deleted before the node.
// The preauthkey can define "tags" of the node so we need it
// around.
AuthKeyID *uint64 `sql:"DEFAULT:NULL"`
AuthKey *PreAuthKey
LastSeen *time.Time
Expiry *time.Time
@@ -105,7 +108,7 @@ func (node *Node) GivenNameHasBeenChanged() bool {
// IsExpired returns whether the node registration has expired.
func (node Node) IsExpired() bool {
// If Expiry is not set, the client has not indicated that
// it wants an expiry time, it is therefor considered
// it wants an expiry time, it is therefore considered
// to mean "not expired"
if node.Expiry == nil || node.Expiry.IsZero() {
return false
@@ -180,7 +183,7 @@ func (node *Node) CanAccess(filter []tailcfg.FilterRule, node2 *Node) bool {
src := node.IPs()
allowedIPs := node2.IPs()
// TODO(kradalby): Regenerate this everytime the filter change, instead of
// TODO(kradalby): Regenerate this every time the filter change, instead of
// every time we use it.
matchers := make([]matcher.Match, len(filter))
for i, rule := range filter {

View File

@@ -14,7 +14,7 @@ type PreAuthKey struct {
ID uint64 `gorm:"primary_key"`
Key string
UserID uint
User User `gorm:"constraint:OnDelete:CASCADE;"`
User User `gorm:"constraint:OnDelete:SET NULL;"`
Reusable bool
Ephemeral bool `gorm:"default:false"`
Used bool `gorm:"default:false"`

View File

@@ -13,7 +13,7 @@ import (
type Route struct {
gorm.Model
NodeID uint64
NodeID uint64 `gorm:"not null"`
Node *Node
// TODO(kradalby): change this custom type to netip.Prefix
@@ -79,7 +79,6 @@ func (rs Routes) Proto() []*v1.Route {
for _, route := range rs {
protoRoute := v1.Route{
Id: uint64(route.ID),
Node: route.Node.Proto(),
Prefix: route.Prefix.String(),
Advertised: route.Advertised,
Enabled: route.Enabled,
@@ -88,6 +87,10 @@ func (rs Routes) Proto() []*v1.Route {
UpdatedAt: timestamppb.New(route.UpdatedAt),
}
if route.Node != nil {
protoRoute.Node = route.Node.Proto()
}
if route.DeletedAt.Valid {
protoRoute.DeletedAt = timestamppb.New(route.DeletedAt.Time)
}

View File

@@ -3,11 +3,14 @@ package types
import (
"cmp"
"database/sql"
"encoding/json"
"fmt"
"net/mail"
"strconv"
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
"github.com/juanfont/headscale/hscontrol/util"
"github.com/rs/zerolog/log"
"google.golang.org/protobuf/types/known/timestamppb"
"gorm.io/gorm"
"tailscale.com/tailcfg"
@@ -26,8 +29,9 @@ type User struct {
// you can have multiple users with the same name in OIDC,
// but not if you only run with CLI users.
// Username for the user, is used if email is empty
// Name (username) for the user, is used if email is empty
// Should not be used, please use Username().
// It is unique if ProviderIdentifier is not set.
Name string
// Typically the full name of the user
@@ -37,9 +41,11 @@ type User struct {
// Should not be used, please use Username().
Email string
// Unique identifier of the user from OIDC,
// comes from `sub` claim in the OIDC token
// and is used to lookup the user.
// ProviderIdentifier is a unique or not set identifier of the
// user from OIDC. It is the combination of `iss`
// and `sub` claim in the OIDC token.
// It is unique if set.
// It is unique together with Name.
ProviderIdentifier sql.NullString
// Provider is the origin of the user account,
@@ -74,10 +80,8 @@ func (u *User) profilePicURL() string {
func (u *User) TailscaleUser() *tailcfg.User {
user := tailcfg.User{
ID: tailcfg.UserID(u.ID),
LoginName: u.Username(),
DisplayName: u.DisplayNameOrUsername(),
ProfilePicURL: u.profilePicURL(),
Logins: []tailcfg.LoginID{},
Created: u.CreatedAt,
}
@@ -119,18 +123,49 @@ func (u *User) Proto() *v1.User {
}
}
// JumpCloud returns a JSON where email_verified is returned as a
// string "true" or "false" instead of a boolean.
// This maps bool to a specific type with a custom unmarshaler to
// ensure we can decode it from a string.
// https://github.com/juanfont/headscale/issues/2293
type FlexibleBoolean bool
func (bit *FlexibleBoolean) UnmarshalJSON(data []byte) error {
var val interface{}
err := json.Unmarshal(data, &val)
if err != nil {
return fmt.Errorf("could not unmarshal data: %w", err)
}
switch v := val.(type) {
case bool:
*bit = FlexibleBoolean(v)
case string:
pv, err := strconv.ParseBool(v)
if err != nil {
return fmt.Errorf("could not parse %s as boolean: %w", v, err)
}
*bit = FlexibleBoolean(pv)
default:
return fmt.Errorf("could not parse %v as boolean", v)
}
return nil
}
type OIDCClaims struct {
// Sub is the user's unique identifier at the provider.
Sub string `json:"sub"`
Iss string `json:"iss"`
// Name is the user's full name.
Name string `json:"name,omitempty"`
Groups []string `json:"groups,omitempty"`
Email string `json:"email,omitempty"`
EmailVerified bool `json:"email_verified,omitempty"`
ProfilePictureURL string `json:"picture,omitempty"`
Username string `json:"preferred_username,omitempty"`
Name string `json:"name,omitempty"`
Groups []string `json:"groups,omitempty"`
Email string `json:"email,omitempty"`
EmailVerified FlexibleBoolean `json:"email_verified,omitempty"`
ProfilePictureURL string `json:"picture,omitempty"`
Username string `json:"preferred_username,omitempty"`
}
func (c *OIDCClaims) Identifier() string {
@@ -140,9 +175,11 @@ func (c *OIDCClaims) Identifier() string {
// FromClaim overrides a User from OIDC claims.
// All fields will be updated, except for the ID.
func (u *User) FromClaim(claims *OIDCClaims) {
err := util.CheckForFQDNRules(claims.Username)
err := util.ValidateUsername(claims.Username)
if err == nil {
u.Name = claims.Username
} else {
log.Debug().Err(err).Msgf("Username %s is not valid", claims.Username)
}
if claims.EmailVerified {

View File

@@ -0,0 +1,223 @@
package types
import (
"database/sql"
"encoding/json"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/juanfont/headscale/hscontrol/util"
)
func TestUnmarshallOIDCClaims(t *testing.T) {
tests := []struct {
name string
jsonstr string
want OIDCClaims
}{
{
name: "normal-bool",
jsonstr: `
{
"sub": "test",
"email": "test@test.no",
"email_verified": true
}
`,
want: OIDCClaims{
Sub: "test",
Email: "test@test.no",
EmailVerified: true,
},
},
{
name: "string-bool-true",
jsonstr: `
{
"sub": "test2",
"email": "test2@test.no",
"email_verified": "true"
}
`,
want: OIDCClaims{
Sub: "test2",
Email: "test2@test.no",
EmailVerified: true,
},
},
{
name: "string-bool-false",
jsonstr: `
{
"sub": "test3",
"email": "test3@test.no",
"email_verified": "false"
}
`,
want: OIDCClaims{
Sub: "test3",
Email: "test3@test.no",
EmailVerified: false,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var got OIDCClaims
if err := json.Unmarshal([]byte(tt.jsonstr), &got); err != nil {
t.Errorf("UnmarshallOIDCClaims() error = %v", err)
return
}
if diff := cmp.Diff(got, tt.want); diff != "" {
t.Errorf("UnmarshallOIDCClaims() mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestOIDCClaimsJSONToUser(t *testing.T) {
tests := []struct {
name string
jsonstr string
want User
}{
{
name: "normal-bool",
jsonstr: `
{
"sub": "test",
"email": "test@test.no",
"email_verified": true
}
`,
want: User{
Provider: util.RegisterMethodOIDC,
Email: "test@test.no",
ProviderIdentifier: sql.NullString{
String: "/test",
Valid: true,
},
},
},
{
name: "string-bool-true",
jsonstr: `
{
"sub": "test2",
"email": "test2@test.no",
"email_verified": "true"
}
`,
want: User{
Provider: util.RegisterMethodOIDC,
Email: "test2@test.no",
ProviderIdentifier: sql.NullString{
String: "/test2",
Valid: true,
},
},
},
{
name: "string-bool-false",
jsonstr: `
{
"sub": "test3",
"email": "test3@test.no",
"email_verified": "false"
}
`,
want: User{
Provider: util.RegisterMethodOIDC,
ProviderIdentifier: sql.NullString{
String: "/test3",
Valid: true,
},
},
},
{
// From https://github.com/juanfont/headscale/issues/2333
name: "okta-oidc-claim-20250121",
jsonstr: `
{
"sub": "00u7dr4qp7XXXXXXXXXX",
"name": "Tim Horton",
"email": "tim.horton@company.com",
"ver": 1,
"iss": "https://sso.company.com/oauth2/default",
"aud": "0oa8neto4tXXXXXXXXXX",
"iat": 1737455152,
"exp": 1737458752,
"jti": "ID.zzJz93koTunMKv5Bq-XXXXXXXXXXXXXXXXXXXXXXXXX",
"amr": [
"pwd"
],
"idp": "00o42r3s2cXXXXXXXX",
"nonce": "nonce",
"preferred_username": "tim.horton@company.com",
"auth_time": 1000,
"at_hash": "preview_at_hash"
}
`,
want: User{
Provider: util.RegisterMethodOIDC,
DisplayName: "Tim Horton",
Name: "tim.horton@company.com",
ProviderIdentifier: sql.NullString{
String: "https://sso.company.com/oauth2/default/00u7dr4qp7XXXXXXXXXX",
Valid: true,
},
},
},
{
// From https://github.com/juanfont/headscale/issues/2333
name: "okta-oidc-claim-20250121",
jsonstr: `
{
"aud": "79xxxxxx-xxxx-xxxx-xxxx-892146xxxxxx",
"iss": "https://login.microsoftonline.com//v2.0",
"iat": 1737346441,
"nbf": 1737346441,
"exp": 1737350341,
"aio": "AWQAm/8ZAAAABKne9EWr6ygVO2DbcRmoPIpRM819qqlP/mmK41AAWv/C2tVkld4+znbG8DaXFdLQa9jRUzokvsT7rt9nAT6Fg7QC+/ecDWsF5U+QX11f9Ox7ZkK4UAIWFcIXpuZZvRS7",
"email": "user@domain.com",
"name": "XXXXXX XXXX",
"oid": "54c2323d-5052-4130-9588-ad751909003f",
"preferred_username": "user@domain.com",
"rh": "1.AXUAXdg0Rfc11UifLDJv67ChfSluoXmD9z1EmK-JIUYuSK9cAQl1AA.",
"sid": "5250a0a2-0b4e-4e68-8652-b4e97866411d",
"sub": "I-70OQnj3TogrNSfkZQqB3f7dGwyBWSm1dolHNKrMzQ",
"tid": "<redacted>",
"uti": "zAuXeEtMM0GwcTAcOsBZAA",
"ver": "2.0"
}
`,
want: User{
Provider: util.RegisterMethodOIDC,
DisplayName: "XXXXXX XXXX",
Name: "user@domain.com",
ProviderIdentifier: sql.NullString{
String: "https://login.microsoftonline.com//v2.0/I-70OQnj3TogrNSfkZQqB3f7dGwyBWSm1dolHNKrMzQ",
Valid: true,
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var got OIDCClaims
if err := json.Unmarshal([]byte(tt.jsonstr), &got); err != nil {
t.Errorf("TestOIDCClaimsJSONToUser() error = %v", err)
return
}
var user User
user.FromClaim(&got)
if diff := cmp.Diff(user, tt.want); diff != "" {
t.Errorf("TestOIDCClaimsJSONToUser() mismatch (-want +got):\n%s", diff)
}
})
}
}

View File

@@ -6,6 +6,7 @@ import (
"net/netip"
"regexp"
"strings"
"unicode"
"go4.org/netipx"
"tailscale.com/util/dnsname"
@@ -20,11 +21,55 @@ const (
LabelHostnameLength = 63
)
var invalidDNSRegex = regexp.MustCompile("[^a-z0-9-.]+")
var invalidCharsInUserRegex = regexp.MustCompile("[^a-z0-9-.]+")
var ErrInvalidUserName = errors.New("invalid user name")
// ValidateUsername checks if a username is valid.
// It must be at least 2 characters long, start with a letter, and contain
// only letters, numbers, hyphens, dots, and underscores.
// It cannot contain more than one '@'.
// It cannot contain invalid characters.
func ValidateUsername(username string) error {
// Ensure the username meets the minimum length requirement
if len(username) < 2 {
return errors.New("username must be at least 2 characters long")
}
// Ensure the username does not start with a number
if unicode.IsDigit(rune(username[0])) {
return errors.New("username cannot start with a number")
}
atCount := 0
for _, char := range username {
switch {
case unicode.IsLetter(char),
unicode.IsDigit(char),
char == '-',
char == '.',
char == '_':
// Valid characters
case char == '@':
atCount++
if atCount > 1 {
return errors.New("username cannot contain more than one '@'")
}
default:
return fmt.Errorf("username contains invalid character: '%c'", char)
}
}
return nil
}
func CheckForFQDNRules(name string) error {
// Ensure the username meets the minimum length requirement
if len(name) < 2 {
return errors.New("name must be at least 2 characters long")
}
if len(name) > LabelHostnameLength {
return fmt.Errorf(
"DNS segment must not be over 63 chars. %v doesn't comply with this rule: %w",
@@ -39,9 +84,9 @@ func CheckForFQDNRules(name string) error {
ErrInvalidUserName,
)
}
if invalidCharsInUserRegex.MatchString(name) {
if invalidDNSRegex.MatchString(name) {
return fmt.Errorf(
"DNS segment should only be composed of lowercase ASCII letters numbers, hyphen and dots. %v doesn't comply with theses rules: %w",
"DNS segment should only be composed of lowercase ASCII letters numbers, hyphen and dots. %v doesn't comply with these rules: %w",
name,
ErrInvalidUserName,
)
@@ -52,7 +97,7 @@ func CheckForFQDNRules(name string) error {
func ConvertWithFQDNRules(name string) string {
name = strings.ToLower(name)
name = invalidCharsInUserRegex.ReplaceAllString(name, "")
name = invalidDNSRegex.ReplaceAllString(name, "")
return name
}
@@ -197,7 +242,7 @@ func NormalizeToFQDNRules(name string, stripEmailDomain bool) (string, error) {
} else {
name = strings.ReplaceAll(name, "@", ".")
}
name = invalidCharsInUserRegex.ReplaceAllString(name, "-")
name = invalidDNSRegex.ReplaceAllString(name, "-")
for _, elt := range strings.Split(name, ".") {
if len(elt) > LabelHostnameLength {

View File

@@ -32,7 +32,8 @@ func GenerateRandomBytes(n int) ([]byte, error) {
func GenerateRandomStringURLSafe(n int) (string, error) {
b, err := GenerateRandomBytes(n)
return base64.RawURLEncoding.EncodeToString(b), err
uenc := base64.RawURLEncoding.EncodeToString(b)
return uenc[:n], err
}
// GenerateRandomStringDNSSafe returns a DNS-safe

View File

@@ -119,12 +119,12 @@ func TestACLHostsInNetMapTable(t *testing.T) {
},
},
}, want: map[string]int{
"user1": 3, // ns1 + ns2
"user2": 3, // ns2 + ns1
"user1@test.no": 3, // ns1 + ns2
"user2@test.no": 3, // ns2 + ns1
},
},
// Test that when we have two users, which cannot see
// eachother, each node has only the number of pairs from
// each other, each node has only the number of pairs from
// their own user.
"two-isolated-users": {
users: map[string]int{
@@ -145,8 +145,8 @@ func TestACLHostsInNetMapTable(t *testing.T) {
},
},
}, want: map[string]int{
"user1": 1,
"user2": 1,
"user1@test.no": 1,
"user2@test.no": 1,
},
},
// Test that when we have two users, with ACLs and they
@@ -181,8 +181,8 @@ func TestACLHostsInNetMapTable(t *testing.T) {
},
},
}, want: map[string]int{
"user1": 3,
"user2": 3,
"user1@test.no": 3,
"user2@test.no": 3,
},
},
// Test that when we have two users, that are isolated,
@@ -213,8 +213,8 @@ func TestACLHostsInNetMapTable(t *testing.T) {
},
},
}, want: map[string]int{
"user1": 3, // ns1 + ns2
"user2": 3, // ns1 + ns2 (return path)
"user1@test.no": 3, // ns1 + ns2
"user2@test.no": 3, // ns1 + ns2 (return path)
},
},
"very-large-destination-prefix-1372": {
@@ -241,8 +241,8 @@ func TestACLHostsInNetMapTable(t *testing.T) {
},
},
}, want: map[string]int{
"user1": 3, // ns1 + ns2
"user2": 3, // ns1 + ns2 (return path)
"user1@test.no": 3, // ns1 + ns2
"user2@test.no": 3, // ns1 + ns2 (return path)
},
},
"ipv6-acls-1470": {
@@ -259,8 +259,8 @@ func TestACLHostsInNetMapTable(t *testing.T) {
},
},
}, want: map[string]int{
"user1": 3, // ns1 + ns2
"user2": 3, // ns2 + ns1
"user1@test.no": 3, // ns1 + ns2
"user2@test.no": 3, // ns2 + ns1
},
},
}
@@ -282,7 +282,7 @@ func TestACLHostsInNetMapTable(t *testing.T) {
allClients, err := scenario.ListTailscaleClients()
require.NoError(t, err)
err = scenario.WaitForTailscaleSyncWithPeerCount(testCase.want["user1"])
err = scenario.WaitForTailscaleSyncWithPeerCount(testCase.want["user1@test.no"])
require.NoError(t, err)
for _, client := range allClients {

View File

@@ -0,0 +1,230 @@
package integration
import (
"fmt"
"net/netip"
"testing"
"time"
"github.com/juanfont/headscale/integration/hsic"
"github.com/juanfont/headscale/integration/tsic"
"github.com/samber/lo"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAuthKeyLogoutAndReloginSameUser(t *testing.T) {
IntegrationSkip(t)
t.Parallel()
for _, https := range []bool{true, false} {
t.Run(fmt.Sprintf("with-https-%t", https), func(t *testing.T) {
scenario, err := NewScenario(dockertestMaxWait())
assertNoErr(t, err)
defer scenario.ShutdownAssertNoPanics(t)
spec := map[string]int{
"user1": len(MustTestVersions),
"user2": len(MustTestVersions),
}
opts := []hsic.Option{hsic.WithTestName("pingallbyip")}
if https {
opts = append(opts, []hsic.Option{
hsic.WithTLS(),
}...)
}
err = scenario.CreateHeadscaleEnv(spec, []tsic.Option{}, opts...)
assertNoErrHeadscaleEnv(t, err)
allClients, err := scenario.ListTailscaleClients()
assertNoErrListClients(t, err)
err = scenario.WaitForTailscaleSync()
assertNoErrSync(t, err)
// assertClientsState(t, allClients)
clientIPs := make(map[TailscaleClient][]netip.Addr)
for _, client := range allClients {
ips, err := client.IPs()
if err != nil {
t.Fatalf("failed to get IPs for client %s: %s", client.Hostname(), err)
}
clientIPs[client] = ips
}
headscale, err := scenario.Headscale()
assertNoErrGetHeadscale(t, err)
listNodes, err := headscale.ListNodes()
assert.Equal(t, len(listNodes), len(allClients))
nodeCountBeforeLogout := len(listNodes)
t.Logf("node count before logout: %d", nodeCountBeforeLogout)
for _, client := range allClients {
err := client.Logout()
if err != nil {
t.Fatalf("failed to logout client %s: %s", client.Hostname(), err)
}
}
err = scenario.WaitForTailscaleLogout()
assertNoErrLogout(t, err)
t.Logf("all clients logged out")
// if the server is not running with HTTPS, we have to wait a bit before
// reconnection as the newest Tailscale client has a measure that will only
// reconnect over HTTPS if they saw a noise connection previously.
// https://github.com/tailscale/tailscale/commit/1eaad7d3deb0815e8932e913ca1a862afa34db38
// https://github.com/juanfont/headscale/issues/2164
if !https {
time.Sleep(5 * time.Minute)
}
for userName := range spec {
key, err := scenario.CreatePreAuthKey(userName, true, false)
if err != nil {
t.Fatalf("failed to create pre-auth key for user %s: %s", userName, err)
}
err = scenario.RunTailscaleUp(userName, headscale.GetEndpoint(), key.GetKey())
if err != nil {
t.Fatalf("failed to run tailscale up for user %s: %s", userName, err)
}
}
listNodes, err = headscale.ListNodes()
require.Equal(t, nodeCountBeforeLogout, len(listNodes))
allIps, err := scenario.ListTailscaleClientsIPs()
assertNoErrListClientIPs(t, err)
allAddrs := lo.Map(allIps, func(x netip.Addr, index int) string {
return x.String()
})
success := pingAllHelper(t, allClients, allAddrs)
t.Logf("%d successful pings out of %d", success, len(allClients)*len(allIps))
for _, client := range allClients {
ips, err := client.IPs()
if err != nil {
t.Fatalf("failed to get IPs for client %s: %s", client.Hostname(), err)
}
// lets check if the IPs are the same
if len(ips) != len(clientIPs[client]) {
t.Fatalf("IPs changed for client %s", client.Hostname())
}
for _, ip := range ips {
found := false
for _, oldIP := range clientIPs[client] {
if ip == oldIP {
found = true
break
}
}
if !found {
t.Fatalf(
"IPs changed for client %s. Used to be %v now %v",
client.Hostname(),
clientIPs[client],
ips,
)
}
}
}
})
}
}
// This test will first log in two sets of nodes to two sets of users, then
// it will log out all users from user2 and log them in as user1.
// This should leave us with all nodes connected to user1, while user2
// still has nodes, but they are not connected.
func TestAuthKeyLogoutAndReloginNewUser(t *testing.T) {
IntegrationSkip(t)
t.Parallel()
scenario, err := NewScenario(dockertestMaxWait())
assertNoErr(t, err)
defer scenario.ShutdownAssertNoPanics(t)
spec := map[string]int{
"user1": len(MustTestVersions),
"user2": len(MustTestVersions),
}
err = scenario.CreateHeadscaleEnv(spec, []tsic.Option{},
hsic.WithTestName("keyrelognewuser"),
hsic.WithTLS(),
)
assertNoErrHeadscaleEnv(t, err)
allClients, err := scenario.ListTailscaleClients()
assertNoErrListClients(t, err)
err = scenario.WaitForTailscaleSync()
assertNoErrSync(t, err)
// assertClientsState(t, allClients)
headscale, err := scenario.Headscale()
assertNoErrGetHeadscale(t, err)
listNodes, err := headscale.ListNodes()
assert.Equal(t, len(listNodes), len(allClients))
nodeCountBeforeLogout := len(listNodes)
t.Logf("node count before logout: %d", nodeCountBeforeLogout)
for _, client := range allClients {
err := client.Logout()
if err != nil {
t.Fatalf("failed to logout client %s: %s", client.Hostname(), err)
}
}
err = scenario.WaitForTailscaleLogout()
assertNoErrLogout(t, err)
t.Logf("all clients logged out")
// Create a new authkey for user1, to be used for all clients
key, err := scenario.CreatePreAuthKey("user1", true, false)
if err != nil {
t.Fatalf("failed to create pre-auth key for user1: %s", err)
}
// Log in all clients as user1, iterating over the spec only returns the
// clients, not the usernames.
for userName := range spec {
err = scenario.RunTailscaleUp(userName, headscale.GetEndpoint(), key.GetKey())
if err != nil {
t.Fatalf("failed to run tailscale up for user %s: %s", userName, err)
}
}
user1Nodes, err := headscale.ListNodes("user1")
assertNoErr(t, err)
assert.Len(t, user1Nodes, len(allClients))
// Validate that all the old nodes are still present with user2
user2Nodes, err := headscale.ListNodes("user2")
assertNoErr(t, err)
assert.Len(t, user2Nodes, len(allClients)/2)
for _, client := range allClients {
status, err := client.Status()
if err != nil {
t.Fatalf("failed to get status for client %s: %s", client.Hostname(), err)
}
assert.Equal(t, "user1@test.no", status.User[status.Self.UserID].LoginName)
}
}

View File

@@ -10,7 +10,9 @@ import (
"log"
"net"
"net/http"
"net/http/cookiejar"
"net/netip"
"net/url"
"sort"
"strconv"
"testing"
@@ -54,7 +56,7 @@ func TestOIDCAuthenticationPingAll(t *testing.T) {
scenario := AuthOIDCScenario{
Scenario: baseScenario,
}
// defer scenario.ShutdownAssertNoPanics(t)
defer scenario.ShutdownAssertNoPanics(t)
// Logins to MockOIDC is served by a queue with a strict order,
// if we use more than one node per user, the order of the logins
@@ -89,7 +91,6 @@ func TestOIDCAuthenticationPingAll(t *testing.T) {
hsic.WithTestName("oidcauthping"),
hsic.WithConfigEnv(oidcMap),
hsic.WithTLS(),
hsic.WithHostnameAsServerURL(),
hsic.WithFileInContainer("/tmp/hs_client_oidc_secret", []byte(oidcConfig.ClientSecret)),
)
assertNoErrHeadscaleEnv(t, err)
@@ -115,23 +116,14 @@ func TestOIDCAuthenticationPingAll(t *testing.T) {
headscale, err := scenario.Headscale()
assertNoErr(t, err)
var listUsers []v1.User
err = executeAndUnmarshal(headscale,
[]string{
"headscale",
"users",
"list",
"--output",
"json",
},
&listUsers,
)
listUsers, err := headscale.ListUsers()
assertNoErr(t, err)
want := []v1.User{
want := []*v1.User{
{
Id: 1,
Name: "user1",
Id: 1,
Name: "user1",
Email: "user1@test.no",
},
{
Id: 2,
@@ -141,8 +133,9 @@ func TestOIDCAuthenticationPingAll(t *testing.T) {
ProviderId: oidcConfig.Issuer + "/user1",
},
{
Id: 3,
Name: "user2",
Id: 3,
Name: "user2",
Email: "user2@test.no",
},
{
Id: 4,
@@ -202,7 +195,6 @@ func TestOIDCExpireNodesBasedOnTokenExpiry(t *testing.T) {
spec,
hsic.WithTestName("oidcexpirenodes"),
hsic.WithConfigEnv(oidcMap),
hsic.WithHostnameAsServerURL(),
)
assertNoErrHeadscaleEnv(t, err)
@@ -226,7 +218,7 @@ func TestOIDCExpireNodesBasedOnTokenExpiry(t *testing.T) {
// This is not great, but this sadly is a time dependent test, so the
// safe thing to do is wait out the whole TTL time before checking if
// the clients have logged out. The Wait function cant do it itself
// the clients have logged out. The Wait function can't do it itself
// as it has an upper bound of 1 min.
time.Sleep(shortAccessTTL)
@@ -247,7 +239,7 @@ func TestOIDC024UserCreation(t *testing.T) {
emailVerified bool
cliUsers []string
oidcUsers []string
want func(iss string) []v1.User
want func(iss string) []*v1.User
}{
{
name: "no-migration-verified-email",
@@ -257,11 +249,12 @@ func TestOIDC024UserCreation(t *testing.T) {
emailVerified: true,
cliUsers: []string{"user1", "user2"},
oidcUsers: []string{"user1", "user2"},
want: func(iss string) []v1.User {
return []v1.User{
want: func(iss string) []*v1.User {
return []*v1.User{
{
Id: 1,
Name: "user1",
Id: 1,
Name: "user1",
Email: "user1@test.no",
},
{
Id: 2,
@@ -271,8 +264,9 @@ func TestOIDC024UserCreation(t *testing.T) {
ProviderId: iss + "/user1",
},
{
Id: 3,
Name: "user2",
Id: 3,
Name: "user2",
Email: "user2@test.no",
},
{
Id: 4,
@@ -292,11 +286,12 @@ func TestOIDC024UserCreation(t *testing.T) {
emailVerified: false,
cliUsers: []string{"user1", "user2"},
oidcUsers: []string{"user1", "user2"},
want: func(iss string) []v1.User {
return []v1.User{
want: func(iss string) []*v1.User {
return []*v1.User{
{
Id: 1,
Name: "user1",
Id: 1,
Name: "user1",
Email: "user1@test.no",
},
{
Id: 2,
@@ -305,8 +300,9 @@ func TestOIDC024UserCreation(t *testing.T) {
ProviderId: iss + "/user1",
},
{
Id: 3,
Name: "user2",
Id: 3,
Name: "user2",
Email: "user2@test.no",
},
{
Id: 4,
@@ -326,8 +322,8 @@ func TestOIDC024UserCreation(t *testing.T) {
emailVerified: true,
cliUsers: []string{"user1", "user2"},
oidcUsers: []string{"user1", "user2"},
want: func(iss string) []v1.User {
return []v1.User{
want: func(iss string) []*v1.User {
return []*v1.User{
{
Id: 1,
Name: "user1",
@@ -354,11 +350,12 @@ func TestOIDC024UserCreation(t *testing.T) {
emailVerified: false,
cliUsers: []string{"user1", "user2"},
oidcUsers: []string{"user1", "user2"},
want: func(iss string) []v1.User {
return []v1.User{
want: func(iss string) []*v1.User {
return []*v1.User{
{
Id: 1,
Name: "user1",
Id: 1,
Name: "user1",
Email: "user1@test.no",
},
{
Id: 2,
@@ -367,8 +364,9 @@ func TestOIDC024UserCreation(t *testing.T) {
ProviderId: iss + "/user1",
},
{
Id: 3,
Name: "user2",
Id: 3,
Name: "user2",
Email: "user2@test.no",
},
{
Id: 4,
@@ -388,8 +386,8 @@ func TestOIDC024UserCreation(t *testing.T) {
emailVerified: true,
cliUsers: []string{"user1.headscale.net", "user2.headscale.net"},
oidcUsers: []string{"user1", "user2"},
want: func(iss string) []v1.User {
return []v1.User{
want: func(iss string) []*v1.User {
return []*v1.User{
// Hmm I think we will have to overwrite the initial name here
// createuser with "user1.headscale.net", but oidc with "user1"
{
@@ -418,11 +416,12 @@ func TestOIDC024UserCreation(t *testing.T) {
emailVerified: false,
cliUsers: []string{"user1.headscale.net", "user2.headscale.net"},
oidcUsers: []string{"user1", "user2"},
want: func(iss string) []v1.User {
return []v1.User{
want: func(iss string) []*v1.User {
return []*v1.User{
{
Id: 1,
Name: "user1.headscale.net",
Id: 1,
Name: "user1.headscale.net",
Email: "user1.headscale.net@test.no",
},
{
Id: 2,
@@ -431,8 +430,9 @@ func TestOIDC024UserCreation(t *testing.T) {
ProviderId: iss + "/user1",
},
{
Id: 3,
Name: "user2.headscale.net",
Id: 3,
Name: "user2.headscale.net",
Email: "user2.headscale.net@test.no",
},
{
Id: 4,
@@ -485,7 +485,6 @@ func TestOIDC024UserCreation(t *testing.T) {
hsic.WithTestName("oidcmigration"),
hsic.WithConfigEnv(oidcMap),
hsic.WithTLS(),
hsic.WithHostnameAsServerURL(),
hsic.WithFileInContainer("/tmp/hs_client_oidc_secret", []byte(oidcConfig.ClientSecret)),
)
assertNoErrHeadscaleEnv(t, err)
@@ -500,17 +499,7 @@ func TestOIDC024UserCreation(t *testing.T) {
want := tt.want(oidcConfig.Issuer)
var listUsers []v1.User
err = executeAndUnmarshal(headscale,
[]string{
"headscale",
"users",
"list",
"--output",
"json",
},
&listUsers,
)
listUsers, err := headscale.ListUsers()
assertNoErr(t, err)
sort.Slice(listUsers, func(i, j int) bool {
@@ -524,6 +513,290 @@ func TestOIDC024UserCreation(t *testing.T) {
}
}
func TestOIDCAuthenticationWithPKCE(t *testing.T) {
IntegrationSkip(t)
t.Parallel()
baseScenario, err := NewScenario(dockertestMaxWait())
assertNoErr(t, err)
scenario := AuthOIDCScenario{
Scenario: baseScenario,
}
defer scenario.ShutdownAssertNoPanics(t)
// Single user with one node for testing PKCE flow
spec := map[string]int{
"user1": 1,
}
mockusers := []mockoidc.MockUser{
oidcMockUser("user1", true),
}
oidcConfig, err := scenario.runMockOIDC(defaultAccessTTL, mockusers)
assertNoErrf(t, "failed to run mock OIDC server: %s", err)
defer scenario.mockOIDC.Close()
oidcMap := map[string]string{
"HEADSCALE_OIDC_ISSUER": oidcConfig.Issuer,
"HEADSCALE_OIDC_CLIENT_ID": oidcConfig.ClientID,
"HEADSCALE_OIDC_CLIENT_SECRET_PATH": "${CREDENTIALS_DIRECTORY_TEST}/hs_client_oidc_secret",
"CREDENTIALS_DIRECTORY_TEST": "/tmp",
"HEADSCALE_OIDC_PKCE_ENABLED": "1", // Enable PKCE
"HEADSCALE_OIDC_MAP_LEGACY_USERS": "0",
"HEADSCALE_OIDC_STRIP_EMAIL_DOMAIN": "0",
}
err = scenario.CreateHeadscaleEnv(
spec,
hsic.WithTestName("oidcauthpkce"),
hsic.WithConfigEnv(oidcMap),
hsic.WithTLS(),
hsic.WithFileInContainer("/tmp/hs_client_oidc_secret", []byte(oidcConfig.ClientSecret)),
)
assertNoErrHeadscaleEnv(t, err)
// Get all clients and verify they can connect
allClients, err := scenario.ListTailscaleClients()
assertNoErrListClients(t, err)
allIps, err := scenario.ListTailscaleClientsIPs()
assertNoErrListClientIPs(t, err)
err = scenario.WaitForTailscaleSync()
assertNoErrSync(t, err)
allAddrs := lo.Map(allIps, func(x netip.Addr, index int) string {
return x.String()
})
success := pingAllHelper(t, allClients, allAddrs)
t.Logf("%d successful pings out of %d", success, len(allClients)*len(allIps))
}
func TestOIDCReloginSameNodeNewUser(t *testing.T) {
IntegrationSkip(t)
t.Parallel()
baseScenario, err := NewScenario(dockertestMaxWait())
assertNoErr(t, err)
scenario := AuthOIDCScenario{
Scenario: baseScenario,
}
defer scenario.ShutdownAssertNoPanics(t)
// Create no nodes and no users
spec := map[string]int{}
// First login creates the first OIDC user
// Second login logs in the same node, which creates a new node
// Third login logs in the same node back into the original user
mockusers := []mockoidc.MockUser{
oidcMockUser("user1", true),
oidcMockUser("user2", true),
oidcMockUser("user1", true),
}
oidcConfig, err := scenario.runMockOIDC(defaultAccessTTL, mockusers)
assertNoErrf(t, "failed to run mock OIDC server: %s", err)
// defer scenario.mockOIDC.Close()
oidcMap := map[string]string{
"HEADSCALE_OIDC_ISSUER": oidcConfig.Issuer,
"HEADSCALE_OIDC_CLIENT_ID": oidcConfig.ClientID,
"CREDENTIALS_DIRECTORY_TEST": "/tmp",
"HEADSCALE_OIDC_CLIENT_SECRET_PATH": "${CREDENTIALS_DIRECTORY_TEST}/hs_client_oidc_secret",
// TODO(kradalby): Remove when strip_email_domain is removed
// after #2170 is cleaned up
"HEADSCALE_OIDC_MAP_LEGACY_USERS": "0",
"HEADSCALE_OIDC_STRIP_EMAIL_DOMAIN": "0",
}
err = scenario.CreateHeadscaleEnv(
spec,
hsic.WithTestName("oidcauthrelog"),
hsic.WithConfigEnv(oidcMap),
hsic.WithTLS(),
hsic.WithFileInContainer("/tmp/hs_client_oidc_secret", []byte(oidcConfig.ClientSecret)),
hsic.WithEmbeddedDERPServerOnly(),
)
assertNoErrHeadscaleEnv(t, err)
headscale, err := scenario.Headscale()
assertNoErr(t, err)
listUsers, err := headscale.ListUsers()
assertNoErr(t, err)
assert.Len(t, listUsers, 0)
ts, err := scenario.CreateTailscaleNode("unstable")
assertNoErr(t, err)
u, err := ts.LoginWithURL(headscale.GetEndpoint())
assertNoErr(t, err)
_, err = doLoginURL(ts.Hostname(), u)
assertNoErr(t, err)
listUsers, err = headscale.ListUsers()
assertNoErr(t, err)
assert.Len(t, listUsers, 1)
wantUsers := []*v1.User{
{
Id: 1,
Name: "user1",
Email: "user1@headscale.net",
Provider: "oidc",
ProviderId: oidcConfig.Issuer + "/user1",
},
}
sort.Slice(listUsers, func(i, j int) bool {
return listUsers[i].GetId() < listUsers[j].GetId()
})
if diff := cmp.Diff(wantUsers, listUsers, cmpopts.IgnoreUnexported(v1.User{}), cmpopts.IgnoreFields(v1.User{}, "CreatedAt")); diff != "" {
t.Fatalf("unexpected users: %s", diff)
}
listNodes, err := headscale.ListNodes()
assertNoErr(t, err)
assert.Len(t, listNodes, 1)
// Log out user1 and log in user2, this should create a new node
// for user2, the node should have the same machine key and
// a new node key.
err = ts.Logout()
assertNoErr(t, err)
time.Sleep(5 * time.Second)
// TODO(kradalby): Not sure why we need to logout twice, but it fails and
// logs in immediately after the first logout and I cannot reproduce it
// manually.
err = ts.Logout()
assertNoErr(t, err)
u, err = ts.LoginWithURL(headscale.GetEndpoint())
assertNoErr(t, err)
_, err = doLoginURL(ts.Hostname(), u)
assertNoErr(t, err)
listUsers, err = headscale.ListUsers()
assertNoErr(t, err)
assert.Len(t, listUsers, 2)
wantUsers = []*v1.User{
{
Id: 1,
Name: "user1",
Email: "user1@headscale.net",
Provider: "oidc",
ProviderId: oidcConfig.Issuer + "/user1",
},
{
Id: 2,
Name: "user2",
Email: "user2@headscale.net",
Provider: "oidc",
ProviderId: oidcConfig.Issuer + "/user2",
},
}
sort.Slice(listUsers, func(i, j int) bool {
return listUsers[i].GetId() < listUsers[j].GetId()
})
if diff := cmp.Diff(wantUsers, listUsers, cmpopts.IgnoreUnexported(v1.User{}), cmpopts.IgnoreFields(v1.User{}, "CreatedAt")); diff != "" {
t.Fatalf("unexpected users: %s", diff)
}
listNodesAfterNewUserLogin, err := headscale.ListNodes()
assertNoErr(t, err)
assert.Len(t, listNodesAfterNewUserLogin, 2)
// Machine key is the same as the "machine" has not changed,
// but Node key is not as it is a new node
assert.Equal(t, listNodes[0].MachineKey, listNodesAfterNewUserLogin[0].MachineKey)
assert.Equal(t, listNodesAfterNewUserLogin[0].MachineKey, listNodesAfterNewUserLogin[1].MachineKey)
assert.NotEqual(t, listNodesAfterNewUserLogin[0].NodeKey, listNodesAfterNewUserLogin[1].NodeKey)
// Log out user2, and log into user1, no new node should be created,
// the node should now "become" node1 again
err = ts.Logout()
assertNoErr(t, err)
time.Sleep(5 * time.Second)
// TODO(kradalby): Not sure why we need to logout twice, but it fails and
// logs in immediately after the first logout and I cannot reproduce it
// manually.
err = ts.Logout()
assertNoErr(t, err)
u, err = ts.LoginWithURL(headscale.GetEndpoint())
assertNoErr(t, err)
_, err = doLoginURL(ts.Hostname(), u)
assertNoErr(t, err)
listUsers, err = headscale.ListUsers()
assertNoErr(t, err)
assert.Len(t, listUsers, 2)
wantUsers = []*v1.User{
{
Id: 1,
Name: "user1",
Email: "user1@headscale.net",
Provider: "oidc",
ProviderId: oidcConfig.Issuer + "/user1",
},
{
Id: 2,
Name: "user2",
Email: "user2@headscale.net",
Provider: "oidc",
ProviderId: oidcConfig.Issuer + "/user2",
},
}
sort.Slice(listUsers, func(i, j int) bool {
return listUsers[i].GetId() < listUsers[j].GetId()
})
if diff := cmp.Diff(wantUsers, listUsers, cmpopts.IgnoreUnexported(v1.User{}), cmpopts.IgnoreFields(v1.User{}, "CreatedAt")); diff != "" {
t.Fatalf("unexpected users: %s", diff)
}
listNodesAfterLoggingBackIn, err := headscale.ListNodes()
assertNoErr(t, err)
assert.Len(t, listNodesAfterLoggingBackIn, 2)
// Validate that the machine we had when we logged in the first time, has the same
// machine key, but a different ID than the newly logged in version of the same
// machine.
assert.Equal(t, listNodes[0].MachineKey, listNodesAfterNewUserLogin[0].MachineKey)
assert.Equal(t, listNodes[0].NodeKey, listNodesAfterNewUserLogin[0].NodeKey)
assert.Equal(t, listNodes[0].Id, listNodesAfterNewUserLogin[0].Id)
assert.Equal(t, listNodes[0].MachineKey, listNodesAfterNewUserLogin[1].MachineKey)
assert.NotEqual(t, listNodes[0].Id, listNodesAfterNewUserLogin[1].Id)
assert.NotEqual(t, listNodes[0].User.Id, listNodesAfterNewUserLogin[1].User.Id)
// Even tho we are logging in again with the same user, the previous key has been expired
// and a new one has been generated. The node entry in the database should be the same
// as the user + machinekey still matches.
assert.Equal(t, listNodes[0].MachineKey, listNodesAfterLoggingBackIn[0].MachineKey)
assert.NotEqual(t, listNodes[0].NodeKey, listNodesAfterLoggingBackIn[0].NodeKey)
assert.Equal(t, listNodes[0].Id, listNodesAfterLoggingBackIn[0].Id)
// The "logged back in" machine should have the same machinekey but a different nodekey
// than the version logged in with a different user.
assert.Equal(t, listNodesAfterLoggingBackIn[0].MachineKey, listNodesAfterLoggingBackIn[1].MachineKey)
assert.NotEqual(t, listNodesAfterLoggingBackIn[0].NodeKey, listNodesAfterLoggingBackIn[1].NodeKey)
}
func (s *AuthOIDCScenario) CreateHeadscaleEnv(
users map[string]int,
opts ...hsic.Option,
@@ -657,71 +930,42 @@ func (s *AuthOIDCScenario) runMockOIDC(accessTTL time.Duration, users []mockoidc
}, nil
}
type LoggingRoundTripper struct{}
func (t LoggingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
noTls := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // nolint
}
resp, err := noTls.RoundTrip(req)
if err != nil {
return nil, err
}
log.Printf("---")
log.Printf("method: %s | url: %s", resp.Request.Method, resp.Request.URL.String())
log.Printf("status: %d | cookies: %+v", resp.StatusCode, resp.Cookies())
return resp, nil
}
func (s *AuthOIDCScenario) runTailscaleUp(
userStr, loginServer string,
) error {
headscale, err := s.Headscale()
if err != nil {
return err
}
log.Printf("running tailscale up for user %s", userStr)
if user, ok := s.users[userStr]; ok {
for _, client := range user.Clients {
c := client
tsc := client
user.joinWaitGroup.Go(func() error {
loginURL, err := c.LoginWithURL(loginServer)
loginURL, err := tsc.LoginWithURL(loginServer)
if err != nil {
log.Printf("%s failed to run tailscale up: %s", c.Hostname(), err)
log.Printf("%s failed to run tailscale up: %s", tsc.Hostname(), err)
}
loginURL.Host = fmt.Sprintf("%s:8080", headscale.GetIP())
loginURL.Scheme = "http"
if len(headscale.GetCert()) > 0 {
loginURL.Scheme = "https"
}
insecureTransport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // nolint
}
log.Printf("%s login url: %s\n", c.Hostname(), loginURL.String())
log.Printf("%s logging in with url", c.Hostname())
httpClient := &http.Client{Transport: insecureTransport}
ctx := context.Background()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, loginURL.String(), nil)
resp, err := httpClient.Do(req)
_, err = doLoginURL(tsc.Hostname(), loginURL)
if err != nil {
log.Printf(
"%s failed to login using url %s: %s",
c.Hostname(),
loginURL,
err,
)
return err
}
if resp.StatusCode != http.StatusOK {
log.Printf("%s response code of oidc login request was %s", c.Hostname(), resp.Status)
body, _ := io.ReadAll(resp.Body)
log.Printf("body: %s", body)
return errStatusCodeNotOK
}
defer resp.Body.Close()
_, err = io.ReadAll(resp.Body)
if err != nil {
log.Printf("%s failed to read response body: %s", c.Hostname(), err)
return err
}
log.Printf("Finished request for %s to join tailnet", c.Hostname())
return nil
})
@@ -749,6 +993,49 @@ func (s *AuthOIDCScenario) runTailscaleUp(
return fmt.Errorf("failed to up tailscale node: %w", errNoUserAvailable)
}
// doLoginURL visits the given login URL and returns the body as a
// string.
func doLoginURL(hostname string, loginURL *url.URL) (string, error) {
log.Printf("%s login url: %s\n", hostname, loginURL.String())
var err error
hc := &http.Client{
Transport: LoggingRoundTripper{},
}
hc.Jar, err = cookiejar.New(nil)
if err != nil {
return "", fmt.Errorf("%s failed to create cookiejar : %w", hostname, err)
}
log.Printf("%s logging in with url", hostname)
ctx := context.Background()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, loginURL.String(), nil)
resp, err := hc.Do(req)
if err != nil {
return "", fmt.Errorf("%s failed to send http request: %w", hostname, err)
}
log.Printf("cookies: %+v", hc.Jar.Cookies(loginURL))
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
log.Printf("body: %s", body)
return "", fmt.Errorf("%s response code of login request was %w", hostname, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Printf("%s failed to read response body: %s", hostname, err)
return "", fmt.Errorf("%s failed to read response body: %w", hostname, err)
}
return string(body), nil
}
func (s *AuthOIDCScenario) Shutdown() {
err := s.pool.Purge(s.mockOIDC)
if err != nil {

View File

@@ -1,13 +1,9 @@
package integration
import (
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/netip"
"net/url"
"strings"
@@ -15,6 +11,8 @@ import (
"github.com/juanfont/headscale/integration/hsic"
"github.com/samber/lo"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var errParseAuthPage = errors.New("failed to parse auth page")
@@ -47,7 +45,6 @@ func TestAuthWebFlowAuthenticationPingAll(t *testing.T) {
hsic.WithTestName("webauthping"),
hsic.WithEmbeddedDERPServerOnly(),
hsic.WithTLS(),
hsic.WithHostnameAsServerURL(),
)
assertNoErrHeadscaleEnv(t, err)
@@ -87,7 +84,10 @@ func TestAuthWebFlowLogoutAndRelogin(t *testing.T) {
"user2": len(MustTestVersions),
}
err = scenario.CreateHeadscaleEnv(spec, hsic.WithTestName("weblogout"))
err = scenario.CreateHeadscaleEnv(spec,
hsic.WithTestName("weblogout"),
hsic.WithTLS(),
)
assertNoErrHeadscaleEnv(t, err)
allClients, err := scenario.ListTailscaleClients()
@@ -108,6 +108,14 @@ func TestAuthWebFlowLogoutAndRelogin(t *testing.T) {
success := pingAllHelper(t, allClients, allAddrs)
t.Logf("%d successful pings out of %d", success, len(allClients)*len(allIps))
headscale, err := scenario.Headscale()
assertNoErrGetHeadscale(t, err)
listNodes, err := headscale.ListNodes()
assert.Equal(t, len(listNodes), len(allClients))
nodeCountBeforeLogout := len(listNodes)
t.Logf("node count before logout: %d", nodeCountBeforeLogout)
clientIPs := make(map[TailscaleClient][]netip.Addr)
for _, client := range allClients {
ips, err := client.IPs()
@@ -129,21 +137,15 @@ func TestAuthWebFlowLogoutAndRelogin(t *testing.T) {
t.Logf("all clients logged out")
headscale, err := scenario.Headscale()
assertNoErrGetHeadscale(t, err)
for userName := range spec {
err = scenario.runTailscaleUp(userName, headscale.GetEndpoint())
if err != nil {
t.Fatalf("failed to run tailscale up: %s", err)
t.Fatalf("failed to run tailscale up (%q): %s", headscale.GetEndpoint(), err)
}
}
t.Logf("all clients logged in again")
allClients, err = scenario.ListTailscaleClients()
assertNoErrListClients(t, err)
allIps, err = scenario.ListTailscaleClientsIPs()
assertNoErrListClientIPs(t, err)
@@ -154,6 +156,10 @@ func TestAuthWebFlowLogoutAndRelogin(t *testing.T) {
success = pingAllHelper(t, allClients, allAddrs)
t.Logf("%d successful pings out of %d", success, len(allClients)*len(allIps))
listNodes, err = headscale.ListNodes()
require.Equal(t, nodeCountBeforeLogout, len(listNodes))
t.Logf("node count first login: %d, after relogin: %d", nodeCountBeforeLogout, len(listNodes))
for _, client := range allClients {
ips, err := client.IPs()
if err != nil {
@@ -227,11 +233,12 @@ func (s *AuthWebFlowScenario) CreateHeadscaleEnv(
func (s *AuthWebFlowScenario) runTailscaleUp(
userStr, loginServer string,
) error {
log.Printf("running tailscale up for user %s", userStr)
log.Printf("running tailscale up for user %q", userStr)
if user, ok := s.users[userStr]; ok {
for _, client := range user.Clients {
c := client
user.joinWaitGroup.Go(func() error {
log.Printf("logging %q into %q", c.Hostname(), loginServer)
loginURL, err := c.LoginWithURL(loginServer)
if err != nil {
log.Printf("failed to run tailscale up (%s): %s", c.Hostname(), err)
@@ -273,39 +280,11 @@ func (s *AuthWebFlowScenario) runTailscaleUp(
}
func (s *AuthWebFlowScenario) runHeadscaleRegister(userStr string, loginURL *url.URL) error {
headscale, err := s.Headscale()
body, err := doLoginURL("web-auth-not-set", loginURL)
if err != nil {
return err
}
log.Printf("loginURL: %s", loginURL)
loginURL.Host = fmt.Sprintf("%s:8080", headscale.GetIP())
loginURL.Scheme = "http"
if len(headscale.GetCert()) > 0 {
loginURL.Scheme = "https"
}
insecureTransport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // nolint
}
httpClient := &http.Client{
Transport: insecureTransport,
}
ctx := context.Background()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, loginURL.String(), nil)
resp, err := httpClient.Do(req)
if err != nil {
return err
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
defer resp.Body.Close()
// see api.go HTML template
codeSep := strings.Split(string(body), "</code>")
if len(codeSep) != 2 {

View File

@@ -12,6 +12,7 @@ import (
"github.com/google/go-cmp/cmp/cmpopts"
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
"github.com/juanfont/headscale/hscontrol/policy"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/integration/hsic"
"github.com/juanfont/headscale/integration/tsic"
"github.com/stretchr/testify/assert"
@@ -135,8 +136,9 @@ func TestUserCommand(t *testing.T) {
slices.SortFunc(listByUsername, sortWithID)
want := []*v1.User{
{
Id: 1,
Name: "user1",
Id: 1,
Name: "user1",
Email: "user1@test.no",
},
}
@@ -161,8 +163,9 @@ func TestUserCommand(t *testing.T) {
slices.SortFunc(listByID, sortWithID)
want = []*v1.User{
{
Id: 1,
Name: "user1",
Id: 1,
Name: "user1",
Email: "user1@test.no",
},
}
@@ -199,8 +202,9 @@ func TestUserCommand(t *testing.T) {
slices.SortFunc(listAfterIDDelete, sortWithID)
want = []*v1.User{
{
Id: 2,
Name: "newname",
Id: 2,
Name: "newname",
Email: "user2@test.no",
},
}
@@ -541,7 +545,6 @@ func TestPreAuthKeyCorrectUserLoggedInCommand(t *testing.T) {
hsic.WithTestName("clipak"),
hsic.WithEmbeddedDERPServerOnly(),
hsic.WithTLS(),
hsic.WithHostnameAsServerURL(),
)
assertNoErr(t, err)
@@ -603,22 +606,12 @@ func TestPreAuthKeyCorrectUserLoggedInCommand(t *testing.T) {
t.Fatalf("expected node to be logged in as userid:2, got: %s", status.Self.UserID.String())
}
var listNodes []v1.Node
err = executeAndUnmarshal(
headscale,
[]string{
"headscale",
"nodes",
"list",
"--output",
"json",
},
&listNodes,
)
listNodes, err := headscale.ListNodes()
assert.Nil(t, err)
assert.Len(t, listNodes, 1)
assert.Len(t, listNodes, 2)
assert.Equal(t, "user2", listNodes[0].GetUser().GetName())
assert.Equal(t, "user1", listNodes[0].GetUser().GetName())
assert.Equal(t, "user2", listNodes[1].GetUser().GetName())
}
func TestApiKeyCommand(t *testing.T) {
@@ -809,14 +802,14 @@ func TestNodeTagCommand(t *testing.T) {
headscale, err := scenario.Headscale()
assertNoErr(t, err)
machineKeys := []string{
"mkey:9b2ffa7e08cc421a3d2cca9012280f6a236fd0de0b4ce005b30a98ad930306fe",
"mkey:6abd00bb5fdda622db51387088c68e97e71ce58e7056aa54f592b6a8219d524c",
regIDs := []string{
types.MustRegistrationID().String(),
types.MustRegistrationID().String(),
}
nodes := make([]*v1.Node, len(machineKeys))
nodes := make([]*v1.Node, len(regIDs))
assert.Nil(t, err)
for index, machineKey := range machineKeys {
for index, regID := range regIDs {
_, err := headscale.Execute(
[]string{
"headscale",
@@ -827,7 +820,7 @@ func TestNodeTagCommand(t *testing.T) {
"--user",
"user1",
"--key",
machineKey,
regID,
"--output",
"json",
},
@@ -844,7 +837,7 @@ func TestNodeTagCommand(t *testing.T) {
"user1",
"register",
"--key",
machineKey,
regID,
"--output",
"json",
},
@@ -854,7 +847,7 @@ func TestNodeTagCommand(t *testing.T) {
nodes[index] = &node
}
assert.Len(t, nodes, len(machineKeys))
assert.Len(t, nodes, len(regIDs))
var node v1.Node
err = executeAndUnmarshal(
@@ -886,7 +879,7 @@ func TestNodeTagCommand(t *testing.T) {
assert.ErrorContains(t, err, "tag must start with the string 'tag:'")
// Test list all nodes after added seconds
resultMachines := make([]*v1.Node, len(machineKeys))
resultMachines := make([]*v1.Node, len(regIDs))
err = executeAndUnmarshal(
headscale,
[]string{
@@ -930,7 +923,23 @@ func TestNodeAdvertiseTagCommand(t *testing.T) {
wantTag: false,
},
{
name: "with-policy",
name: "with-policy-email",
policy: &policy.ACLPolicy{
ACLs: []policy.ACL{
{
Action: "accept",
Sources: []string{"*"},
Destinations: []string{"*:*"},
},
},
TagOwners: map[string][]string{
"tag:test": {"user1@test.no"},
},
},
wantTag: true,
},
{
name: "with-policy-username",
policy: &policy.ACLPolicy{
ACLs: []policy.ACL{
{
@@ -945,13 +954,32 @@ func TestNodeAdvertiseTagCommand(t *testing.T) {
},
wantTag: true,
},
{
name: "with-policy-groups",
policy: &policy.ACLPolicy{
Groups: policy.Groups{
"group:admins": []string{"user1"},
},
ACLs: []policy.ACL{
{
Action: "accept",
Sources: []string{"*"},
Destinations: []string{"*:*"},
},
},
TagOwners: map[string][]string{
"tag:test": {"group:admins"},
},
},
wantTag: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
scenario, err := NewScenario(dockertestMaxWait())
assertNoErr(t, err)
// defer scenario.ShutdownAssertNoPanics(t)
defer scenario.ShutdownAssertNoPanics(t)
spec := map[string]int{
"user1": 1,
@@ -1016,18 +1044,17 @@ func TestNodeCommand(t *testing.T) {
headscale, err := scenario.Headscale()
assertNoErr(t, err)
// Pregenerated machine keys
machineKeys := []string{
"mkey:9b2ffa7e08cc421a3d2cca9012280f6a236fd0de0b4ce005b30a98ad930306fe",
"mkey:6abd00bb5fdda622db51387088c68e97e71ce58e7056aa54f592b6a8219d524c",
"mkey:f08305b4ee4250b95a70f3b7504d048d75d899993c624a26d422c67af0422507",
"mkey:8bc13285cee598acf76b1824a6f4490f7f2e3751b201e28aeb3b07fe81d5b4a1",
"mkey:cf7b0fd05da556fdc3bab365787b506fd82d64a70745db70e00e86c1b1c03084",
regIDs := []string{
types.MustRegistrationID().String(),
types.MustRegistrationID().String(),
types.MustRegistrationID().String(),
types.MustRegistrationID().String(),
types.MustRegistrationID().String(),
}
nodes := make([]*v1.Node, len(machineKeys))
nodes := make([]*v1.Node, len(regIDs))
assert.Nil(t, err)
for index, machineKey := range machineKeys {
for index, regID := range regIDs {
_, err := headscale.Execute(
[]string{
"headscale",
@@ -1038,7 +1065,7 @@ func TestNodeCommand(t *testing.T) {
"--user",
"node-user",
"--key",
machineKey,
regID,
"--output",
"json",
},
@@ -1055,7 +1082,7 @@ func TestNodeCommand(t *testing.T) {
"node-user",
"register",
"--key",
machineKey,
regID,
"--output",
"json",
},
@@ -1066,7 +1093,7 @@ func TestNodeCommand(t *testing.T) {
nodes[index] = &node
}
assert.Len(t, nodes, len(machineKeys))
assert.Len(t, nodes, len(regIDs))
// Test list all nodes after added seconds
var listAll []v1.Node
@@ -1097,14 +1124,14 @@ func TestNodeCommand(t *testing.T) {
assert.Equal(t, "node-4", listAll[3].GetName())
assert.Equal(t, "node-5", listAll[4].GetName())
otherUserMachineKeys := []string{
"mkey:b5b444774186d4217adcec407563a1223929465ee2c68a4da13af0d0185b4f8e",
"mkey:dc721977ac7415aafa87f7d4574cbe07c6b171834a6d37375782bdc1fb6b3584",
otherUserRegIDs := []string{
types.MustRegistrationID().String(),
types.MustRegistrationID().String(),
}
otherUserMachines := make([]*v1.Node, len(otherUserMachineKeys))
otherUserMachines := make([]*v1.Node, len(otherUserRegIDs))
assert.Nil(t, err)
for index, machineKey := range otherUserMachineKeys {
for index, regID := range otherUserRegIDs {
_, err := headscale.Execute(
[]string{
"headscale",
@@ -1115,7 +1142,7 @@ func TestNodeCommand(t *testing.T) {
"--user",
"other-user",
"--key",
machineKey,
regID,
"--output",
"json",
},
@@ -1132,7 +1159,7 @@ func TestNodeCommand(t *testing.T) {
"other-user",
"register",
"--key",
machineKey,
regID,
"--output",
"json",
},
@@ -1143,7 +1170,7 @@ func TestNodeCommand(t *testing.T) {
otherUserMachines[index] = &node
}
assert.Len(t, otherUserMachines, len(otherUserMachineKeys))
assert.Len(t, otherUserMachines, len(otherUserRegIDs))
// Test list all nodes after added otherUser
var listAllWithotherUser []v1.Node
@@ -1256,17 +1283,16 @@ func TestNodeExpireCommand(t *testing.T) {
headscale, err := scenario.Headscale()
assertNoErr(t, err)
// Pregenerated machine keys
machineKeys := []string{
"mkey:9b2ffa7e08cc421a3d2cca9012280f6a236fd0de0b4ce005b30a98ad930306fe",
"mkey:6abd00bb5fdda622db51387088c68e97e71ce58e7056aa54f592b6a8219d524c",
"mkey:f08305b4ee4250b95a70f3b7504d048d75d899993c624a26d422c67af0422507",
"mkey:8bc13285cee598acf76b1824a6f4490f7f2e3751b201e28aeb3b07fe81d5b4a1",
"mkey:cf7b0fd05da556fdc3bab365787b506fd82d64a70745db70e00e86c1b1c03084",
regIDs := []string{
types.MustRegistrationID().String(),
types.MustRegistrationID().String(),
types.MustRegistrationID().String(),
types.MustRegistrationID().String(),
types.MustRegistrationID().String(),
}
nodes := make([]*v1.Node, len(machineKeys))
nodes := make([]*v1.Node, len(regIDs))
for index, machineKey := range machineKeys {
for index, regID := range regIDs {
_, err := headscale.Execute(
[]string{
"headscale",
@@ -1277,7 +1303,7 @@ func TestNodeExpireCommand(t *testing.T) {
"--user",
"node-expire-user",
"--key",
machineKey,
regID,
"--output",
"json",
},
@@ -1294,7 +1320,7 @@ func TestNodeExpireCommand(t *testing.T) {
"node-expire-user",
"register",
"--key",
machineKey,
regID,
"--output",
"json",
},
@@ -1305,7 +1331,7 @@ func TestNodeExpireCommand(t *testing.T) {
nodes[index] = &node
}
assert.Len(t, nodes, len(machineKeys))
assert.Len(t, nodes, len(regIDs))
var listAll []v1.Node
err = executeAndUnmarshal(
@@ -1383,18 +1409,17 @@ func TestNodeRenameCommand(t *testing.T) {
headscale, err := scenario.Headscale()
assertNoErr(t, err)
// Pregenerated machine keys
machineKeys := []string{
"mkey:cf7b0fd05da556fdc3bab365787b506fd82d64a70745db70e00e86c1b1c03084",
"mkey:8bc13285cee598acf76b1824a6f4490f7f2e3751b201e28aeb3b07fe81d5b4a1",
"mkey:f08305b4ee4250b95a70f3b7504d048d75d899993c624a26d422c67af0422507",
"mkey:6abd00bb5fdda622db51387088c68e97e71ce58e7056aa54f592b6a8219d524c",
"mkey:9b2ffa7e08cc421a3d2cca9012280f6a236fd0de0b4ce005b30a98ad930306fe",
regIDs := []string{
types.MustRegistrationID().String(),
types.MustRegistrationID().String(),
types.MustRegistrationID().String(),
types.MustRegistrationID().String(),
types.MustRegistrationID().String(),
}
nodes := make([]*v1.Node, len(machineKeys))
nodes := make([]*v1.Node, len(regIDs))
assert.Nil(t, err)
for index, machineKey := range machineKeys {
for index, regID := range regIDs {
_, err := headscale.Execute(
[]string{
"headscale",
@@ -1405,7 +1430,7 @@ func TestNodeRenameCommand(t *testing.T) {
"--user",
"node-rename-command",
"--key",
machineKey,
regID,
"--output",
"json",
},
@@ -1422,7 +1447,7 @@ func TestNodeRenameCommand(t *testing.T) {
"node-rename-command",
"register",
"--key",
machineKey,
regID,
"--output",
"json",
},
@@ -1433,7 +1458,7 @@ func TestNodeRenameCommand(t *testing.T) {
nodes[index] = &node
}
assert.Len(t, nodes, len(machineKeys))
assert.Len(t, nodes, len(regIDs))
var listAll []v1.Node
err = executeAndUnmarshal(
@@ -1551,7 +1576,7 @@ func TestNodeMoveCommand(t *testing.T) {
assertNoErr(t, err)
// Randomly generated node key
machineKey := "mkey:688411b767663479632d44140f08a9fde87383adc7cdeb518f62ce28a17ef0aa"
regID := types.MustRegistrationID()
_, err = headscale.Execute(
[]string{
@@ -1563,7 +1588,7 @@ func TestNodeMoveCommand(t *testing.T) {
"--user",
"old-user",
"--key",
machineKey,
regID.String(),
"--output",
"json",
},
@@ -1580,7 +1605,7 @@ func TestNodeMoveCommand(t *testing.T) {
"old-user",
"register",
"--key",
machineKey,
regID.String(),
"--output",
"json",
},
@@ -1802,7 +1827,7 @@ func TestPolicyBrokenConfigCommand(t *testing.T) {
{
// This is an unknown action, so it will return an error
// and the config will not be applied.
Action: "acccept",
Action: "unknown-action",
Sources: []string{"*"},
Destinations: []string{"*:*"},
},

View File

@@ -17,7 +17,8 @@ type ControlServer interface {
WaitForRunning() error
CreateUser(user string) error
CreateAuthKey(user string, reusable bool, ephemeral bool) (*v1.PreAuthKey, error)
ListNodesInUser(user string) ([]*v1.Node, error)
ListNodes(users ...string) ([]*v1.Node, error)
ListUsers() ([]*v1.User, error)
GetCert() []byte
GetHostname() string
GetIP() string

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