diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml
index 3ea343f8a5..94b285e79a 100644
--- a/.github/workflows/docs-deploy.yml
+++ b/.github/workflows/docs-deploy.yml
@@ -12,8 +12,8 @@ on:
# Stable release tags
- v[0-9]+.[0-9]+.[0-9]+
paths:
- - 'docs/**'
- - 'mkdocs.yml'
+ - "docs/**"
+ - "mkdocs.yml"
workflow_dispatch:
jobs:
diff --git a/.github/workflows/test-integration.yaml b/.github/workflows/test-integration.yaml
index dbd3cb9779..83db1c33b0 100644
--- a/.github/workflows/test-integration.yaml
+++ b/.github/workflows/test-integration.yaml
@@ -25,6 +25,7 @@ jobs:
- TestOIDCAuthenticationPingAll
- TestOIDCExpireNodesBasedOnTokenExpiry
- TestOIDC024UserCreation
+ - TestOIDCAuthenticationWithPKCE
- TestAuthWebFlowAuthenticationPingAll
- TestAuthWebFlowLogoutAndRelogin
- TestUserCommand
@@ -43,6 +44,7 @@ jobs:
- TestPolicyBrokenConfigCommand
- TestDERPVerifyEndpoint
- TestResolveMagicDNS
+ - TestResolveMagicDNSExtraRecordsPath
- TestValidateResolvConf
- TestDERPServerScenario
- TestDERPServerWebsocketScenario
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index f4659332e3..610c60f668 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -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
diff --git a/.goreleaser.yml b/.goreleaser.yml
index 51f8000f9b..400cd12f46 100644
--- a/.goreleaser.yml
+++ b/.goreleaser.yml
@@ -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:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c217355920..5a56a1361e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,66 +2,155 @@
## Next
+### 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)
+
+## 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
-_Headscale v0.23.0 and earlier_ identified OIDC users by the "username" part of their email address (when `strip_email_domain: true`, the default) or whole email address (when `strip_email_domain: false`).
+The following issue _only_ affects Headscale installations which authenticate
+with OIDC.
-Depending on how Headscale and your Identity Provider (IdP) were configured, only using the `email` claim could allow a malicious user with an IdP account to take over another Headscale user's account, even when `strip_email_domain: false`.
+_Headscale v0.23.0 and earlier_ identified OIDC users by the "username" part of
+their email address (when `strip_email_domain: true`, the default) or whole
+email address (when `strip_email_domain: false`).
-This would also cause a user to lose access to their Headscale account if they changed their email address.
+Depending on how Headscale and your Identity Provider (IdP) were configured,
+only using the `email` claim could allow a malicious user with an IdP account to
+take over another Headscale user's account, even when
+`strip_email_domain: false`.
-_Headscale v0.24.0_ now identifies OIDC users by the `iss` and `sub` claims. [These are guaranteed by the OIDC specification to be stable and unique](https://openid.net/specs/openid-connect-core-1_0.html#ClaimStability), even if a user changes email address. A well-designed IdP will typically set `sub` to an opaque identifier like a UUID or numeric ID, which has no relation to the user's name or email address.
+This would also cause a user to lose access to their Headscale account if they
+changed their email address.
-This issue _only_ affects Headscale installations which authenticate with OIDC.
+_Headscale v0.24.0_ now identifies OIDC users by the `iss` and `sub` claims.
+[These are guaranteed by the OIDC specification to be stable and unique](https://openid.net/specs/openid-connect-core-1_0.html#ClaimStability),
+even if a user changes email address. A well-designed IdP will typically set
+`sub` to an opaque identifier like a UUID or numeric ID, which has no relation
+to the user's name or email address.
-Headscale v0.24.0 and later will also automatically update profile fields with OIDC data on login. This means that users can change those details in your IdP, 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.24.0 and later will also automatically update profile fields with
+OIDC data on login. This means that users can change those details in your IdP,
+and have it populate to Headscale automatically the next time they log in.
+However, this may affect the way you reference users in policies.
-#### Migrating existing installations
+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.
-Headscale v0.23.0 and earlier never recorded the `iss` and `sub` fields, so all legacy (existing) OIDC accounts from _need to be migrated_ to be properly secured.
+#### What do I need to do to migrate?
-Headscale v0.24.0 has an automatic migration feature, which is enabled by default (`map_legacy_users: true`). **This will be disabled by default in a future version of Headscale – any unmigrated users will get new accounts.**
+Headscale v0.24.0 has an automatic migration feature, which is enabled by
+default (`map_legacy_users: true`). **This will be disabled by default in a
+future version of Headscale – any unmigrated users will get new accounts.**
-Headscale v0.24.0 will ignore any `email` claim if the IdP does not provide an `email_verified` claim set to `true`. [What "verified" actually means is contextually dependent](https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims) – Headscale uses it as a signal that the contents of the `email` claim is reasonably trustworthy.
+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.
-Headscale v0.23.0 and earlier never checked the `email_verified` claim. This means even if an IdP explicitly indicated to Headscale that its `email` claim was untrustworthy, Headscale would have still accepted it.
+After this exception is ensured, make all users log into Headscale with their
+account, and Headscale will automatically update the account record. This will
+be transparent to the users.
+
+When all users have logged in, you can disable the automatic migration by
+setting `map_legacy_users: false` in your configuration file.
+
+Please note that `map_legacy_users` will be set to `false` by default in v0.25.0
+and the migration mechanism will be removed in v0.26.0.
+
+
+
+What does automatic migration do?
##### What does automatic migration do?
-When automatic migration is enabled (`map_legacy_users: true`), Headscale will first match an OIDC account to a Headscale account by `iss` and `sub`, and then fall back to matching OIDC users similarly to how Headscale v0.23.0 did:
+When automatic migration is enabled (`map_legacy_users: true`), Headscale will
+first match an OIDC account to a Headscale account by `iss` and `sub`, and then
+fall back to matching OIDC users similarly to how Headscale v0.23.0 did:
-- If `strip_email_domain: true` (the default): the Headscale username matches the "username" part of their email address.
-- If `strip_email_domain: false`: the Headscale username matches the _whole_ email address.
+- If `strip_email_domain: true` (the default): the Headscale username matches
+ the "username" part of their email address.
+- If `strip_email_domain: false`: the Headscale username matches the _whole_
+ email address.
-On migration, Headscale will change the account's username to their `preferred_username`. **This could break any ACLs or policies which are configured to match by username.**
+On migration, Headscale will change the account's username to their
+`preferred_username`. **This could break any ACLs or policies which are
+configured to match by username.**
-Like with Headscale v0.23.0 and earlier, this migration only works for users who haven't changed their email address since their last Headscale login.
+Like with Headscale v0.23.0 and earlier, this migration only works for users who
+haven't changed their email address since their last Headscale login.
A _successful_ automated migration should otherwise be transparent to users.
-Once a Headscale account has been migrated, it will be _unavailable_ to be matched by the legacy process. An OIDC login with a matching username, but _non-matching_ `iss` and `sub` will instead get a _new_ Headscale account.
+Once a Headscale account has been migrated, it will be _unavailable_ to be
+matched by the legacy process. An OIDC login with a matching username, but
+_non-matching_ `iss` and `sub` will instead get a _new_ Headscale account.
+
+Because of the way OIDC works, Headscale's automated migration process can
+_only_ work when a user tries to log in after the update.
+
+Legacy account migration should have no effect on new installations where all
+users have a recorded `sub` and `iss`.
-Because of the way OIDC works, Headscale's automated migration process can _only_ work when a user tries to log in after the update. Mass updates would require Headscale implement a protocol like SCIM, which is **extremely** complicated and not available in all identity providers.
+
-Administrators could also attempt to migrate users manually by editing the database, using their own mapping rules with known-good data sources.
+
-Legacy account migration should have no effect on new installations where all users have a recorded `sub` and `iss`.
+What happens when automatic migration is disabled?
##### What happens when automatic migration is disabled?
-When automatic migration is disabled (`map_legacy_users: false`), Headscale will only try to match an OIDC account to a Headscale account by `iss` and `sub`.
+When automatic migration is disabled (`map_legacy_users: false`), Headscale will
+only try to match an OIDC account to a Headscale account by `iss` and `sub`.
-If there is no match, it will get a _new_ Headscale account – even if there was a legacy account which _could_ have matched and migrated.
+If there is no match, it will get a _new_ Headscale account – even if there was
+a legacy account which _could_ have matched and migrated.
-We recommend new Headscale users explicitly disable automatic migration – but it should otherwise have no effect if every account has a recorded `iss` and `sub`.
+We recommend new Headscale users explicitly disable automatic migration – but it
+should otherwise have no effect if every account has a recorded `iss` and `sub`.
-When automatic migration is disabled, the `strip_email_domain` setting will have no effect.
+When automatic migration is disabled, the `strip_email_domain` setting will have
+no effect.
-Special thanks to @micolous for reviewing, proposing and working with us on these changes.
+
+
+Special thanks to @micolous for reviewing, proposing and working with us on
+these changes.
#### Other OIDC changes
-Headscale now uses [the standard OIDC claims](https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims) to populate and update user information every time they log in:
+Headscale now uses
+[the standard OIDC claims](https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims)
+to populate and update user information every time they log in:
| Headscale profile field | OIDC claim | Notes / examples |
| ----------------------- | -------------------- | --------------------------------------------------------------------------------------------------------- |
@@ -72,262 +161,442 @@ Headscale now uses [the standard OIDC claims](https://openid.net/specs/openid-co
These should show up nicely in the Tailscale client.
-This will also affect the way you [reference users in policies](https://github.com/juanfont/headscale/pull/2205).
+This will also affect the way you
+[reference users in policies](https://github.com/juanfont/headscale/pull/2205).
### BREAKING
-- Remove `dns.use_username_in_magic_dns` configuration option [#2020](https://github.com/juanfont/headscale/pull/2020), [#2279](https://github.com/juanfont/headscale/pull/2279)
+- Remove `dns.use_username_in_magic_dns` configuration option
+ [#2020](https://github.com/juanfont/headscale/pull/2020),
+ [#2279](https://github.com/juanfont/headscale/pull/2279)
- Having usernames in magic DNS is no longer possible.
-- Remove versions older than 1.56 [#2149](https://github.com/juanfont/headscale/pull/2149)
+- Remove versions older than 1.56
+ [#2149](https://github.com/juanfont/headscale/pull/2149)
- Clean up old code required by old versions
- 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 and ID parameter
- - `RenameUser` and `DeleteUser` now requires and ID instead of a name.
+ - 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
+ - `RenameUser` and `DeleteUser` now require an ID instead of a name.
### Changes
-- Improved compatibilty 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)
-- Fixed processing of fields in post request in MoveNode rpc [#2179](https://github.com/juanfont/headscale/pull/2179)
-- Added conversion of 'Hostname' to 'givenName' in a node with FQDN rules applied [#2198](https://github.com/juanfont/headscale/pull/2198)
-- 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)
-- 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)
+- 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)
+- Fixed processing of fields in post request in MoveNode rpc
+ [#2179](https://github.com/juanfont/headscale/pull/2179)
+- Added conversion of 'Hostname' to 'givenName' in a node with FQDN rules
+ applied [#2198](https://github.com/juanfont/headscale/pull/2198)
+- 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/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)
+- 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)
-This release was intended to be mainly a code reorganisation and refactoring, significantly improving the maintainability of the codebase. This should allow us to improve further and make it easier for the maintainers to keep on top of the project.
-However, as you all have noticed, it turned out to become a much larger, much longer release cycle than anticipated. It has ended up to be a release with a lot of rewrites and changes to the code base and functionality of Headscale, cleaning up a lot of technical debt and introducing a lot of improvements. This does come with some breaking changes,
+This release was intended to be mainly a code reorganisation and refactoring,
+significantly improving the maintainability of the codebase. This should allow
+us to improve further and make it easier for the maintainers to keep on top of
+the project. However, as you all have noticed, it turned out to become a much
+larger, much longer release cycle than anticipated. It has ended up to be a
+release with a lot of rewrites and changes to the code base and functionality of
+Headscale, cleaning up a lot of technical debt and introducing a lot of
+improvements. This does come with some breaking changes,
**Please remember to always back up your database between versions**
#### Here is a short summary of the broad topics of changes:
-Code has been organised into modules, reducing use of global variables/objects, isolating concerns and “putting the right things in the logical place”.
-
-The new [policy](https://github.com/juanfont/headscale/tree/main/hscontrol/policy) and [mapper](https://github.com/juanfont/headscale/tree/main/hscontrol/mapper) package, containing the ACL/Policy logic and the logic for creating the data served to clients (the network “map”) has been rewritten and improved. This change has allowed us to finish SSH support and add additional tests throughout the code to ensure correctness.
-
-The [“poller”, or streaming logic](https://github.com/juanfont/headscale/blob/main/hscontrol/poll.go) has been rewritten and instead of keeping track of the latest updates, checking at a fixed interval, it now uses go channels, implemented in our new [notifier](https://github.com/juanfont/headscale/tree/main/hscontrol/notifier) package and it allows us to send updates to connected clients immediately. This should both improve performance and potential latency before a client picks up an update.
-
-Headscale now supports sending “delta” updates, thanks to the new mapper and poller logic, allowing us to only inform nodes about new nodes, changed nodes and removed nodes. Previously we sent the entire state of the network every time an update was due.
-
-While we have a pretty good [test harness](https://github.com/search?q=repo%3Ajuanfont%2Fheadscale+path%3A_test.go&type=code) for validating our changes, the changes came down to [284 changed files with 32,316 additions and 24,245 deletions](https://github.com/juanfont/headscale/compare/b01f1f1867136d9b2d7b1392776eb363b482c525...ed78ecd) and bugs are expected. We need help testing this release. In addition, while we think the performance should in general be better, there might be regressions in parts of the platform, particularly where we prioritised correctness over speed.
-
-There are also several bugfixes that has been encountered and fixed as part of implementing these changes, particularly
-after improving the test harness as part of adopting [#1460](https://github.com/juanfont/headscale/pull/1460).
+Code has been organised into modules, reducing use of global variables/objects,
+isolating concerns and “putting the right things in the logical place”.
+
+The new
+[policy](https://github.com/juanfont/headscale/tree/main/hscontrol/policy) and
+[mapper](https://github.com/juanfont/headscale/tree/main/hscontrol/mapper)
+package, containing the ACL/Policy logic and the logic for creating the data
+served to clients (the network “map”) has been rewritten and improved. This
+change has allowed us to finish SSH support and add additional tests throughout
+the code to ensure correctness.
+
+The
+[“poller”, or streaming logic](https://github.com/juanfont/headscale/blob/main/hscontrol/poll.go)
+has been rewritten and instead of keeping track of the latest updates, checking
+at a fixed interval, it now uses go channels, implemented in our new
+[notifier](https://github.com/juanfont/headscale/tree/main/hscontrol/notifier)
+package and it allows us to send updates to connected clients immediately. This
+should both improve performance and potential latency before a client picks up
+an update.
+
+Headscale now supports sending “delta” updates, thanks to the new mapper and
+poller logic, allowing us to only inform nodes about new nodes, changed nodes
+and removed nodes. Previously we sent the entire state of the network every time
+an update was due.
+
+While we have a pretty good
+[test harness](https://github.com/search?q=repo%3Ajuanfont%2Fheadscale+path%3A_test.go&type=code)
+for validating our changes, the changes came down to
+[284 changed files with 32,316 additions and 24,245 deletions](https://github.com/juanfont/headscale/compare/b01f1f1867136d9b2d7b1392776eb363b482c525...ed78ecd)
+and bugs are expected. We need help testing this release. In addition, while we
+think the performance should in general be better, there might be regressions in
+parts of the platform, particularly where we prioritised correctness over speed.
+
+There are also several bugfixes that has been encountered and fixed as part of
+implementing these changes, particularly after improving the test harness as
+part of adopting [#1460](https://github.com/juanfont/headscale/pull/1460).
### BREAKING
-- Code reorganisation, a lot of code has moved, please review the following PRs accordingly [#1473](https://github.com/juanfont/headscale/pull/1473)
-- Change the structure of database configuration, see [config-example.yaml](./config-example.yaml) for the new structure. [#1700](https://github.com/juanfont/headscale/pull/1700)
+- Code reorganisation, a lot of code has moved, please review the following PRs
+ accordingly [#1473](https://github.com/juanfont/headscale/pull/1473)
+- Change the structure of database configuration, see
+ [config-example.yaml](./config-example.yaml) for the new structure.
+ [#1700](https://github.com/juanfont/headscale/pull/1700)
- Old structure has been remove and the configuration _must_ be converted.
- - Adds additional configuration for PostgreSQL for setting max open, idle connection and idle connection lifetime.
-- API: Machine is now Node [#1553](https://github.com/juanfont/headscale/pull/1553)
-- Remove support for older Tailscale clients [#1611](https://github.com/juanfont/headscale/pull/1611)
+ - Adds additional configuration for PostgreSQL for setting max open, idle
+ connection and idle connection lifetime.
+- API: Machine is now Node
+ [#1553](https://github.com/juanfont/headscale/pull/1553)
+- Remove support for older Tailscale clients
+ [#1611](https://github.com/juanfont/headscale/pull/1611)
- The oldest supported client is 1.42
-- Headscale checks that _at least_ one DERP is defined at start [#1564](https://github.com/juanfont/headscale/pull/1564)
- - If no DERP is configured, the server will fail to start, this can be because it cannot load the DERPMap from file or url.
-- Embedded DERP server requires a private key [#1611](https://github.com/juanfont/headscale/pull/1611)
- - Add a filepath entry to [`derp.server.private_key_path`](https://github.com/juanfont/headscale/blob/b35993981297e18393706b2c963d6db882bba6aa/config-example.yaml#L95)
-- Docker images are now built with goreleaser (ko) [#1716](https://github.com/juanfont/headscale/pull/1716) [#1763](https://github.com/juanfont/headscale/pull/1763)
- - 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)
-- Prefixes are now defined per v4 and v6 range. [#1756](https://github.com/juanfont/headscale/pull/1756)
+- Headscale checks that _at least_ one DERP is defined at start
+ [#1564](https://github.com/juanfont/headscale/pull/1564)
+ - If no DERP is configured, the server will fail to start, this can be because
+ it cannot load the DERPMap from file or url.
+- Embedded DERP server requires a private key
+ [#1611](https://github.com/juanfont/headscale/pull/1611)
+ - Add a filepath entry to
+ [`derp.server.private_key_path`](https://github.com/juanfont/headscale/blob/b35993981297e18393706b2c963d6db882bba6aa/config-example.yaml#L95)
+- Docker images are now built with goreleaser (ko)
+ [#1716](https://github.com/juanfont/headscale/pull/1716)
+ [#1763](https://github.com/juanfont/headscale/pull/1763)
+ - 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/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)
+ - `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 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 [#1369](https://github.com/juanfont/headscale/issues/1369) for more information.
- - `use_username_in_magic_dns` can be used to turn this behaviour on again, but note that this option _will be removed_ when tags are fixed.
+ - 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
+ [#1369](https://github.com/juanfont/headscale/issues/1369) for more
+ information.
+ - `use_username_in_magic_dns` can be used to turn this behaviour on again, but
+ note that this option _will be removed_ when tags are fixed.
- dns.base_domain can no longer be the same as (or part of) server_url.
- This option brings Headscales behaviour in line with Tailscale.
-- YAML files are no longer supported for headscale policy. [#1792](https://github.com/juanfont/headscale/pull/1792)
+- YAML files are no longer supported for headscale policy.
+ [#1792](https://github.com/juanfont/headscale/pull/1792)
- HuJSON is now the only supported format for policy.
-- DNS configuration has been restructured [#2034](https://github.com/juanfont/headscale/pull/2034)
- - Please review the new [config-example.yaml](./config-example.yaml) for the new structure.
+- DNS configuration has been restructured
+ [#2034](https://github.com/juanfont/headscale/pull/2034)
+ - Please review the new [config-example.yaml](./config-example.yaml) for the
+ new structure.
### Changes
-- Use versioned migrations [#1644](https://github.com/juanfont/headscale/pull/1644)
-- Make the OIDC callback page better [#1484](https://github.com/juanfont/headscale/pull/1484)
+- Use versioned migrations
+ [#1644](https://github.com/juanfont/headscale/pull/1644)
+- Make the OIDC callback page better
+ [#1484](https://github.com/juanfont/headscale/pull/1484)
- SSH support [#1487](https://github.com/juanfont/headscale/pull/1487)
-- State management has been improved [#1492](https://github.com/juanfont/headscale/pull/1492)
-- Use error group handling to ensure tests actually pass [#1535](https://github.com/juanfont/headscale/pull/1535) based on [#1460](https://github.com/juanfont/headscale/pull/1460)
-- Fix hang on SIGTERM [#1492](https://github.com/juanfont/headscale/pull/1492) taken from [#1480](https://github.com/juanfont/headscale/pull/1480)
-- Send logs to stderr by default [#1524](https://github.com/juanfont/headscale/pull/1524)
-- Fix [TS-2023-006](https://tailscale.com/security-bulletins/#ts-2023-006) security UPnP issue [#1563](https://github.com/juanfont/headscale/pull/1563)
-- Turn off gRPC logging [#1640](https://github.com/juanfont/headscale/pull/1640) fixes [#1259](https://github.com/juanfont/headscale/issues/1259)
-- Added the possibility to manually create a DERP-map entry which can be customized, instead of automatically creating it. [#1565](https://github.com/juanfont/headscale/pull/1565)
-- Add support for deleting api keys [#1702](https://github.com/juanfont/headscale/pull/1702)
-- Add command to backfill IP addresses for nodes missing IPs from configured prefixes. [#1869](https://github.com/juanfont/headscale/pull/1869)
-- Log available update as warning [#1877](https://github.com/juanfont/headscale/pull/1877)
-- Add `autogroup:internet` to Policy [#1917](https://github.com/juanfont/headscale/pull/1917)
-- Restore foreign keys and add constraints [#1562](https://github.com/juanfont/headscale/pull/1562)
+- State management has been improved
+ [#1492](https://github.com/juanfont/headscale/pull/1492)
+- Use error group handling to ensure tests actually pass
+ [#1535](https://github.com/juanfont/headscale/pull/1535) based on
+ [#1460](https://github.com/juanfont/headscale/pull/1460)
+- Fix hang on SIGTERM [#1492](https://github.com/juanfont/headscale/pull/1492)
+ taken from [#1480](https://github.com/juanfont/headscale/pull/1480)
+- Send logs to stderr by default
+ [#1524](https://github.com/juanfont/headscale/pull/1524)
+- Fix [TS-2023-006](https://tailscale.com/security-bulletins/#ts-2023-006)
+ security UPnP issue [#1563](https://github.com/juanfont/headscale/pull/1563)
+- Turn off gRPC logging [#1640](https://github.com/juanfont/headscale/pull/1640)
+ fixes [#1259](https://github.com/juanfont/headscale/issues/1259)
+- Added the possibility to manually create a DERP-map entry which can be
+ customized, instead of automatically creating it.
+ [#1565](https://github.com/juanfont/headscale/pull/1565)
+- Add support for deleting api keys
+ [#1702](https://github.com/juanfont/headscale/pull/1702)
+- Add command to backfill IP addresses for nodes missing IPs from configured
+ prefixes. [#1869](https://github.com/juanfont/headscale/pull/1869)
+- Log available update as warning
+ [#1877](https://github.com/juanfont/headscale/pull/1877)
+- Add `autogroup:internet` to Policy
+ [#1917](https://github.com/juanfont/headscale/pull/1917)
+- Restore foreign keys and add constraints
+ [#1562](https://github.com/juanfont/headscale/pull/1562)
- Make registration page easier to use on mobile devices
-- Make write-ahead-log default on and configurable for SQLite [#1985](https://github.com/juanfont/headscale/pull/1985)
-- Add APIs for managing headscale policy. [#1792](https://github.com/juanfont/headscale/pull/1792)
-- Fix for registering nodes using preauthkeys when running on a postgres database in a non-UTC timezone. [#764](https://github.com/juanfont/headscale/issues/764)
+- Make write-ahead-log default on and configurable for SQLite
+ [#1985](https://github.com/juanfont/headscale/pull/1985)
+- Add APIs for managing headscale policy.
+ [#1792](https://github.com/juanfont/headscale/pull/1792)
+- Fix for registering nodes using preauthkeys when running on a postgres
+ database in a non-UTC timezone.
+ [#764](https://github.com/juanfont/headscale/issues/764)
- Make sure integration tests cover postgres for all scenarios
-- CLI commands (all except `serve`) only requires minimal configuration, no more errors or warnings from unset settings [#2109](https://github.com/juanfont/headscale/pull/2109)
-- CLI results are now concistently sent to stdout and errors to stderr [#2109](https://github.com/juanfont/headscale/pull/2109)
-- Fix issue where shutting down headscale would hang [#2113](https://github.com/juanfont/headscale/pull/2113)
+- CLI commands (all except `serve`) only requires minimal configuration, no more
+ errors or warnings from unset settings
+ [#2109](https://github.com/juanfont/headscale/pull/2109)
+- CLI results are now concistently sent to stdout and errors to stderr
+ [#2109](https://github.com/juanfont/headscale/pull/2109)
+- Fix issue where shutting down headscale would hang
+ [#2113](https://github.com/juanfont/headscale/pull/2113)
## 0.22.3 (2023-05-12)
### Changes
-- Added missing ca-certificates in Docker image [#1463](https://github.com/juanfont/headscale/pull/1463)
+- Added missing ca-certificates in Docker image
+ [#1463](https://github.com/juanfont/headscale/pull/1463)
## 0.22.2 (2023-05-10)
### Changes
-- Add environment flags to enable pprof (profiling) [#1382](https://github.com/juanfont/headscale/pull/1382)
+- Add environment flags to enable pprof (profiling)
+ [#1382](https://github.com/juanfont/headscale/pull/1382)
- Profiles are continuously generated in our integration tests.
-- Fix systemd service file location in `.deb` packages [#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 [#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)
-- Ditch distroless for Docker image, create default socket dir in `/var/run/headscale` [#1450](https://github.com/juanfont/headscale/pull/1450)
+- Fix systemd service file location in `.deb` packages
+ [#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
+ [#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)
+- Ditch distroless for Docker image, create default socket dir in
+ `/var/run/headscale` [#1450](https://github.com/juanfont/headscale/pull/1450)
## 0.22.1 (2023-04-20)
### Changes
-- Fix issue where systemd could not bind to port 80 [#1365](https://github.com/juanfont/headscale/pull/1365)
+- Fix issue where systemd could not bind to port 80
+ [#1365](https://github.com/juanfont/headscale/pull/1365)
## 0.22.0 (2023-04-20)
### Changes
-- Add `.deb` packages to release process [#1297](https://github.com/juanfont/headscale/pull/1297)
-- Update and simplify the documentation to use new `.deb` packages [#1349](https://github.com/juanfont/headscale/pull/1349)
-- Add 32-bit Arm platforms to release process [#1297](https://github.com/juanfont/headscale/pull/1297)
-- Fix longstanding bug that would prevent "\*" from working properly in ACLs (issue [#699](https://github.com/juanfont/headscale/issues/699)) [#1279](https://github.com/juanfont/headscale/pull/1279)
-- Fix issue where IPv6 could not be used in, or while using ACLs (part of [#809](https://github.com/juanfont/headscale/issues/809)) [#1339](https://github.com/juanfont/headscale/pull/1339)
-- Target Go 1.20 and Tailscale 1.38 for Headscale [#1323](https://github.com/juanfont/headscale/pull/1323)
+- Add `.deb` packages to release process
+ [#1297](https://github.com/juanfont/headscale/pull/1297)
+- Update and simplify the documentation to use new `.deb` packages
+ [#1349](https://github.com/juanfont/headscale/pull/1349)
+- Add 32-bit Arm platforms to release process
+ [#1297](https://github.com/juanfont/headscale/pull/1297)
+- Fix longstanding bug that would prevent "\*" from working properly in ACLs
+ (issue [#699](https://github.com/juanfont/headscale/issues/699))
+ [#1279](https://github.com/juanfont/headscale/pull/1279)
+- Fix issue where IPv6 could not be used in, or while using ACLs (part of
+ [#809](https://github.com/juanfont/headscale/issues/809))
+ [#1339](https://github.com/juanfont/headscale/pull/1339)
+- Target Go 1.20 and Tailscale 1.38 for Headscale
+ [#1323](https://github.com/juanfont/headscale/pull/1323)
## 0.21.0 (2023-03-20)
### Changes
-- Adding "configtest" CLI command. [#1230](https://github.com/juanfont/headscale/pull/1230)
-- Add documentation on connecting with iOS to `/apple` [#1261](https://github.com/juanfont/headscale/pull/1261)
-- Update iOS compatibility and added documentation for iOS [#1264](https://github.com/juanfont/headscale/pull/1264)
-- Allow to delete routes [#1244](https://github.com/juanfont/headscale/pull/1244)
+- Adding "configtest" CLI command.
+ [#1230](https://github.com/juanfont/headscale/pull/1230)
+- Add documentation on connecting with iOS to `/apple`
+ [#1261](https://github.com/juanfont/headscale/pull/1261)
+- Update iOS compatibility and added documentation for iOS
+ [#1264](https://github.com/juanfont/headscale/pull/1264)
+- Allow to delete routes
+ [#1244](https://github.com/juanfont/headscale/pull/1244)
## 0.20.0 (2023-02-03)
### Changes
-- Fix wrong behaviour in exit nodes [#1159](https://github.com/juanfont/headscale/pull/1159)
-- Align behaviour of `dns_config.restricted_nameservers` to tailscale [#1162](https://github.com/juanfont/headscale/pull/1162)
-- Make OpenID Connect authenticated client expiry time configurable [#1191](https://github.com/juanfont/headscale/pull/1191)
+- Fix wrong behaviour in exit nodes
+ [#1159](https://github.com/juanfont/headscale/pull/1159)
+- Align behaviour of `dns_config.restricted_nameservers` to tailscale
+ [#1162](https://github.com/juanfont/headscale/pull/1162)
+- Make OpenID Connect authenticated client expiry time configurable
+ [#1191](https://github.com/juanfont/headscale/pull/1191)
- defaults to 180 days like Tailscale SaaS
- - adds option to use the expiry time from the OpenID token for the node (see config-example.yaml)
-- Set ControlTime in Map info sent to nodes [#1195](https://github.com/juanfont/headscale/pull/1195)
-- Populate Tags field on Node updates sent [#1195](https://github.com/juanfont/headscale/pull/1195)
+ - adds option to use the expiry time from the OpenID token for the node (see
+ config-example.yaml)
+- Set ControlTime in Map info sent to nodes
+ [#1195](https://github.com/juanfont/headscale/pull/1195)
+- Populate Tags field on Node updates sent
+ [#1195](https://github.com/juanfont/headscale/pull/1195)
## 0.19.0 (2023-01-29)
### BREAKING
-- Rename Namespace to User [#1144](https://github.com/juanfont/headscale/pull/1144)
+- Rename Namespace to User
+ [#1144](https://github.com/juanfont/headscale/pull/1144)
- **BACKUP your database before upgrading**
-- Command line flags previously taking `--namespace` or `-n` will now require `--user` or `-u`
+- Command line flags previously taking `--namespace` or `-n` will now require
+ `--user` or `-u`
## 0.18.0 (2023-01-14)
### Changes
-- Reworked routing and added support for subnet router failover [#1024](https://github.com/juanfont/headscale/pull/1024)
-- Added an OIDC AllowGroups Configuration options and authorization check [#1041](https://github.com/juanfont/headscale/pull/1041)
-- Set `db_ssl` to false by default [#1052](https://github.com/juanfont/headscale/pull/1052)
-- Fix duplicate nodes due to incorrect implementation of the protocol [#1058](https://github.com/juanfont/headscale/pull/1058)
-- Report if a machine is online in CLI more accurately [#1062](https://github.com/juanfont/headscale/pull/1062)
-- Added config option for custom DNS records [#1035](https://github.com/juanfont/headscale/pull/1035)
-- Expire nodes based on OIDC token expiry [#1067](https://github.com/juanfont/headscale/pull/1067)
-- Remove ephemeral nodes on logout [#1098](https://github.com/juanfont/headscale/pull/1098)
-- Performance improvements in ACLs [#1129](https://github.com/juanfont/headscale/pull/1129)
-- OIDC client secret can be passed via a file [#1127](https://github.com/juanfont/headscale/pull/1127)
+- Reworked routing and added support for subnet router failover
+ [#1024](https://github.com/juanfont/headscale/pull/1024)
+- Added an OIDC AllowGroups Configuration options and authorization check
+ [#1041](https://github.com/juanfont/headscale/pull/1041)
+- Set `db_ssl` to false by default
+ [#1052](https://github.com/juanfont/headscale/pull/1052)
+- Fix duplicate nodes due to incorrect implementation of the protocol
+ [#1058](https://github.com/juanfont/headscale/pull/1058)
+- Report if a machine is online in CLI more accurately
+ [#1062](https://github.com/juanfont/headscale/pull/1062)
+- Added config option for custom DNS records
+ [#1035](https://github.com/juanfont/headscale/pull/1035)
+- Expire nodes based on OIDC token expiry
+ [#1067](https://github.com/juanfont/headscale/pull/1067)
+- Remove ephemeral nodes on logout
+ [#1098](https://github.com/juanfont/headscale/pull/1098)
+- Performance improvements in ACLs
+ [#1129](https://github.com/juanfont/headscale/pull/1129)
+- OIDC client secret can be passed via a file
+ [#1127](https://github.com/juanfont/headscale/pull/1127)
## 0.17.1 (2022-12-05)
### Changes
-- Correct typo on macOS standalone profile link [#1028](https://github.com/juanfont/headscale/pull/1028)
-- Update platform docs with Fast User Switching [#1016](https://github.com/juanfont/headscale/pull/1016)
+- Correct typo on macOS standalone profile link
+ [#1028](https://github.com/juanfont/headscale/pull/1028)
+- Update platform docs with Fast User Switching
+ [#1016](https://github.com/juanfont/headscale/pull/1016)
## 0.17.0 (2022-11-26)
### BREAKING
-- `noise.private_key_path` has been added and is required for the new noise protocol.
-- Log level option `log_level` was moved to a distinct `log` config section and renamed to `level` [#768](https://github.com/juanfont/headscale/pull/768)
-- Removed Alpine Linux container image [#962](https://github.com/juanfont/headscale/pull/962)
+- `noise.private_key_path` has been added and is required for the new noise
+ protocol.
+- Log level option `log_level` was moved to a distinct `log` config section and
+ renamed to `level` [#768](https://github.com/juanfont/headscale/pull/768)
+- Removed Alpine Linux container image
+ [#962](https://github.com/juanfont/headscale/pull/962)
### Important Changes
-- Added support for Tailscale TS2021 protocol [#738](https://github.com/juanfont/headscale/pull/738)
-- Add experimental support for [SSH ACL](https://tailscale.com/kb/1018/acls/#tailscale-ssh) (see docs for limitations) [#847](https://github.com/juanfont/headscale/pull/847)
+- Added support for Tailscale TS2021 protocol
+ [#738](https://github.com/juanfont/headscale/pull/738)
+- Add experimental support for
+ [SSH ACL](https://tailscale.com/kb/1018/acls/#tailscale-ssh) (see docs for
+ limitations) [#847](https://github.com/juanfont/headscale/pull/847)
- Please note that this support should be considered _partially_ implemented
- SSH ACLs status:
- - Support `accept` and `check` (SSH can be enabled and used for connecting and authentication)
- - Rejecting connections **are not supported**, meaning that if you enable SSH, then assume that _all_ `ssh` connections **will be allowed**.
- - If you decided to try this feature, please carefully managed permissions by blocking port `22` with regular ACLs or do _not_ set `--ssh` on your clients.
- - We are currently improving our testing of the SSH ACLs, help us get an overview by testing and giving feedback.
- - This feature should be considered dangerous and it is disabled by default. Enable by setting `HEADSCALE_EXPERIMENTAL_FEATURE_SSH=1`.
+ - Support `accept` and `check` (SSH can be enabled and used for connecting
+ and authentication)
+ - Rejecting connections **are not supported**, meaning that if you enable
+ SSH, then assume that _all_ `ssh` connections **will be allowed**.
+ - If you decided to try this feature, please carefully managed permissions
+ by blocking port `22` with regular ACLs or do _not_ set `--ssh` on your
+ clients.
+ - We are currently improving our testing of the SSH ACLs, help us get an
+ overview by testing and giving feedback.
+ - This feature should be considered dangerous and it is disabled by default.
+ Enable by setting `HEADSCALE_EXPERIMENTAL_FEATURE_SSH=1`.
### Changes
-- Add ability to specify config location via env var `HEADSCALE_CONFIG` [#674](https://github.com/juanfont/headscale/issues/674)
-- Target Go 1.19 for Headscale [#778](https://github.com/juanfont/headscale/pull/778)
-- Target Tailscale v1.30.0 to build Headscale [#780](https://github.com/juanfont/headscale/pull/780)
-- Give a warning when running Headscale with reverse proxy improperly configured for WebSockets [#788](https://github.com/juanfont/headscale/pull/788)
-- Fix subnet routers with Primary Routes [#811](https://github.com/juanfont/headscale/pull/811)
-- Added support for JSON logs [#653](https://github.com/juanfont/headscale/issues/653)
-- Sanitise the node key passed to registration url [#823](https://github.com/juanfont/headscale/pull/823)
-- Add support for generating pre-auth keys with tags [#767](https://github.com/juanfont/headscale/pull/767)
-- Add support for evaluating `autoApprovers` ACL entries when a machine is registered [#763](https://github.com/juanfont/headscale/pull/763)
-- Add config flag to allow Headscale to start if OIDC provider is down [#829](https://github.com/juanfont/headscale/pull/829)
-- Fix prefix length comparison bug in AutoApprovers route evaluation [#862](https://github.com/juanfont/headscale/pull/862)
-- Random node DNS suffix only applied if names collide in namespace. [#766](https://github.com/juanfont/headscale/issues/766)
-- Remove `ip_prefix` configuration option and warning [#899](https://github.com/juanfont/headscale/pull/899)
-- Add `dns_config.override_local_dns` option [#905](https://github.com/juanfont/headscale/pull/905)
-- Fix some DNS config issues [#660](https://github.com/juanfont/headscale/issues/660)
-- Make it possible to disable TS2019 with build flag [#928](https://github.com/juanfont/headscale/pull/928)
-- Fix OIDC registration issues [#960](https://github.com/juanfont/headscale/pull/960) and [#971](https://github.com/juanfont/headscale/pull/971)
-- Add support for specifying NextDNS DNS-over-HTTPS resolver [#940](https://github.com/juanfont/headscale/pull/940)
-- Make more sslmode available for postgresql connection [#927](https://github.com/juanfont/headscale/pull/927)
+- Add ability to specify config location via env var `HEADSCALE_CONFIG`
+ [#674](https://github.com/juanfont/headscale/issues/674)
+- Target Go 1.19 for Headscale
+ [#778](https://github.com/juanfont/headscale/pull/778)
+- Target Tailscale v1.30.0 to build Headscale
+ [#780](https://github.com/juanfont/headscale/pull/780)
+- Give a warning when running Headscale with reverse proxy improperly configured
+ for WebSockets [#788](https://github.com/juanfont/headscale/pull/788)
+- Fix subnet routers with Primary Routes
+ [#811](https://github.com/juanfont/headscale/pull/811)
+- Added support for JSON logs
+ [#653](https://github.com/juanfont/headscale/issues/653)
+- Sanitise the node key passed to registration url
+ [#823](https://github.com/juanfont/headscale/pull/823)
+- Add support for generating pre-auth keys with tags
+ [#767](https://github.com/juanfont/headscale/pull/767)
+- Add support for evaluating `autoApprovers` ACL entries when a machine is
+ registered [#763](https://github.com/juanfont/headscale/pull/763)
+- Add config flag to allow Headscale to start if OIDC provider is down
+ [#829](https://github.com/juanfont/headscale/pull/829)
+- Fix prefix length comparison bug in AutoApprovers route evaluation
+ [#862](https://github.com/juanfont/headscale/pull/862)
+- Random node DNS suffix only applied if names collide in namespace.
+ [#766](https://github.com/juanfont/headscale/issues/766)
+- Remove `ip_prefix` configuration option and warning
+ [#899](https://github.com/juanfont/headscale/pull/899)
+- Add `dns_config.override_local_dns` option
+ [#905](https://github.com/juanfont/headscale/pull/905)
+- Fix some DNS config issues
+ [#660](https://github.com/juanfont/headscale/issues/660)
+- Make it possible to disable TS2019 with build flag
+ [#928](https://github.com/juanfont/headscale/pull/928)
+- Fix OIDC registration issues
+ [#960](https://github.com/juanfont/headscale/pull/960) and
+ [#971](https://github.com/juanfont/headscale/pull/971)
+- Add support for specifying NextDNS DNS-over-HTTPS resolver
+ [#940](https://github.com/juanfont/headscale/pull/940)
+- Make more sslmode available for postgresql connection
+ [#927](https://github.com/juanfont/headscale/pull/927)
## 0.16.4 (2022-08-21)
### Changes
-- Add ability to connect to PostgreSQL over TLS/SSL [#745](https://github.com/juanfont/headscale/pull/745)
-- Fix CLI registration of expired machines [#754](https://github.com/juanfont/headscale/pull/754)
+- Add ability to connect to PostgreSQL over TLS/SSL
+ [#745](https://github.com/juanfont/headscale/pull/745)
+- Fix CLI registration of expired machines
+ [#754](https://github.com/juanfont/headscale/pull/754)
## 0.16.3 (2022-08-17)
### Changes
-- Fix issue with OIDC authentication [#747](https://github.com/juanfont/headscale/pull/747)
+- Fix issue with OIDC authentication
+ [#747](https://github.com/juanfont/headscale/pull/747)
## 0.16.2 (2022-08-14)
### Changes
-- Fixed bugs in the client registration process after migration to NodeKey [#735](https://github.com/juanfont/headscale/pull/735)
+- Fixed bugs in the client registration process after migration to NodeKey
+ [#735](https://github.com/juanfont/headscale/pull/735)
## 0.16.1 (2022-08-12)
### Changes
-- Updated dependencies (including the library that lacked armhf support) [#722](https://github.com/juanfont/headscale/pull/722)
-- Fix missing group expansion in function `excludeCorrectlyTaggedNodes` [#563](https://github.com/juanfont/headscale/issues/563)
-- Improve registration protocol implementation and switch to NodeKey as main identifier [#725](https://github.com/juanfont/headscale/pull/725)
-- Add ability to connect to PostgreSQL via unix socket [#734](https://github.com/juanfont/headscale/pull/734)
+- Updated dependencies (including the library that lacked armhf support)
+ [#722](https://github.com/juanfont/headscale/pull/722)
+- Fix missing group expansion in function `excludeCorrectlyTaggedNodes`
+ [#563](https://github.com/juanfont/headscale/issues/563)
+- Improve registration protocol implementation and switch to NodeKey as main
+ identifier [#725](https://github.com/juanfont/headscale/pull/725)
+- Add ability to connect to PostgreSQL via unix socket
+ [#734](https://github.com/juanfont/headscale/pull/734)
## 0.16.0 (2022-07-25)
@@ -335,38 +604,70 @@ after improving the test harness as part of adopting [#1460](https://github.com/
### BREAKING
-- Old ACL syntax is no longer supported ("users" & "ports" -> "src" & "dst"). Please check [the new syntax](https://tailscale.com/kb/1018/acls/).
+- Old ACL syntax is no longer supported ("users" & "ports" -> "src" & "dst").
+ Please check [the new syntax](https://tailscale.com/kb/1018/acls/).
### Changes
-- **Drop** armhf (32-bit ARM) support. [#609](https://github.com/juanfont/headscale/pull/609)
-- Headscale fails to serve if the ACL policy file cannot be parsed [#537](https://github.com/juanfont/headscale/pull/537)
-- Fix labels cardinality error when registering unknown pre-auth key [#519](https://github.com/juanfont/headscale/pull/519)
-- Fix send on closed channel crash in polling [#542](https://github.com/juanfont/headscale/pull/542)
-- Fixed spurious calls to setLastStateChangeToNow from ephemeral nodes [#566](https://github.com/juanfont/headscale/pull/566)
-- Add command for moving nodes between namespaces [#362](https://github.com/juanfont/headscale/issues/362)
-- Added more configuration parameters for OpenID Connect (scopes, free-form parameters, domain and user allowlist)
-- Add command to set tags on a node [#525](https://github.com/juanfont/headscale/issues/525)
-- Add command to view tags of nodes [#356](https://github.com/juanfont/headscale/issues/356)
-- Add --all (-a) flag to enable routes command [#360](https://github.com/juanfont/headscale/issues/360)
-- Fix issue where nodes was not updated across namespaces [#560](https://github.com/juanfont/headscale/pull/560)
-- Add the ability to rename a nodes name [#560](https://github.com/juanfont/headscale/pull/560)
- - Node DNS names are now unique, a random suffix will be added when a node joins
- - This change contains database changes, remember to **backup** your database before upgrading
-- Add option to enable/disable logtail (Tailscale's logging infrastructure) [#596](https://github.com/juanfont/headscale/pull/596)
+- **Drop** armhf (32-bit ARM) support.
+ [#609](https://github.com/juanfont/headscale/pull/609)
+- Headscale fails to serve if the ACL policy file cannot be parsed
+ [#537](https://github.com/juanfont/headscale/pull/537)
+- Fix labels cardinality error when registering unknown pre-auth key
+ [#519](https://github.com/juanfont/headscale/pull/519)
+- Fix send on closed channel crash in polling
+ [#542](https://github.com/juanfont/headscale/pull/542)
+- Fixed spurious calls to setLastStateChangeToNow from ephemeral nodes
+ [#566](https://github.com/juanfont/headscale/pull/566)
+- Add command for moving nodes between namespaces
+ [#362](https://github.com/juanfont/headscale/issues/362)
+- Added more configuration parameters for OpenID Connect (scopes, free-form
+ parameters, domain and user allowlist)
+- Add command to set tags on a node
+ [#525](https://github.com/juanfont/headscale/issues/525)
+- Add command to view tags of nodes
+ [#356](https://github.com/juanfont/headscale/issues/356)
+- Add --all (-a) flag to enable routes command
+ [#360](https://github.com/juanfont/headscale/issues/360)
+- Fix issue where nodes was not updated across namespaces
+ [#560](https://github.com/juanfont/headscale/pull/560)
+- Add the ability to rename a nodes name
+ [#560](https://github.com/juanfont/headscale/pull/560)
+ - Node DNS names are now unique, a random suffix will be added when a node
+ joins
+ - This change contains database changes, remember to **backup** your database
+ before upgrading
+- Add option to enable/disable logtail (Tailscale's logging infrastructure)
+ [#596](https://github.com/juanfont/headscale/pull/596)
- This change disables the logs by default
-- Use [Prometheus]'s duration parser, supporting days (`d`), weeks (`w`) and years (`y`) [#598](https://github.com/juanfont/headscale/pull/598)
-- Add support for reloading ACLs with SIGHUP [#601](https://github.com/juanfont/headscale/pull/601)
+- Use [Prometheus]'s duration parser, supporting days (`d`), weeks (`w`) and
+ years (`y`) [#598](https://github.com/juanfont/headscale/pull/598)
+- Add support for reloading ACLs with SIGHUP
+ [#601](https://github.com/juanfont/headscale/pull/601)
- Use new ACL syntax [#618](https://github.com/juanfont/headscale/pull/618)
-- Add -c option to specify config file from command line [#285](https://github.com/juanfont/headscale/issues/285) [#612](https://github.com/juanfont/headscale/pull/601)
-- Add configuration option to allow Tailscale clients to use a random WireGuard port. [kb/1181/firewalls](https://tailscale.com/kb/1181/firewalls) [#624](https://github.com/juanfont/headscale/pull/624)
-- Improve obtuse UX regarding missing configuration (`ephemeral_node_inactivity_timeout` not set) [#639](https://github.com/juanfont/headscale/pull/639)
-- Fix nodes being shown as 'offline' in `tailscale status` [#648](https://github.com/juanfont/headscale/pull/648)
-- Improve shutdown behaviour [#651](https://github.com/juanfont/headscale/pull/651)
-- Drop Gin as web framework in Headscale [648](https://github.com/juanfont/headscale/pull/648) [677](https://github.com/juanfont/headscale/pull/677)
-- Make tailnet node updates check interval configurable [#675](https://github.com/juanfont/headscale/pull/675)
-- Fix regression with HTTP API [#684](https://github.com/juanfont/headscale/pull/684)
-- nodes ls now print both Hostname and Name(Issue [#647](https://github.com/juanfont/headscale/issues/647) PR [#687](https://github.com/juanfont/headscale/pull/687))
+- Add -c option to specify config file from command line
+ [#285](https://github.com/juanfont/headscale/issues/285)
+ [#612](https://github.com/juanfont/headscale/pull/601)
+- Add configuration option to allow Tailscale clients to use a random WireGuard
+ port. [kb/1181/firewalls](https://tailscale.com/kb/1181/firewalls)
+ [#624](https://github.com/juanfont/headscale/pull/624)
+- Improve obtuse UX regarding missing configuration
+ (`ephemeral_node_inactivity_timeout` not set)
+ [#639](https://github.com/juanfont/headscale/pull/639)
+- Fix nodes being shown as 'offline' in `tailscale status`
+ [#648](https://github.com/juanfont/headscale/pull/648)
+- Improve shutdown behaviour
+ [#651](https://github.com/juanfont/headscale/pull/651)
+- Drop Gin as web framework in Headscale
+ [648](https://github.com/juanfont/headscale/pull/648)
+ [677](https://github.com/juanfont/headscale/pull/677)
+- Make tailnet node updates check interval configurable
+ [#675](https://github.com/juanfont/headscale/pull/675)
+- Fix regression with HTTP API
+ [#684](https://github.com/juanfont/headscale/pull/684)
+- nodes ls now print both Hostname and Name(Issue
+ [#647](https://github.com/juanfont/headscale/issues/647) PR
+ [#687](https://github.com/juanfont/headscale/pull/687))
## 0.15.0 (2022-03-20)
@@ -374,57 +675,78 @@ after improving the test harness as part of adopting [#1460](https://github.com/
### BREAKING
-- 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).
-- `/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:
+- 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/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:
```yaml
metrics_listen_addr: 127.0.0.1:9090
```
### Features
-- Add support for writing ACL files with YAML [#359](https://github.com/juanfont/headscale/pull/359)
-- Users can now use emails in ACL's groups [#372](https://github.com/juanfont/headscale/issues/372)
-- Add shorthand aliases for commands and subcommands [#376](https://github.com/juanfont/headscale/pull/376)
-- Add `/windows` endpoint for Windows configuration instructions + registry file download [#392](https://github.com/juanfont/headscale/pull/392)
-- Added embedded DERP (and STUN) server into Headscale [#388](https://github.com/juanfont/headscale/pull/388)
+- Add support for writing ACL files with YAML
+ [#359](https://github.com/juanfont/headscale/pull/359)
+- Users can now use emails in ACL's groups
+ [#372](https://github.com/juanfont/headscale/issues/372)
+- Add shorthand aliases for commands and subcommands
+ [#376](https://github.com/juanfont/headscale/pull/376)
+- Add `/windows` endpoint for Windows configuration instructions + registry file
+ download [#392](https://github.com/juanfont/headscale/pull/392)
+- Added embedded DERP (and STUN) server into Headscale
+ [#388](https://github.com/juanfont/headscale/pull/388)
### Changes
-- Fix a bug were the same IP could be assigned to multiple hosts if joined in quick succession [#346](https://github.com/juanfont/headscale/pull/346)
-- Simplify the code behind registration of machines [#366](https://github.com/juanfont/headscale/pull/366)
+- Fix a bug were the same IP could be assigned to multiple hosts if joined in
+ quick succession [#346](https://github.com/juanfont/headscale/pull/346)
+- Simplify the code behind registration of machines
+ [#366](https://github.com/juanfont/headscale/pull/366)
- Nodes are now only written to database if they are registered successfully
-- Fix a limitation in the ACLs that prevented users to write rules with `*` as source [#374](https://github.com/juanfont/headscale/issues/374)
-- Reduce the overhead of marshal/unmarshal for Hostinfo, routes and endpoints by using specific types in Machine [#371](https://github.com/juanfont/headscale/pull/371)
-- Apply normalization function to FQDN on hostnames when hosts registers and retrieve information [#363](https://github.com/juanfont/headscale/issues/363)
-- Fix a bug that prevented the use of `tailscale logout` with OIDC [#508](https://github.com/juanfont/headscale/issues/508)
-- Added Tailscale repo HEAD and unstable releases channel to the integration tests targets [#513](https://github.com/juanfont/headscale/pull/513)
+- Fix a limitation in the ACLs that prevented users to write rules with `*` as
+ source [#374](https://github.com/juanfont/headscale/issues/374)
+- Reduce the overhead of marshal/unmarshal for Hostinfo, routes and endpoints by
+ using specific types in Machine
+ [#371](https://github.com/juanfont/headscale/pull/371)
+- Apply normalization function to FQDN on hostnames when hosts registers and
+ retrieve information [#363](https://github.com/juanfont/headscale/issues/363)
+- Fix a bug that prevented the use of `tailscale logout` with OIDC
+ [#508](https://github.com/juanfont/headscale/issues/508)
+- Added Tailscale repo HEAD and unstable releases channel to the integration
+ tests targets [#513](https://github.com/juanfont/headscale/pull/513)
## 0.14.0 (2022-02-24)
-**UPCOMING ### BREAKING
-From the **next\*\* version (`0.15.0`), all machines will be able to communicate regardless of
-if they are in the same namespace. This means that the behaviour currently limited to ACLs
-will become default. From version `0.15.0`, all limitation of communications must be done
-with ACLs.
+**UPCOMING ### BREAKING From the **next\*\* version (`0.15.0`), all machines
+will be able to communicate regardless of if they are in the same namespace.
+This means that the behaviour currently limited to ACLs will become default.
+From version `0.15.0`, all limitation of communications must be done with ACLs.
-This is a part of aligning `headscale`'s behaviour with Tailscale's upstream behaviour.
+This is a part of aligning `headscale`'s behaviour with Tailscale's upstream
+behaviour.
### BREAKING
-- ACLs have been rewritten to align with the bevaviour Tailscale Control Panel provides. **NOTE:** This is only active if you use ACLs
+- ACLs have been rewritten to align with the bevaviour Tailscale Control Panel
+ provides. **NOTE:** This is only active if you use ACLs
- Namespaces are now treated as Users
- 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
+ - Tags should now work correctly and adding a host to Headscale should now
+ reload the rules.
+ - 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) [#297](https://github.com/juanfont/headscale/pull/297)
+- Add support for configurable mTLS [docs](./docs/ref/tls.md)
+ [#297](https://github.com/juanfont/headscale/pull/297)
### Changes
-- Remove dependency on CGO (switch from CGO SQLite to pure Go) [#346](https://github.com/juanfont/headscale/pull/346)
+- Remove dependency on CGO (switch from CGO SQLite to pure Go)
+ [#346](https://github.com/juanfont/headscale/pull/346)
**0.13.0 (2022-02-18):**
@@ -432,35 +754,47 @@ This is a part of aligning `headscale`'s behaviour with Tailscale's upstream beh
- 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
- `oidc.domain_map` option has been removed
- - `strip_email_domain` option has been added (see [config-example.yaml](./config-example.yaml))
+ - `strip_email_domain` option has been added (see
+ [config-example.yaml](./config-example.yaml))
### Changes
-- `ip_prefix` is now superseded by `ip_prefixes` in the configuration [#208](https://github.com/juanfont/headscale/pull/208)
-- Upgrade `tailscale` (1.20.4) and other dependencies to latest [#314](https://github.com/juanfont/headscale/pull/314)
-- fix swapped machine<->namespace labels in `/metrics` [#312](https://github.com/juanfont/headscale/pull/312)
-- remove key-value based update mechanism for namespace changes [#316](https://github.com/juanfont/headscale/pull/316)
+- `ip_prefix` is now superseded by `ip_prefixes` in the configuration
+ [#208](https://github.com/juanfont/headscale/pull/208)
+- Upgrade `tailscale` (1.20.4) and other dependencies to latest
+ [#314](https://github.com/juanfont/headscale/pull/314)
+- fix swapped machine<->namespace labels in `/metrics`
+ [#312](https://github.com/juanfont/headscale/pull/312)
+- remove key-value based update mechanism for namespace changes
+ [#316](https://github.com/juanfont/headscale/pull/316)
**0.12.4 (2022-01-29):**
### Changes
-- Make gRPC Unix Socket permissions configurable [#292](https://github.com/juanfont/headscale/pull/292)
-- Trim whitespace before reading Private Key from file [#289](https://github.com/juanfont/headscale/pull/289)
-- Add new command to generate a private key for `headscale` [#290](https://github.com/juanfont/headscale/pull/290)
-- Fixed issue where hosts deleted from control server may be written back to the database, as long as they are connected to the control server [#278](https://github.com/juanfont/headscale/pull/278)
+- Make gRPC Unix Socket permissions configurable
+ [#292](https://github.com/juanfont/headscale/pull/292)
+- Trim whitespace before reading Private Key from file
+ [#289](https://github.com/juanfont/headscale/pull/289)
+- Add new command to generate a private key for `headscale`
+ [#290](https://github.com/juanfont/headscale/pull/290)
+- Fixed issue where hosts deleted from control server may be written back to the
+ database, as long as they are connected to the control server
+ [#278](https://github.com/juanfont/headscale/pull/278)
## 0.12.3 (2022-01-13)
### Changes
- Added Alpine container [#270](https://github.com/juanfont/headscale/pull/270)
-- Minor updates in dependencies [#271](https://github.com/juanfont/headscale/pull/271)
+- Minor updates in dependencies
+ [#271](https://github.com/juanfont/headscale/pull/271)
## 0.12.2 (2022-01-11)
@@ -474,29 +808,40 @@ Happy New Year!
## 0.12.1 (2021-12-24)
-(We are skipping 0.12.0 to correct a mishap done weeks ago with the version tagging)
+(We are skipping 0.12.0 to correct a mishap done weeks ago with the version
+tagging)
### BREAKING
-- Upgrade to Tailscale 1.18 [#229](https://github.com/juanfont/headscale/pull/229)
- - This change requires a new format for private key, private keys are now generated automatically:
+- Upgrade to Tailscale 1.18
+ [#229](https://github.com/juanfont/headscale/pull/229)
+ - This change requires a new format for private key, private keys are now
+ generated automatically:
1. Delete your current key
2. Restart `headscale`, a new key will be generated.
3. Restart all Tailscale clients to fetch the new key
### Changes
-- Unify configuration example [#197](https://github.com/juanfont/headscale/pull/197)
-- Add stricter linting and formatting [#223](https://github.com/juanfont/headscale/pull/223)
+- Unify configuration example
+ [#197](https://github.com/juanfont/headscale/pull/197)
+- Add stricter linting and formatting
+ [#223](https://github.com/juanfont/headscale/pull/223)
### Features
-- Add gRPC and HTTP API (HTTP API is currently disabled) [#204](https://github.com/juanfont/headscale/pull/204)
-- Use gRPC between the CLI and the server [#206](https://github.com/juanfont/headscale/pull/206), [#212](https://github.com/juanfont/headscale/pull/212)
-- Beta OpenID Connect support [#126](https://github.com/juanfont/headscale/pull/126), [#227](https://github.com/juanfont/headscale/pull/227)
+- Add gRPC and HTTP API (HTTP API is currently disabled)
+ [#204](https://github.com/juanfont/headscale/pull/204)
+- Use gRPC between the CLI and the server
+ [#206](https://github.com/juanfont/headscale/pull/206),
+ [#212](https://github.com/juanfont/headscale/pull/212)
+- Beta OpenID Connect support
+ [#126](https://github.com/juanfont/headscale/pull/126),
+ [#227](https://github.com/juanfont/headscale/pull/227)
## 0.11.0 (2021-10-25)
### BREAKING
-- Make headscale fetch DERP map from URL and file [#196](https://github.com/juanfont/headscale/pull/196)
+- Make headscale fetch DERP map from URL and file
+ [#196](https://github.com/juanfont/headscale/pull/196)
diff --git a/Dockerfile.integration b/Dockerfile.integration
index cf55bd7476..735cdba588 100644
--- a/Dockerfile.integration
+++ b/Dockerfile.integration
@@ -8,7 +8,7 @@ ENV GOPATH /go
WORKDIR /go/src/headscale
RUN apt-get update \
- && apt-get install --no-install-recommends --yes less jq sqlite3 \
+ && apt-get install --no-install-recommends --yes less jq sqlite3 dnsutils \
&& rm -rf /var/lib/apt/lists/* \
&& apt-get clean
RUN mkdir -p /var/run/headscale
diff --git a/Makefile b/Makefile
index 96aff1fd96..25fa1c6770 100644
--- a/Makefile
+++ b/Makefile
@@ -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
@@ -42,9 +42,13 @@ fmt: fmt-go fmt-prettier fmt-proto
fmt-prettier:
prettier --write '**/**.{ts,js,md,yaml,yml,sass,css,scss,html}'
+ prettier --write --print-width 80 --prose-wrap always CHANGELOG.md
fmt-go:
- golines --max-len=88 --base-formatter=gofumpt -w $(GO_SOURCES)
+ # TODO(kradalby): Reeval if we want to use 88 in the future.
+ # golines --max-len=88 --base-formatter=gofumpt -w $(GO_SOURCES)
+ gofumpt -l -w .
+ golangci-lint run --fix
fmt-proto:
clang-format -i $(PROTO_SOURCES)
diff --git a/cmd/headscale/cli/debug.go b/cmd/headscale/cli/debug.go
index 72cde32d76..41b46fb07e 100644
--- a/cmd/headscale/cli/debug.go
+++ b/cmd/headscale/cli/debug.go
@@ -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,
diff --git a/cmd/headscale/cli/nodes.go b/cmd/headscale/cli/nodes.go
index b9e97a3368..d65814135f 100644
--- a/cmd/headscale/cli/nodes.go
+++ b/cmd/headscale/cli/nodes.go
@@ -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,
}
diff --git a/cmd/headscale/cli/routes.go b/cmd/headscale/cli/routes.go
index dfbcb8fa8c..ef28949776 100644
--- a/cmd/headscale/cli/routes.go
+++ b/cmd/headscale/cli/routes.go
@@ -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()),
diff --git a/cmd/headscale/cli/users.go b/cmd/headscale/cli/users.go
index 4032b82d4f..b5f1bc4953 100644
--- a/cmd/headscale/cli/users.go
+++ b/cmd/headscale/cli/users.go
@@ -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 {
diff --git a/config-example.yaml b/config-example.yaml
index b083091ffb..95c080f43c 100644
--- a/config-example.yaml
+++ b/config-example.yaml
@@ -40,6 +40,16 @@ grpc_listen_addr: 127.0.0.1:50443
# are doing.
grpc_allow_insecure: false
+# The allow_origins list will allow you to set the Access-Control-Allow-Origin header to the origin in the list.
+# This will allow you to enable cors and set headscale without a reverse proxy.
+# Multiple origins can be set in the allow_origins list.
+# Options:
+# - "*" is disabled (due to security risks).
+# - "https://example.com" to only allow access from a specific origin.
+# - "https://example.com:1234" to allow access from a specific origin with a port.
+cors:
+ allow_origins: []
+
# The Noise section includes specific configuration for the
# TS2021 Noise protocol
noise:
@@ -58,8 +68,8 @@ noise:
# IPv4: https://github.com/tailscale/tailscale/blob/22ebb25e833264f58d7c3f534a8b166894a89536/net/tsaddr/tsaddr.go#L33
# Any other range is NOT supported, and it will cause unexpected issues.
prefixes:
- v6: fd7a:115c:a1e0::/48
v4: 100.64.0.0/10
+ v6: fd7a:115c:a1e0::/48
# Strategy used for allocation of IPs to nodes, available options:
# - sequential (default): assigns the next free IP from the previous given IP.
@@ -301,7 +311,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 +320,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,18 +374,30 @@ 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
# # to force them using the unique identifier from the OIDC and to give them a
# # proper display name and picture if available.
# # Note that this will only work if the username from the legacy user is the same
-# # and ther is a posibility for account takeover should a username have changed
+# # 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
diff --git a/docs/about/features.md b/docs/about/features.md
index 80e94874c1..21b5fb6d97 100644
--- a/docs/about/features.md
+++ b/docs/about/features.md
@@ -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
+ - [ ] 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))
diff --git a/docs/ref/acls.md b/docs/ref/acls.md
index d7ceb629fe..c5f7d55ef7 100644
--- a/docs/ref/acls.md
+++ b/docs/ref/acls.md
@@ -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
diff --git a/docs/ref/dns.md b/docs/ref/dns.md
index 1e3ad8977a..3777661a61 100644
--- a/docs/ref/dns.md
+++ b/docs/ref/dns.md
@@ -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`"
+
+ ```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"
+ ...
+ ```
-1. Restart your headscale instance.
+ 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;
diff --git a/docs/ref/integration/reverse-proxy.md b/docs/ref/integration/reverse-proxy.md
index a50e894a10..91ee8dfc8c 100644
--- a/docs/ref/integration/reverse-proxy.md
+++ b/docs/ref/integration/reverse-proxy.md
@@ -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:// # 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. `` 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 - `` should be the FQDN at which headscale will be served, and `` should be the IP address and port where headscale is running. In most cases, this will be `localhost:8080`.
-```
+```none title="Caddyfile"
{
reverse_proxy
}
@@ -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 ``. 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"
ServerName
diff --git a/docs/ref/integration/web-ui.md b/docs/ref/integration/web-ui.md
index de86e5d7c0..4bcb749523 100644
--- a/docs/ref/integration/web-ui.md
+++ b/docs/ref/integration/web-ui.md
@@ -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 |
diff --git a/docs/ref/oidc.md b/docs/ref/oidc.md
index 734184df83..9f8c3e596d 100644
--- a/docs/ref/oidc.md
+++ b/docs/ref/oidc.md
@@ -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//v2.0"
client_id: ""
@@ -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: ""
diff --git a/docs/ref/remote-cli.md b/docs/ref/remote-cli.md
index d50359c2d1..10c7534fe3 100644
--- a/docs/ref/remote-cli.md
+++ b/docs/ref/remote-cli.md
@@ -54,7 +54,7 @@ headscale apikeys expire --prefix ""
=== "Minimal YAML configuration file"
- ```yaml
+ ```yaml title="config.yaml"
cli:
address: :
api_key:
@@ -69,7 +69,7 @@ headscale apikeys expire --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.
diff --git a/docs/ref/tls.md b/docs/ref/tls.md
index 23bc82a4f2..d1e9101667 100644
--- a/docs/ref/tls.md
+++ b/docs/ref/tls.md
@@ -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"
diff --git a/docs/setup/install/cloud.md b/docs/setup/install/cloud.md
deleted file mode 100644
index 99e6c74bfc..0000000000
--- a/docs/setup/install/cloud.md
+++ /dev/null
@@ -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.
diff --git a/docs/setup/install/official.md b/docs/setup/install/official.md
index d3f307f53a..0bd594992f 100644
--- a/docs/setup/install/official.md
+++ b/docs/setup/install/official.md
@@ -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
```
diff --git a/docs/setup/install/source.md b/docs/setup/install/source.md
index 327430b4e1..27074855dc 100644
--- a/docs/setup/install/source.md
+++ b/docs/setup/install/source.md
@@ -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
diff --git a/docs/setup/upgrade.md b/docs/setup/upgrade.md
index e518a7b50e..9c72eb4f7f 100644
--- a/docs/setup/upgrade.md
+++ b/docs/setup/upgrade.md
@@ -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.
diff --git a/docs/usage/connect/apple.md b/docs/usage/connect/apple.md
index a9aec72fef..d3a96688d9 100644
--- a/docs/usage/connect/apple.md
+++ b/docs/usage/connect/apple.md
@@ -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
#### 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
diff --git a/flake.lock b/flake.lock
index d0269268e8..8b0a0ea4f9 100644
--- a/flake.lock
+++ b/flake.lock
@@ -20,11 +20,11 @@
},
"nixpkgs": {
"locked": {
- "lastModified": 1733376361,
- "narHash": "sha256-aLJxoTDDSqB+/3orsulE6/qdlX6MzDLIITLZqdgMpqo=",
+ "lastModified": 1737717945,
+ "narHash": "sha256-ET91TMkab3PmOZnqiJQYOtSGvSTvGeHoegAv4zcTefM=",
"owner": "NixOS",
"repo": "nixpkgs",
- "rev": "929116e316068c7318c54eb4d827f7d9756d5e9c",
+ "rev": "ecd26a469ac56357fd333946a99086e992452b6a",
"type": "github"
},
"original": {
diff --git a/flake.nix b/flake.nix
index 853eb34b59..507f82d7c4 100644
--- a/flake.nix
+++ b/flake.nix
@@ -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-OPgL2q13Hus6o9Npcp2bFiDiBZvbi/x8YVH6dU5q5fg=";
+ # update this if you have a mismatch after doing a change to those files.
+ vendorHash = "sha256-SBfeixT8DQOrK2SWmHHSOBtzRdSZs+pwomHpw6Jd+qc=";
subPackages = ["cmd/headscale"];
diff --git a/gen/go/headscale/v1/apikey.pb.go b/gen/go/headscale/v1/apikey.pb.go
index 4c28a3b133..c1529c1756 100644
--- a/gen/go/headscale/v1/apikey.pb.go
+++ b/gen/go/headscale/v1/apikey.pb.go
@@ -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
diff --git a/gen/go/headscale/v1/device.pb.go b/gen/go/headscale/v1/device.pb.go
index b17bda09d6..de59736b56 100644
--- a/gen/go/headscale/v1/device.pb.go
+++ b/gen/go/headscale/v1/device.pb.go
@@ -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
diff --git a/gen/go/headscale/v1/headscale.pb.go b/gen/go/headscale/v1/headscale.pb.go
index 7ff023b98f..32e97ee6e1 100644
--- a/gen/go/headscale/v1/headscale.pb.go
+++ b/gen/go/headscale/v1/headscale.pb.go
@@ -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
diff --git a/gen/go/headscale/v1/node.pb.go b/gen/go/headscale/v1/node.pb.go
index 99045e165c..074310e527 100644
--- a/gen/go/headscale/v1/node.pb.go
+++ b/gen/go/headscale/v1/node.pb.go
@@ -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
diff --git a/gen/go/headscale/v1/policy.pb.go b/gen/go/headscale/v1/policy.pb.go
index 957c62cfdd..ca169b8a37 100644
--- a/gen/go/headscale/v1/policy.pb.go
+++ b/gen/go/headscale/v1/policy.pb.go
@@ -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
diff --git a/gen/go/headscale/v1/preauthkey.pb.go b/gen/go/headscale/v1/preauthkey.pb.go
index 2802e7a5f8..4aef49b0f3 100644
--- a/gen/go/headscale/v1/preauthkey.pb.go
+++ b/gen/go/headscale/v1/preauthkey.pb.go
@@ -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
diff --git a/gen/go/headscale/v1/routes.pb.go b/gen/go/headscale/v1/routes.pb.go
index 9582527f4e..dea8649491 100644
--- a/gen/go/headscale/v1/routes.pb.go
+++ b/gen/go/headscale/v1/routes.pb.go
@@ -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
diff --git a/gen/go/headscale/v1/user.pb.go b/gen/go/headscale/v1/user.pb.go
index d1bf6e7c35..9b44d3d3c0 100644
--- a/gen/go/headscale/v1/user.pb.go
+++ b/gen/go/headscale/v1/user.pb.go
@@ -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 (
diff --git a/gen/openapiv2/headscale/v1/headscale.swagger.json b/gen/openapiv2/headscale/v1/headscale.swagger.json
index 1f0a9c4abd..f68133912e 100644
--- a/gen/openapiv2/headscale/v1/headscale.swagger.json
+++ b/gen/openapiv2/headscale/v1/headscale.swagger.json
@@ -1039,6 +1039,15 @@
"properties": {
"name": {
"type": "string"
+ },
+ "displayName": {
+ "type": "string"
+ },
+ "email": {
+ "type": "string"
+ },
+ "pictureUrl": {
+ "type": "string"
}
}
},
diff --git a/go.mod b/go.mod
index d880cfde8e..71d0039bb6 100644
--- a/go.mod
+++ b/go.mod
@@ -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.31.0
+ golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e
+ golang.org/x/net v0.32.0
+ golang.org/x/oauth2 v0.24.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.79.0-pre
zgo.at/zcache/v2 v2.1.0
zombiezen.com/go/postgrestest v1.0.1
)
@@ -87,37 +89,35 @@ 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.0+incompatible // indirect
+ github.com/docker/docker v27.4.0+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.7.0 // indirect
github.com/fxamacker/cbor/v2 v2.6.0 // indirect
github.com/gaissmai/bart v0.11.1 // indirect
github.com/glebarez/go-sqlite v1.22.0 // indirect
@@ -135,7 +135,7 @@ 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
@@ -148,8 +148,8 @@ 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
@@ -178,9 +178,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 +189,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
@@ -204,10 +204,10 @@ require (
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/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-20240226180453-5db17b287bf1 // indirect
- github.com/tailscale/wireguard-go v0.0.0-20240905161824-799c1978fafc // indirect
+ github.com/tailscale/wireguard-go v0.0.0-20241113014420-4e883d38c8d3 // indirect
github.com/tcnksm/go-httpstat v0.2.0 // indirect
github.com/u-root/uio v0.0.0-20240118234441-a3c409a6018e // indirect
github.com/vishvananda/netns v0.0.4 // indirect
@@ -218,15 +218,15 @@ require (
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/mod v0.22.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/term v0.27.0 // indirect
+ golang.org/x/text v0.21.0 // indirect
golang.org/x/time v0.5.0 // indirect
- golang.org/x/tools v0.24.0 // indirect
+ golang.org/x/tools v0.28.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
)
diff --git a/go.sum b/go.sum
index 1149bab906..4ca882553b 100644
--- a/go.sum
+++ b/go.sum
@@ -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.0+incompatible h1:/nJzWkcI1MDMN+U+px/YXnQWJqnu4J+QKGTfD6ptiTc=
+github.com/docker/cli v27.4.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
+github.com/docker/docker v27.4.0+incompatible h1:I9z7sQ5qyzO0BfAb9IMOawRkAGxhYsidKiTMcm0DU+A=
+github.com/docker/docker v27.4.0+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,8 +155,8 @@ 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/gaissmai/bart v0.11.1 h1:5Uv5XwsaFBRo4E5VBcb9TzY8B7zxFf+U7isDxqOrRfc=
@@ -167,8 +167,8 @@ 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=
@@ -177,6 +177,10 @@ github.com/go-json-experiment/json v0.0.0-20231102232822-2e55bd4e08b0 h1:ymLjT4f
github.com/go-json-experiment/json v0.0.0-20231102232822-2e55bd4e08b0/go.mod h1:6daplAwHHGbUGib4990V3Il26O0OC4aRyvewaaAihaA=
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=
@@ -222,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=
@@ -240,8 +244,8 @@ github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kX
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=
@@ -262,10 +266,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=
@@ -288,8 +292,8 @@ github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNU
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=
@@ -361,16 +365,17 @@ 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=
@@ -389,13 +394,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=
@@ -405,8 +410,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=
@@ -415,8 +420,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=
@@ -459,8 +464,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=
@@ -471,24 +476,24 @@ 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/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-20240226180453-5db17b287bf1 h1:tdUdyPqJ0C97SJfjB9tW6EylTtreyee9C44de+UBG0g=
github.com/tailscale/web-client-prebuilt v0.0.0-20240226180453-5db17b287bf1/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-20241113014420-4e883d38c8d3 h1:dmoPb3dG27tZgMtrvqfD/LW4w7gA6BSWl8prCPNmkCQ=
+github.com/tailscale/wireguard-go v0.0.0-20241113014420-4e883d38c8d3/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=
@@ -521,6 +526,16 @@ 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/otel v1.32.0 h1:WnBN+Xjcteh0zdk01SVqV55d/m62NJLJdIyb4y/WO5U=
+go.opentelemetry.io/otel v1.32.0/go.mod h1:00DCVSB0RQcnzlwyTfqtxSm+DRr9hpYrHjNGiBHVQIg=
+go.opentelemetry.io/otel/metric v1.32.0 h1:xV2umtmNcThh2/a/aCP+h64Xx5wsj8qqnkYZktzNa0M=
+go.opentelemetry.io/otel/metric v1.32.0/go.mod h1:jH7CIbbK6SH2V2wE16W05BHCtIDzauciCRLoc/SyMv8=
+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.32.0 h1:WIC9mYrXf8TmY/EXuULKc8hR17vE+Hjv2cssQDe03fM=
+go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L+V8e9a7YvhT8=
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=
@@ -536,13 +551,13 @@ 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.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
+golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
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/exp v0.0.0-20241215155358-4a5509556b9e h1:4qufH0hlUYs6AO6XmZC3GqfDPGSXHVXUFR6OND+iJX4=
+golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
+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.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@@ -553,8 +568,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=
@@ -567,11 +582,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.32.0 h1:ZqPmj8Kzc+Y6e0+skZsuACbx+wzMgo5MQsJh9Qd6aYI=
+golang.org/x/net v0.32.0/go.mod h1:CwU0IoeOlnQQWJ6ioyFrfRuomB8GKF6KbYXZVyeXNfs=
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.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
+golang.org/x/oauth2 v0.24.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=
@@ -580,8 +595,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=
@@ -622,8 +637,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.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
+golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
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=
@@ -631,8 +646,8 @@ 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/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.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -646,8 +661,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.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8=
+golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw=
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=
@@ -661,19 +676,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=
@@ -688,10 +703,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=
@@ -730,8 +745,8 @@ 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.79.0-pre h1:iJ4+ox4kxadiTJRlybF+9Co+CEDIa1dflMPuxUb5gRg=
+tailscale.com v1.79.0-pre/go.mod h1:aNv7W0AEQtUsDOByv8mGZAk5ZGT49gQ3vIaPaol1RCc=
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=
diff --git a/hscontrol/app.go b/hscontrol/app.go
index 1651b8f211..dc735cab1f 100644
--- a/hscontrol/app.go
+++ b/hscontrol/app.go
@@ -24,9 +24,11 @@ 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"
+ "github.com/juanfont/headscale/hscontrol/dns"
"github.com/juanfont/headscale/hscontrol/mapper"
"github.com/juanfont/headscale/hscontrol/notifier"
"github.com/juanfont/headscale/hscontrol/policy"
@@ -88,13 +90,14 @@ type Headscale struct {
DERPMap *tailcfg.DERPMap
DERPServer *derpServer.DERPServer
- polManOnce sync.Once
- polMan policy.PolicyManager
+ polManOnce sync.Once
+ polMan policy.PolicyManager
+ extraRecordMan *dns.ExtraRecordsMan
mapper *mapper.Mapper
nodeNotifier *notifier.Notifier
- registrationCache *zcache.Cache[string, types.Node]
+ registrationCache *zcache.Cache[types.RegistrationID, types.RegisterNode]
authProvider AuthProvider
@@ -121,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,
)
@@ -184,7 +187,7 @@ func NewHeadscale(cfg *types.Config) (*Headscale, error) {
}
app.authProvider = authProvider
- if app.cfg.DNSConfig != nil && app.cfg.DNSConfig.Proxied { // if MagicDNS
+ if app.cfg.TailcfgDNSConfig != nil && app.cfg.TailcfgDNSConfig.Proxied { // if MagicDNS
// TODO(kradalby): revisit why this takes a list.
var magicDNSDomains []dnsname.FQDN
@@ -196,11 +199,11 @@ func NewHeadscale(cfg *types.Config) (*Headscale, error) {
}
// we might have routes already from Split DNS
- if app.cfg.DNSConfig.Routes == nil {
- app.cfg.DNSConfig.Routes = make(map[string][]*dnstype.Resolver)
+ if app.cfg.TailcfgDNSConfig.Routes == nil {
+ app.cfg.TailcfgDNSConfig.Routes = make(map[string][]*dnstype.Resolver)
}
for _, d := range magicDNSDomains {
- app.cfg.DNSConfig.Routes[d.WithoutTrailingDot()] = nil
+ app.cfg.TailcfgDNSConfig.Routes[d.WithoutTrailingDot()] = nil
}
}
@@ -237,23 +240,38 @@ func (h *Headscale) redirect(w http.ResponseWriter, req *http.Request) {
http.Redirect(w, req, target, http.StatusFound)
}
-// expireExpiredNodes expires nodes that have an explicit expiry set
-// after that expiry time has passed.
-func (h *Headscale) expireExpiredNodes(ctx context.Context, every time.Duration) {
- ticker := time.NewTicker(every)
+func (h *Headscale) scheduledTasks(ctx context.Context) {
+ expireTicker := time.NewTicker(updateInterval)
+ defer expireTicker.Stop()
- lastCheck := time.Unix(0, 0)
- var update types.StateUpdate
- var changed bool
+ lastExpiryCheck := time.Unix(0, 0)
+
+ 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
+ if h.extraRecordMan != nil {
+ extraRecordsUpdate = h.extraRecordMan.UpdateCh()
+ } else {
+ extraRecordsUpdate = make(chan []tailcfg.DNSRecord)
+ }
for {
select {
case <-ctx.Done():
- ticker.Stop()
+ log.Info().Caller().Msg("scheduled task worker is shutting down.")
return
- case <-ticker.C:
+
+ case <-expireTicker.C:
+ var update types.StateUpdate
+ var changed bool
+
if err := h.db.Write(func(tx *gorm.DB) error {
- lastCheck, update, changed = db.ExpireExpiredNodes(tx, lastCheck)
+ lastExpiryCheck, update, changed = db.ExpireExpiredNodes(tx, lastExpiryCheck)
return nil
}); err != nil {
@@ -267,24 +285,8 @@ func (h *Headscale) expireExpiredNodes(ctx context.Context, every time.Duration)
ctx := types.NotifyCtx(context.Background(), "expire-expired", "na")
h.nodeNotifier.NotifyAll(ctx, update)
}
- }
- }
-}
-
-// scheduledDERPMapUpdateWorker refreshes the DERPMap stored on the global object
-// at a set interval.
-func (h *Headscale) scheduledDERPMapUpdateWorker(cancelChan <-chan struct{}) {
- log.Info().
- Dur("frequency", h.cfg.DERP.UpdateFrequency).
- Msg("Setting up a DERPMap update worker")
- ticker := time.NewTicker(h.cfg.DERP.UpdateFrequency)
-
- for {
- select {
- case <-cancelChan:
- return
- case <-ticker.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 {
@@ -297,6 +299,19 @@ func (h *Headscale) scheduledDERPMapUpdateWorker(cancelChan <-chan struct{}) {
Type: types.StateDERPUpdated,
DERPMap: h.DERPMap,
})
+
+ case records, ok := <-extraRecordsUpdate:
+ if !ok {
+ continue
+ }
+ h.cfg.TailcfgDNSConfig.ExtraRecords = records
+
+ ctx := types.NotifyCtx(context.Background(), "dns-extrarecord", "all")
+ h.nodeNotifier.NotifyAll(ctx, types.StateUpdate{
+ // TODO(kradalby): We can probably do better than sending a full update here,
+ // but for now this will ensure that all of the nodes get the new records.
+ Type: types.StateFullUpdate,
+ })
}
}
}
@@ -440,15 +455,71 @@ func (h *Headscale) ensureUnixSocketIsAbsent() error {
return os.Remove(h.cfg.UnixSocket)
}
+// corsHeaderMiddleware will add an "Access-Control-Allow-Origin" to enable CORS.
+func (h *Headscale) corsHeadersMiddleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // skip disabled CORS endpoints
+ if !h.enabledCorsRoutes(r.URL.Path) {
+ next.ServeHTTP(w, r)
+ return
+ }
+
+ origin := r.Header.Get("Origin")
+ // we compare origin from the allowed Origins list. Then add the header with origin
+ for _, allowedOrigin := range h.cfg.AllowedOrigins.Origins {
+ if allowedOrigin == origin {
+ w.Header().Set("Vary", "Origin")
+ w.Header().Set("Access-Control-Allow-Origin", allowedOrigin)
+ break
+ }
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
+func (h *Headscale) enabledCorsRoutes(routerPath string) bool {
+ // enable all api endpoints
+ if strings.HasPrefix(routerPath, "/api/") {
+ return true
+ }
+
+ // A list of enabled CORS endpoints
+ enabledRoutes := []string{
+ "/health",
+ "/key",
+ "/register/{registration_id}",
+ "/oidc/callback",
+ "/verify",
+ "/derp",
+ "/derp/probe",
+ "/derp/latency-check",
+ "/bootstrap-dns",
+ "/machine/register",
+ "/machine/map",
+ }
+
+ for _, routes := range enabledRoutes {
+ if routes == routerPath {
+ return true
+ }
+ }
+
+ return false
+}
+
func (h *Headscale) createRouter(grpcMux *grpcRuntime.ServeMux) *mux.Router {
router := mux.NewRouter()
router.Use(prometheusMiddleware)
+ if len(h.cfg.AllowedOrigins.Origins) != 0 {
+ router.Use(h.corsHeadersMiddleware)
+ }
+
router.HandleFunc(ts2021UpgradePath, h.NoiseUpgradeHandler).Methods(http.MethodPost, http.MethodGet)
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)
@@ -546,6 +617,11 @@ func (h *Headscale) Serve() error {
spew.Dump(h.cfg)
}
+ log.Info().
+ Caller().
+ Str("minimum_version", capver.TailscaleVersion(MinimumCapVersion)).
+ 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)
@@ -568,12 +644,6 @@ func (h *Headscale) Serve() error {
go h.DERPServer.ServeSTUN()
}
- if h.cfg.DERP.AutoUpdate {
- derpMapCancelChannel := make(chan struct{})
- defer func() { derpMapCancelChannel <- struct{}{} }()
- go h.scheduledDERPMapUpdateWorker(derpMapCancelChannel)
- }
-
if len(h.DERPMap.Regions) == 0 {
return errEmptyInitialDERPMap
}
@@ -591,9 +661,21 @@ func (h *Headscale) Serve() error {
h.ephemeralGC.Schedule(node.ID, h.cfg.EphemeralNodeInactivityTimeout)
}
- expireNodeCtx, expireNodeCancel := context.WithCancel(context.Background())
- defer expireNodeCancel()
- go h.expireExpiredNodes(expireNodeCtx, updateInterval)
+ if h.cfg.DNSConfig.ExtraRecordsPath != "" {
+ h.extraRecordMan, err = dns.NewExtraRecordsManager(h.cfg.DNSConfig.ExtraRecordsPath)
+ if err != nil {
+ return fmt.Errorf("setting up extrarecord manager: %w", err)
+ }
+ h.cfg.TailcfgDNSConfig.ExtraRecords = h.extraRecordMan.Records()
+ go h.extraRecordMan.Run()
+ defer h.extraRecordMan.Close()
+ }
+
+ // Start all scheduled tasks, e.g. expiring nodes, derp updates and
+ // records updates
+ scheduleCtx, scheduleCancel := context.WithCancel(context.Background())
+ defer scheduleCancel()
+ go h.scheduledTasks(scheduleCtx)
if zl.GlobalLevel() == zl.TraceLevel {
zerolog.RespLog = true
@@ -818,6 +900,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")
}
@@ -847,7 +933,7 @@ func (h *Headscale) Serve() error {
Str("signal", sig.String()).
Msg("Received signal to stop, shutting down gracefully")
- expireNodeCancel()
+ scheduleCancel()
h.ephemeralGC.Close()
// Gracefully shut down servers
@@ -1082,6 +1168,10 @@ func (h *Headscale) policyBytes() ([]byte, error) {
return nil, err
}
+ if p.Data == "" {
+ return nil, nil
+ }
+
return []byte(p.Data), err
}
diff --git a/hscontrol/auth.go b/hscontrol/auth.go
index 2b23aad3d7..9e22660d46 100644
--- a/hscontrol/auth.go
+++ b/hscontrol/auth.go
@@ -6,6 +6,8 @@ import (
"errors"
"fmt"
"net/http"
+ "net/url"
+ "strings"
"time"
"github.com/juanfont/headscale/hscontrol/db"
@@ -20,16 +22,18 @@ 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,
+ registrationId types.RegistrationID,
) (func(string), func(string), func(error, string)) {
return func(msg string) {
log.Info().
Caller().
+ Str("registration_id", registrationId.String()).
Str("machine_key", machineKey.ShortString()).
Str("node_key", registerRequest.NodeKey.ShortString()).
Str("node_key_old", registerRequest.OldNodeKey.ShortString()).
@@ -41,6 +45,7 @@ func logAuthFunc(
func(msg string) {
log.Trace().
Caller().
+ Str("registration_id", registrationId.String()).
Str("machine_key", machineKey.ShortString()).
Str("node_key", registerRequest.NodeKey.ShortString()).
Str("node_key_old", registerRequest.OldNodeKey.ShortString()).
@@ -52,6 +57,7 @@ func logAuthFunc(
func(err error, msg string) {
log.Error().
Caller().
+ Str("registration_id", registrationId.String()).
Str("machine_key", machineKey.ShortString()).
Str("node_key", registerRequest.NodeKey.ShortString()).
Str("node_key_old", registerRequest.OldNodeKey.ShortString()).
@@ -63,6 +69,40 @@ func logAuthFunc(
}
}
+func (h *Headscale) waitForFollowup(
+ req *http.Request,
+ regReq tailcfg.RegisterRequest,
+ logTrace func(string),
+) {
+ logTrace("register request is a followup")
+ fu, err := url.Parse(regReq.Followup)
+ if err != nil {
+ logTrace("failed to parse followup URL")
+ return
+ }
+
+ followupReg, err := types.RegistrationIDFromString(strings.ReplaceAll(fu.Path, "/register/", ""))
+ if err != nil {
+ logTrace("followup URL does not contains a valid registration ID")
+ return
+ }
+
+ logTrace(fmt.Sprintf("followup URL contains a valid registration ID, looking up in cache: %s", followupReg))
+
+ if reg, ok := h.registrationCache.Get(followupReg); ok {
+ logTrace("Node is waiting for interactive login")
+
+ select {
+ case <-req.Context().Done():
+ logTrace("node went away before it was registered")
+ return
+ case <-reg.Registered:
+ logTrace("node has successfully registered")
+ return
+ }
+ }
+}
+
// handleRegister is the logic for registering a client.
func (h *Headscale) handleRegister(
writer http.ResponseWriter,
@@ -70,9 +110,23 @@ func (h *Headscale) handleRegister(
regReq tailcfg.RegisterRequest,
machineKey key.MachinePublic,
) {
- logInfo, logTrace, _ := logAuthFunc(regReq, machineKey)
+ registrationId, err := types.NewRegistrationID()
+ if err != nil {
+ log.Error().
+ Caller().
+ Err(err).
+ Msg("Failed to generate registration ID")
+ http.Error(writer, "Internal server error", http.StatusInternalServerError)
+
+ return
+ }
+
+ logInfo, logTrace, _ := logAuthFunc(regReq, machineKey, registrationId)
now := time.Now().UTC()
logTrace("handleRegister called, looking up machine in DB")
+
+ // TODO(kradalby): Use reqs NodeKey and OldNodeKey as indicators for new registrations vs
+ // key refreshes. This will allow us to remove the machineKey from the registration request.
node, err := h.db.GetNodeByAnyKey(machineKey, regReq.NodeKey, regReq.OldNodeKey)
logTrace("handleRegister database lookup has returned")
if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -84,27 +138,9 @@ func (h *Headscale) handleRegister(
}
// 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")
-
- select {
- case <-req.Context().Done():
- return
- case <-time.After(registrationHoldoff):
- h.handleNewNode(writer, regReq, machineKey)
-
- return
- }
- }
+ h.waitForFollowup(req, regReq, logTrace)
+ return
}
logInfo("Node not found in database, creating new")
@@ -113,25 +149,28 @@ func (h *Headscale) handleRegister(
// 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{},
+ newNode := types.RegisterNode{
+ Node: types.Node{
+ MachineKey: machineKey,
+ Hostname: regReq.Hostinfo.Hostname,
+ NodeKey: regReq.NodeKey,
+ LastSeen: &now,
+ Expiry: &time.Time{},
+ },
+ Registered: make(chan struct{}),
}
if !regReq.Expiry.IsZero() {
logTrace("Non-zero expiry time requested")
- newNode.Expiry = ®Req.Expiry
+ newNode.Node.Expiry = ®Req.Expiry
}
h.registrationCache.Set(
- machineKey.String(),
+ registrationId,
newNode,
)
- h.handleNewNode(writer, regReq, machineKey)
+ h.handleNewNode(writer, regReq, registrationId)
return
}
@@ -206,27 +245,28 @@ func (h *Headscale) handleRegister(
}
if regReq.Followup != "" {
- select {
- case <-req.Context().Done():
- return
- case <-time.After(registrationHoldoff):
- }
+ h.waitForFollowup(req, regReq, logTrace)
+ return
}
// The node has expired or it is logged out
- h.handleNodeExpiredOrLoggedOut(writer, regReq, *node, machineKey)
+ h.handleNodeExpiredOrLoggedOut(writer, regReq, *node, machineKey, registrationId)
// TODO(juan): RegisterRequest includes an Expiry time, that we could optionally use
node.Expiry = &time.Time{}
+ // TODO(kradalby): do we need to rethink this as part of authflow?
// 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,
+ registrationId,
+ types.RegisterNode{
+ Node: *node,
+ Registered: make(chan struct{}),
+ },
)
return
@@ -296,6 +336,8 @@ func (h *Headscale) handleAuthKey(
// The error is not important, because if it does not
// exist, then this is a new node and we will move
// on to registration.
+ // TODO(kradalby): Use reqs NodeKey and OldNodeKey as indicators for new registrations vs
+ // key refreshes. This will allow us to remove the machineKey from the registration request.
node, _ := h.db.GetNodeByAnyKey(machineKey, registerRequest.NodeKey, registerRequest.OldNodeKey)
if node != nil {
log.Trace().
@@ -390,7 +432,6 @@ func (h *Headscale) handleAuthKey(
http.Error(writer, "Internal server error", http.StatusInternalServerError)
return
}
-
}
err = h.db.Write(func(tx *gorm.DB) error {
@@ -445,16 +486,16 @@ func (h *Headscale) handleAuthKey(
func (h *Headscale) handleNewNode(
writer http.ResponseWriter,
registerRequest tailcfg.RegisterRequest,
- machineKey key.MachinePublic,
+ registrationId types.RegistrationID,
) {
- logInfo, logTrace, logErr := logAuthFunc(registerRequest, machineKey)
+ logInfo, logTrace, logErr := logAuthFunc(registerRequest, key.MachinePublic{}, registrationId)
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")
+ logTrace("The node is new, sending auth url")
- resp.AuthURL = h.authProvider.AuthURL(machineKey)
+ resp.AuthURL = h.authProvider.AuthURL(registrationId)
respBody, err := json.Marshal(resp)
if err != nil {
@@ -661,6 +702,7 @@ func (h *Headscale) handleNodeExpiredOrLoggedOut(
regReq tailcfg.RegisterRequest,
node types.Node,
machineKey key.MachinePublic,
+ registrationId types.RegistrationID,
) {
resp := tailcfg.RegisterResponse{}
@@ -674,12 +716,12 @@ func (h *Headscale) handleNodeExpiredOrLoggedOut(
log.Trace().
Caller().
Str("node", node.Hostname).
- Str("machine_key", machineKey.ShortString()).
+ Str("registration_id", registrationId.String()).
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)
+ resp.AuthURL = h.authProvider.AuthURL(registrationId)
respBody, err := json.Marshal(resp)
if err != nil {
@@ -704,7 +746,7 @@ func (h *Headscale) handleNodeExpiredOrLoggedOut(
log.Trace().
Caller().
- Str("machine_key", machineKey.ShortString()).
+ Str("registration_id", registrationId.String()).
Str("node_key", regReq.NodeKey.ShortString()).
Str("node_key_old", regReq.OldNodeKey.ShortString()).
Str("node", node.Hostname).
diff --git a/hscontrol/auth_noise.go b/hscontrol/auth_noise.go
deleted file mode 100644
index 6659dfa527..0000000000
--- a/hscontrol/auth_noise.go
+++ /dev/null
@@ -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, ®isterRequest); 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())
-}
diff --git a/hscontrol/capver/capver.go b/hscontrol/capver/capver.go
new file mode 100644
index 0000000000..8dc7a437f5
--- /dev/null
+++ b/hscontrol/capver/capver.go
@@ -0,0 +1,92 @@
+package capver
+
+import (
+ "sort"
+ "strings"
+
+ xmaps "golang.org/x/exp/maps"
+ "tailscale.com/tailcfg"
+ "tailscale.com/util/set"
+)
+
+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:]
+}
diff --git a/hscontrol/capver/capver_generated.go b/hscontrol/capver/capver_generated.go
new file mode 100644
index 0000000000..d5a1f3d987
--- /dev/null
+++ b/hscontrol/capver/capver_generated.go
@@ -0,0 +1,54 @@
+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,
+}
+
+
+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",
+}
diff --git a/hscontrol/capver/capver_test.go b/hscontrol/capver/capver_test.go
new file mode 100644
index 0000000000..8d4659e1d3
--- /dev/null
+++ b/hscontrol/capver/capver_test.go
@@ -0,0 +1,53 @@
+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.74", "v1.76", "v1.78"}},
+ {2, true, []string{"1.76", "1.78"}},
+ {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)
+ }
+ })
+ }
+}
diff --git a/hscontrol/capver/gen/main.go b/hscontrol/capver/gen/main.go
new file mode 100644
index 0000000000..3b31686d79
--- /dev/null
+++ b/hscontrol/capver/gen/main.go
@@ -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)
+}
diff --git a/hscontrol/db/db.go b/hscontrol/db/db.go
index 0d9120c21b..9f208ca9a0 100644
--- a/hscontrol/db/db.go
+++ b/hscontrol/db/db.go
@@ -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 {
@@ -478,6 +478,38 @@ 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
@@ -521,6 +553,35 @@ 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 err
+ }
+
+ return nil
+ },
+ Rollback: func(db *gorm.DB) error { return nil },
+ },
},
)
diff --git a/hscontrol/db/db_test.go b/hscontrol/db/db_test.go
index bafe1e1b76..8ca773033c 100644
--- a/hscontrol/db/db_test.go
+++ b/hscontrol/db/db_test.go
@@ -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)
}
@@ -256,8 +260,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 +282,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 +335,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 +358,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)
},
},
@@ -373,6 +377,60 @@ func TestConstraints(t *testing.T) {
tt.run(t, db.DB.Debug())
})
+ }
+}
+
+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)
+ }
+ })
}
}
diff --git a/hscontrol/db/node.go b/hscontrol/db/node.go
index 1c2a165cb3..f722d9ab16 100644
--- a/hscontrol/db/node.go
+++ b/hscontrol/db/node.go
@@ -158,6 +158,30 @@ func GetNodeByMachineKey(
return &mach, nil
}
+func (hsdb *HSDatabase) GetNodeByNodeKey(nodeKey key.NodePublic) (*types.Node, error) {
+ return Read(hsdb.DB, func(rx *gorm.DB) (*types.Node, error) {
+ return GetNodeByNodeKey(rx, nodeKey)
+ })
+}
+
+// GetNodeByNodeKey finds a Node by its NodeKey and returns the Node struct.
+func GetNodeByNodeKey(
+ tx *gorm.DB,
+ nodeKey key.NodePublic,
+) (*types.Node, error) {
+ mach := types.Node{}
+ if result := tx.
+ Preload("AuthKey").
+ Preload("AuthKey.User").
+ Preload("User").
+ Preload("Routes").
+ First(&mach, "node_key = ?", nodeKey.String()); result.Error != nil {
+ return nil, result.Error
+ }
+
+ return &mach, nil
+}
+
func (hsdb *HSDatabase) GetNodeByAnyKey(
machineKey key.MachinePublic,
nodeKey key.NodePublic,
@@ -245,7 +269,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 +343,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,
)
- }
-
- 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())
+ 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
}
-
- 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) {
@@ -630,7 +677,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 +696,7 @@ func ensureUniqueGivenName(
return "", err
}
- unique, err := isUnqiueName(tx, givenName)
+ unique, err := isUniqueName(tx, givenName)
if err != nil {
return "", err
}
diff --git a/hscontrol/db/node_test.go b/hscontrol/db/node_test.go
index 7c83c1be42..270fd91b2e 100644
--- a/hscontrol/db/node_test.go
+++ b/hscontrol/db/node_test.go
@@ -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)
@@ -85,7 +85,7 @@ func (s *Suite) TestGetNodeByID(c *check.C) {
}
func (s *Suite) TestGetNodeByAnyNodeKey(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)
@@ -116,7 +116,7 @@ func (s *Suite) TestGetNodeByAnyNodeKey(c *check.C) {
}
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 +141,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 +188,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 +279,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 +320,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 +565,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 +706,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 +762,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{
diff --git a/hscontrol/db/preauth_keys_test.go b/hscontrol/db/preauth_keys_test.go
index 3c56a35e8c..a3a24ac72d 100644
--- a/hscontrol/db/preauth_keys_test.go
+++ b/hscontrol/db/preauth_keys_test.go
@@ -15,7 +15,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)
@@ -41,7 +41,7 @@ func (*Suite) TestCreatePreAuthKey(c *check.C) {
}
func (*Suite) TestExpiredPreAuthKey(c *check.C) {
- user, err := db.CreateUser("test2")
+ user, err := db.CreateUser(types.User{Name: "test2"})
c.Assert(err, check.IsNil)
now := time.Now().Add(-5 * time.Second)
@@ -60,7 +60,7 @@ func (*Suite) TestPreAuthKeyDoesNotExist(c *check.C) {
}
func (*Suite) TestValidateKeyOk(c *check.C) {
- user, err := db.CreateUser("test3")
+ user, err := db.CreateUser(types.User{Name: "test3"})
c.Assert(err, check.IsNil)
pak, err := db.CreatePreAuthKey(types.UserID(user.ID), true, false, nil, nil)
@@ -72,7 +72,7 @@ func (*Suite) TestValidateKeyOk(c *check.C) {
}
func (*Suite) TestAlreadyUsedKey(c *check.C) {
- user, err := db.CreateUser("test4")
+ user, err := db.CreateUser(types.User{Name: "test4"})
c.Assert(err, check.IsNil)
pak, err := db.CreatePreAuthKey(types.UserID(user.ID), false, false, nil, nil)
@@ -94,7 +94,7 @@ func (*Suite) TestAlreadyUsedKey(c *check.C) {
}
func (*Suite) TestReusableBeingUsedKey(c *check.C) {
- user, err := db.CreateUser("test5")
+ user, err := db.CreateUser(types.User{Name: "test5"})
c.Assert(err, check.IsNil)
pak, err := db.CreatePreAuthKey(types.UserID(user.ID), true, false, nil, nil)
@@ -116,7 +116,7 @@ func (*Suite) TestReusableBeingUsedKey(c *check.C) {
}
func (*Suite) TestNotReusableNotBeingUsedKey(c *check.C) {
- user, err := db.CreateUser("test6")
+ user, err := db.CreateUser(types.User{Name: "test6"})
c.Assert(err, check.IsNil)
pak, err := db.CreatePreAuthKey(types.UserID(user.ID), false, false, nil, nil)
@@ -128,7 +128,7 @@ func (*Suite) TestNotReusableNotBeingUsedKey(c *check.C) {
}
func (*Suite) TestExpirePreauthKey(c *check.C) {
- user, err := db.CreateUser("test3")
+ user, err := db.CreateUser(types.User{Name: "test3"})
c.Assert(err, check.IsNil)
pak, err := db.CreatePreAuthKey(types.UserID(user.ID), true, false, nil, nil)
@@ -145,7 +145,7 @@ func (*Suite) TestExpirePreauthKey(c *check.C) {
}
func (*Suite) TestNotReusableMarkedAsUsed(c *check.C) {
- user, err := db.CreateUser("test6")
+ user, err := db.CreateUser(types.User{Name: "test6"})
c.Assert(err, check.IsNil)
pak, err := db.CreatePreAuthKey(types.UserID(user.ID), false, false, nil, nil)
@@ -158,7 +158,7 @@ func (*Suite) TestNotReusableMarkedAsUsed(c *check.C) {
}
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"})
diff --git a/hscontrol/db/routes.go b/hscontrol/db/routes.go
index 0a72c4278e..8d86145ac8 100644
--- a/hscontrol/db/routes.go
+++ b/hscontrol/db/routes.go
@@ -117,13 +117,13 @@ func EnableRoute(tx *gorm.DB, id uint64) (*types.StateUpdate, error) {
if route.IsExitRoute() {
return enableRoutes(
tx,
- &route.Node,
+ route.Node,
tsaddr.AllIPv4(),
tsaddr.AllIPv6(),
)
}
- return enableRoutes(tx, &route.Node, netip.Prefix(route.Prefix))
+ return enableRoutes(tx, route.Node, netip.Prefix(route.Prefix))
}
func DisableRoute(tx *gorm.DB,
@@ -154,7 +154,7 @@ func DisableRoute(tx *gorm.DB,
return nil, err
}
} else {
- routes, err = GetNodeRoutes(tx, &node)
+ routes, err = GetNodeRoutes(tx, node)
if err != nil {
return nil, err
}
@@ -201,24 +201,26 @@ func DeleteRoute(
return nil, err
}
+ if route.Node == nil {
+ // If the route is not assigned to a node, just delete it,
+ // there are no updates to be sent as no nodes are
+ // dependent on it
+ if err := tx.Unscoped().Delete(&route).Error; err != nil {
+ return nil, err
+ }
+ return nil, nil
+ }
+
var routes types.Routes
node := route.Node
// Tailscale requires both IPv4 and IPv6 exit routes to
// be enabled at the same time, as per
// https://github.com/juanfont/headscale/issues/804#issuecomment-1399314002
+ // This means that if we delete a route which is an exit route, delete both.
var update []types.NodeID
- if !route.IsExitRoute() {
- update, err = failoverRouteTx(tx, isLikelyConnected, route)
- if err != nil {
- return nil, nil
- }
-
- if err := tx.Unscoped().Delete(&route).Error; err != nil {
- return nil, err
- }
- } else {
- routes, err = GetNodeRoutes(tx, &node)
+ if route.IsExitRoute() {
+ routes, err = GetNodeRoutes(tx, node)
if err != nil {
return nil, err
}
@@ -233,13 +235,22 @@ func DeleteRoute(
if err := tx.Unscoped().Delete(&routesToDelete).Error; err != nil {
return nil, err
}
+ } else {
+ update, err = failoverRouteTx(tx, isLikelyConnected, route)
+ if err != nil {
+ return nil, nil
+ }
+
+ if err := tx.Unscoped().Delete(&route).Error; err != nil {
+ return nil, err
+ }
}
// If update is empty, it means that one was not created
// by failover (as a failover was not necessary), create
// one and return to the caller.
if routes == nil {
- routes, err = GetNodeRoutes(tx, &node)
+ routes, err = GetNodeRoutes(tx, node)
if err != nil {
return nil, err
}
@@ -406,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,
@@ -462,7 +473,7 @@ nodeRouteLoop:
return &types.StateUpdate{
Type: types.StatePeerChanged,
ChangeNodes: chng,
- Message: "called from db.FailoverNodeRoutesIfNeccessary",
+ Message: "called from db.FailoverNodeRoutesIfNecessary",
}, nil
}
diff --git a/hscontrol/db/routes_test.go b/hscontrol/db/routes_test.go
index 7b11e1365d..4547339a25 100644
--- a/hscontrol/db/routes_test.go
+++ b/hscontrol/db/routes_test.go
@@ -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)
@@ -290,23 +290,18 @@ func (s *Suite) TestDeleteRoutes(c *check.C) {
}
var (
- ipp = func(s string) netip.Prefix { return netip.MustParsePrefix(s) }
- mkNode = func(nid types.NodeID) types.Node {
- return types.Node{ID: nid}
+ ipp = func(s string) netip.Prefix { return netip.MustParsePrefix(s) }
+ np = func(nid types.NodeID) *types.Node {
+ return &types.Node{ID: nid}
}
)
-var np = func(nid types.NodeID) *types.Node {
- no := mkNode(nid)
- return &no
-}
-
var r = func(id uint, nid types.NodeID, prefix netip.Prefix, enabled, primary bool) types.Route {
return types.Route{
Model: gorm.Model{
ID: id,
},
- Node: mkNode(nid),
+ Node: np(nid),
Prefix: prefix,
Enabled: enabled,
IsPrimary: primary,
@@ -347,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,
@@ -653,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 {
@@ -693,7 +688,7 @@ func TestFailoverRouteTx(t *testing.T) {
ID: 1,
},
Prefix: ipp("10.0.0.0/24"),
- Node: types.Node{},
+ Node: &types.Node{},
IsPrimary: false,
},
routes: types.Routes{},
@@ -707,7 +702,7 @@ func TestFailoverRouteTx(t *testing.T) {
ID: 1,
},
Prefix: ipp("0.0.0.0/0"),
- Node: types.Node{},
+ Node: &types.Node{},
IsPrimary: true,
},
routes: types.Routes{},
@@ -721,7 +716,7 @@ func TestFailoverRouteTx(t *testing.T) {
ID: 1,
},
Prefix: ipp("10.0.0.0/24"),
- Node: types.Node{
+ Node: &types.Node{
ID: 1,
},
IsPrimary: true,
@@ -732,7 +727,7 @@ func TestFailoverRouteTx(t *testing.T) {
ID: 1,
},
Prefix: ipp("10.0.0.0/24"),
- Node: types.Node{
+ Node: &types.Node{
ID: 1,
},
IsPrimary: true,
@@ -748,7 +743,7 @@ func TestFailoverRouteTx(t *testing.T) {
ID: 1,
},
Prefix: ipp("10.0.0.0/24"),
- Node: types.Node{
+ Node: &types.Node{
ID: 1,
},
IsPrimary: true,
@@ -760,7 +755,7 @@ func TestFailoverRouteTx(t *testing.T) {
ID: 1,
},
Prefix: ipp("10.0.0.0/24"),
- Node: types.Node{
+ Node: &types.Node{
ID: 1,
},
IsPrimary: true,
@@ -771,7 +766,7 @@ func TestFailoverRouteTx(t *testing.T) {
ID: 2,
},
Prefix: ipp("10.0.0.0/24"),
- Node: types.Node{
+ Node: &types.Node{
ID: 2,
},
IsPrimary: false,
@@ -795,7 +790,7 @@ func TestFailoverRouteTx(t *testing.T) {
ID: 1,
},
Prefix: ipp("10.0.0.0/24"),
- Node: types.Node{
+ Node: &types.Node{
ID: 1,
},
IsPrimary: false,
@@ -807,7 +802,7 @@ func TestFailoverRouteTx(t *testing.T) {
ID: 1,
},
Prefix: ipp("10.0.0.0/24"),
- Node: types.Node{
+ Node: &types.Node{
ID: 1,
},
IsPrimary: true,
@@ -818,7 +813,7 @@ func TestFailoverRouteTx(t *testing.T) {
ID: 2,
},
Prefix: ipp("10.0.0.0/24"),
- Node: types.Node{
+ Node: &types.Node{
ID: 2,
},
IsPrimary: false,
@@ -835,7 +830,7 @@ func TestFailoverRouteTx(t *testing.T) {
ID: 2,
},
Prefix: ipp("10.0.0.0/24"),
- Node: types.Node{
+ Node: &types.Node{
ID: 2,
},
IsPrimary: true,
@@ -847,7 +842,7 @@ func TestFailoverRouteTx(t *testing.T) {
ID: 1,
},
Prefix: ipp("10.0.0.0/24"),
- Node: types.Node{
+ Node: &types.Node{
ID: 1,
},
IsPrimary: false,
@@ -858,7 +853,7 @@ func TestFailoverRouteTx(t *testing.T) {
ID: 2,
},
Prefix: ipp("10.0.0.0/24"),
- Node: types.Node{
+ Node: &types.Node{
ID: 2,
},
IsPrimary: true,
@@ -869,7 +864,7 @@ func TestFailoverRouteTx(t *testing.T) {
ID: 3,
},
Prefix: ipp("10.0.0.0/24"),
- Node: types.Node{
+ Node: &types.Node{
ID: 3,
},
IsPrimary: false,
@@ -893,7 +888,7 @@ func TestFailoverRouteTx(t *testing.T) {
ID: 1,
},
Prefix: ipp("10.0.0.0/24"),
- Node: types.Node{
+ Node: &types.Node{
ID: 1,
},
IsPrimary: true,
@@ -905,7 +900,7 @@ func TestFailoverRouteTx(t *testing.T) {
ID: 1,
},
Prefix: ipp("10.0.0.0/24"),
- Node: types.Node{
+ Node: &types.Node{
ID: 1,
},
IsPrimary: true,
@@ -917,7 +912,7 @@ func TestFailoverRouteTx(t *testing.T) {
ID: 2,
},
Prefix: ipp("10.0.0.0/24"),
- Node: types.Node{
+ Node: &types.Node{
ID: 4,
},
IsPrimary: false,
@@ -938,7 +933,7 @@ func TestFailoverRouteTx(t *testing.T) {
ID: 1,
},
Prefix: ipp("10.0.0.0/24"),
- Node: types.Node{
+ Node: &types.Node{
ID: 1,
},
IsPrimary: true,
@@ -950,7 +945,7 @@ func TestFailoverRouteTx(t *testing.T) {
ID: 1,
},
Prefix: ipp("10.0.0.0/24"),
- Node: types.Node{
+ Node: &types.Node{
ID: 1,
},
IsPrimary: true,
@@ -962,7 +957,7 @@ func TestFailoverRouteTx(t *testing.T) {
ID: 2,
},
Prefix: ipp("10.0.0.0/24"),
- Node: types.Node{
+ Node: &types.Node{
ID: 4,
},
IsPrimary: false,
@@ -973,7 +968,7 @@ func TestFailoverRouteTx(t *testing.T) {
ID: 3,
},
Prefix: ipp("10.0.0.0/24"),
- Node: types.Node{
+ Node: &types.Node{
ID: 2,
},
IsPrimary: true,
@@ -998,7 +993,7 @@ func TestFailoverRouteTx(t *testing.T) {
ID: 1,
},
Prefix: ipp("10.0.0.0/24"),
- Node: types.Node{
+ Node: &types.Node{
ID: 1,
},
IsPrimary: true,
@@ -1010,7 +1005,7 @@ func TestFailoverRouteTx(t *testing.T) {
ID: 1,
},
Prefix: ipp("10.0.0.0/24"),
- Node: types.Node{
+ Node: &types.Node{
ID: 1,
},
IsPrimary: true,
@@ -1022,7 +1017,7 @@ func TestFailoverRouteTx(t *testing.T) {
ID: 2,
},
Prefix: ipp("10.0.0.0/24"),
- Node: types.Node{
+ Node: &types.Node{
ID: 2,
},
IsPrimary: false,
@@ -1075,7 +1070,7 @@ func TestFailoverRoute(t *testing.T) {
Model: gorm.Model{
ID: id,
},
- Node: types.Node{
+ Node: &types.Node{
ID: nid,
},
Prefix: prefix,
diff --git a/hscontrol/db/suite_test.go b/hscontrol/db/suite_test.go
index fb7ce1df50..e9c71823e7 100644
--- a/hscontrol/db/suite_test.go
+++ b/hscontrol/db/suite_test.go
@@ -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{
diff --git a/hscontrol/db/testdata/pre-24-postgresdb.pssql.dump b/hscontrol/db/testdata/pre-24-postgresdb.pssql.dump
new file mode 100644
index 0000000000..7f8df28b30
Binary files /dev/null and b/hscontrol/db/testdata/pre-24-postgresdb.pssql.dump differ
diff --git a/hscontrol/db/users.go b/hscontrol/db/users.go
index 0eaa9ea348..c359174df6 100644
--- a/hscontrol/db/users.go
+++ b/hscontrol/db/users.go
@@ -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)
}
@@ -92,7 +89,7 @@ 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
}
diff --git a/hscontrol/db/users_test.go b/hscontrol/db/users_test.go
index 0607376277..6cec2d5a79 100644
--- a/hscontrol/db/users_test.go
+++ b/hscontrol/db/users_test.go
@@ -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)
diff --git a/hscontrol/dns/extrarecords.go b/hscontrol/dns/extrarecords.go
new file mode 100644
index 0000000000..e667c56208
--- /dev/null
+++ b/hscontrol/dns/extrarecords.go
@@ -0,0 +1,194 @@
+package dns
+
+import (
+ "crypto/sha256"
+ "encoding/json"
+ "fmt"
+ "os"
+ "sync"
+
+ "github.com/cenkalti/backoff/v4"
+ "github.com/fsnotify/fsnotify"
+ "github.com/rs/zerolog/log"
+ "tailscale.com/tailcfg"
+ "tailscale.com/util/set"
+)
+
+type ExtraRecordsMan struct {
+ mu sync.RWMutex
+ records set.Set[tailcfg.DNSRecord]
+ watcher *fsnotify.Watcher
+ path string
+
+ updateCh chan []tailcfg.DNSRecord
+ closeCh chan struct{}
+ hashes map[string][32]byte
+}
+
+// NewExtraRecordsManager creates a new ExtraRecordsMan and starts watching the file at the given path.
+func NewExtraRecordsManager(path string) (*ExtraRecordsMan, error) {
+ watcher, err := fsnotify.NewWatcher()
+ if err != nil {
+ return nil, fmt.Errorf("creating watcher: %w", err)
+ }
+
+ fi, err := os.Stat(path)
+ if err != nil {
+ return nil, fmt.Errorf("getting file info: %w", err)
+ }
+
+ if fi.IsDir() {
+ return nil, fmt.Errorf("path is a directory, only file is supported: %s", path)
+ }
+
+ records, hash, err := readExtraRecordsFromPath(path)
+ if err != nil {
+ return nil, fmt.Errorf("reading extra records from path: %w", err)
+ }
+
+ er := &ExtraRecordsMan{
+ watcher: watcher,
+ path: path,
+ records: set.SetOf(records),
+ hashes: map[string][32]byte{
+ path: hash,
+ },
+ closeCh: make(chan struct{}),
+ updateCh: make(chan []tailcfg.DNSRecord),
+ }
+
+ err = watcher.Add(path)
+ if err != nil {
+ return nil, fmt.Errorf("adding path to watcher: %w", err)
+ }
+
+ log.Trace().Caller().Strs("watching", watcher.WatchList()).Msg("started filewatcher")
+
+ return er, nil
+}
+
+func (e *ExtraRecordsMan) Records() []tailcfg.DNSRecord {
+ e.mu.RLock()
+ defer e.mu.RUnlock()
+
+ return e.records.Slice()
+}
+
+func (e *ExtraRecordsMan) Run() {
+ for {
+ select {
+ case <-e.closeCh:
+ return
+ case event, ok := <-e.watcher.Events:
+ if !ok {
+ 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()
+
+ // 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()
+ }
+ }
+
+ case err, ok := <-e.watcher.Errors:
+ if !ok {
+ log.Error().Caller().Msgf("file watcher error channel closing")
+ return
+ }
+ log.Error().Caller().Err(err).Msgf("extra records filewatcher returned error: %q", err)
+ }
+ }
+}
+
+func (e *ExtraRecordsMan) Close() {
+ e.watcher.Close()
+ close(e.closeCh)
+}
+
+func (e *ExtraRecordsMan) UpdateCh() <-chan []tailcfg.DNSRecord {
+ return e.updateCh
+}
+
+func (e *ExtraRecordsMan) updateRecords() {
+ records, newHash, err := readExtraRecordsFromPath(e.path)
+ if err != nil {
+ log.Error().Caller().Err(err).Msgf("reading extra records from path: %s", e.path)
+ return
+ }
+
+ // If there are no records, ignore the update.
+ if records == nil {
+ return
+ }
+
+ e.mu.Lock()
+ defer e.mu.Unlock()
+
+ // If there has not been any change, ignore the update.
+ if oldHash, ok := e.hashes[e.path]; ok {
+ if newHash == oldHash {
+ return
+ }
+ }
+
+ oldCount := e.records.Len()
+
+ e.records = set.SetOf(records)
+ e.hashes[e.path] = newHash
+
+ log.Trace().Caller().Interface("records", e.records).Msgf("extra records updated from path, count old: %d, new: %d", oldCount, e.records.Len())
+ e.updateCh <- e.records.Slice()
+}
+
+// readExtraRecordsFromPath reads a JSON file of tailcfg.DNSRecord
+// and returns the records and the hash of the file.
+func readExtraRecordsFromPath(path string) ([]tailcfg.DNSRecord, [32]byte, error) {
+ b, err := os.ReadFile(path)
+ if err != nil {
+ 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 {
+ return nil, [32]byte{}, fmt.Errorf("unmarshalling records, content: %q: %w", string(b), err)
+ }
+
+ hash := sha256.Sum256(b)
+
+ return records, hash, nil
+}
diff --git a/hscontrol/grpcv1.go b/hscontrol/grpcv1.go
index 607ebdc79f..7b1c658115 100644
--- a/hscontrol/grpcv1.go
+++ b/hscontrol/grpcv1.go
@@ -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
}
@@ -218,11 +227,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 +245,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,
@@ -457,19 +465,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 +478,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 +493,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 +838,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() {}
diff --git a/hscontrol/handlers.go b/hscontrol/handlers.go
index 3858df9339..c310aedffe 100644
--- a/hscontrol/handlers.go
+++ b/hscontrol/handlers.go
@@ -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,15 @@ 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, userError string, code int) {
+ log.Error().Err(err).Msg(userError)
+ http.Error(w, userError, code)
+}
+
var ErrRegisterMethodCLIDoesNotSupportExpire = errors.New(
"machines registered with CLI does not support expire",
)
@@ -57,7 +58,7 @@ func parseCabailityVersion(req *http.Request) (tailcfg.CapabilityVersion, error)
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 +85,14 @@ func (h *Headscale) VerifyHandler(
req *http.Request,
) {
if req.Method != http.MethodPost {
- http.Error(writer, "Wrong method", http.StatusMethodNotAllowed)
-
+ httpError(writer, nil, "Wrong method", http.StatusMethodNotAllowed)
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, "Internal error", http.StatusInternalServerError)
+ return
}
resp := tailcfg.DERPAdmitClientResponse{
@@ -106,14 +100,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 +112,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, "Internal error", http.StatusInternalServerError)
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 +143,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 +165,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 +175,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 +192,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, err, "invalid registration ID", http.StatusBadRequest)
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()))
}
diff --git a/hscontrol/mapper/mapper.go b/hscontrol/mapper/mapper.go
index 51c96f8c87..6821d5b69e 100644
--- a/hscontrol/mapper/mapper.go
+++ b/hscontrol/mapper/mapper.go
@@ -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
@@ -116,11 +115,11 @@ func generateDNSConfig(
cfg *types.Config,
node *types.Node,
) *tailcfg.DNSConfig {
- if cfg.DNSConfig == nil {
+ if cfg.TailcfgDNSConfig == nil {
return nil
}
- dnsConfig := cfg.DNSConfig.Clone()
+ dnsConfig := cfg.TailcfgDNSConfig.Clone()
addNextDNSMetadata(dnsConfig.Resolvers, node)
@@ -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
diff --git a/hscontrol/mapper/mapper_test.go b/hscontrol/mapper/mapper_test.go
index 4ee8c6444e..55ab2ccbf7 100644
--- a/hscontrol/mapper/mapper_test.go
+++ b/hscontrol/mapper/mapper_test.go
@@ -117,7 +117,7 @@ func TestDNSConfigMapResponse(t *testing.T) {
got := generateDNSConfig(
&types.Config{
- DNSConfig: &dnsConfigOrig,
+ TailcfgDNSConfig: &dnsConfigOrig,
},
nodeInShared1,
)
@@ -349,7 +349,7 @@ func Test_fullMapResponse(t *testing.T) {
derpMap: &tailcfg.DERPMap{},
cfg: &types.Config{
BaseDomain: "",
- DNSConfig: &tailcfg.DNSConfig{},
+ TailcfgDNSConfig: &tailcfg.DNSConfig{},
LogTail: types.LogTailConfig{Enabled: false},
RandomizeClientPort: false,
},
@@ -381,7 +381,7 @@ func Test_fullMapResponse(t *testing.T) {
derpMap: &tailcfg.DERPMap{},
cfg: &types.Config{
BaseDomain: "",
- DNSConfig: &tailcfg.DNSConfig{},
+ TailcfgDNSConfig: &tailcfg.DNSConfig{},
LogTail: types.LogTailConfig{Enabled: false},
RandomizeClientPort: false,
},
@@ -424,7 +424,7 @@ func Test_fullMapResponse(t *testing.T) {
derpMap: &tailcfg.DERPMap{},
cfg: &types.Config{
BaseDomain: "",
- DNSConfig: &tailcfg.DNSConfig{},
+ TailcfgDNSConfig: &tailcfg.DNSConfig{},
LogTail: types.LogTailConfig{Enabled: false},
RandomizeClientPort: false,
},
diff --git a/hscontrol/mapper/tail_test.go b/hscontrol/mapper/tail_test.go
index 9d7f1fedfb..96c008ab12 100644
--- a/hscontrol/mapper/tail_test.go
+++ b/hscontrol/mapper/tail_test.go
@@ -187,7 +187,7 @@ func TestTailNode(t *testing.T) {
polMan, _ := policy.NewPolicyManagerForTest(tt.pol, []types.User{}, types.Nodes{tt.node})
cfg := &types.Config{
BaseDomain: tt.baseDomain,
- DNSConfig: tt.dnsConfig,
+ TailcfgDNSConfig: tt.dnsConfig,
RandomizeClientPort: false,
}
got, err := tailNode(
diff --git a/hscontrol/noise.go b/hscontrol/noise.go
index 444a8073e5..b4e90f31d0 100644
--- a/hscontrol/noise.go
+++ b/hscontrol/noise.go
@@ -3,15 +3,17 @@ package hscontrol
import (
"encoding/binary"
"encoding/json"
+ "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"
"tailscale.com/control/controlbase"
- "tailscale.com/control/controlhttp"
+ "tailscale.com/control/controlhttp/controlhttpserver"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
)
@@ -71,7 +73,7 @@ func (h *Headscale) NoiseUpgradeHandler(
challenge: key.NewChallenge(),
}
- noiseConn, err := controlhttp.AcceptHTTP(
+ noiseConn, err := controlhttpserver.AcceptHTTP(
req.Context(),
writer,
req,
@@ -79,9 +81,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, err, "noise upgrade failed", http.StatusInternalServerError)
return
}
@@ -115,18 +115,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{
@@ -162,6 +152,30 @@ const (
MinimumCapVersion tailcfg.CapabilityVersion = 82
)
+func isSupportedVersion(version tailcfg.CapabilityVersion) bool {
+ return version >= MinimumCapVersion
+}
+
+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(MinimumCapVersion)).
+ Int("client_cap_ver", int(version)).
+ Str("minimum_version", capver.TailscaleVersion(MinimumCapVersion)).
+ 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
//
// This is the busiest endpoint, as it keeps the HTTP long poll that updates
@@ -177,34 +191,14 @@ 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, "Internal error", http.StatusInternalServerError)
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
}
@@ -216,11 +210,7 @@ func (ns *noiseServer) NoisePollNetMapHandler(
key.NodePublic{},
)
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)
-
+ httpError(writer, err, "Internal error", http.StatusInternalServerError)
return
}
@@ -232,3 +222,32 @@ 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, nil, "Wrong method", http.StatusMethodNotAllowed)
+
+ return
+ }
+
+ body, _ := io.ReadAll(req.Body)
+ var registerRequest tailcfg.RegisterRequest
+ if err := json.Unmarshal(body, ®isterRequest); err != nil {
+ httpError(writer, err, "Internal error", http.StatusInternalServerError)
+
+ return
+ }
+
+ // Reject unsupported versions
+ if rejectUnsupported(writer, registerRequest.Version, ns.machineKey, registerRequest.NodeKey) {
+ return
+ }
+
+ ns.nodeKey = registerRequest.NodeKey
+
+ ns.headscale.handleRegister(writer, req, registerRequest, ns.conn.Peer())
+}
diff --git a/hscontrol/notifier/notifier.go b/hscontrol/notifier/notifier.go
index ceede6ba42..eb1df73a5c 100644
--- a/hscontrol/notifier/notifier.go
+++ b/hscontrol/notifier/notifier.go
@@ -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()
diff --git a/hscontrol/oidc.go b/hscontrol/oidc.go
index 1db1ec079f..8364dee18e 100644
--- a/hscontrol/oidc.go
+++ b/hscontrol/oidc.go
@@ -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, err, "invalid registration ID", http.StatusBadRequest)
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, "Internal server error", http.StatusInternalServerError)
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, "Internal server error", http.StatusInternalServerError)
+ 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))
+
+ // Cache the registration info
+ a.registrationCache.Set(state, registrationInfo)
- authURL := a.oauth2Config.AuthCodeURL(stateStr, extras...)
+ 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,90 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
) {
code, state, err := extractCodeAndStateParamFromRequest(req)
if err != nil {
- http.Error(writer, err.Error(), http.StatusBadRequest)
+ httpError(writer, err, err.Error(), http.StatusBadRequest)
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, err, "state not found", http.StatusBadRequest)
return
}
+
+ if state != cookieState.Value {
+ httpError(writer, err, "state did not match", http.StatusBadRequest)
+ return
+ }
+
+ idToken, err := a.extractIDToken(req.Context(), code, state)
+ if err != nil {
+ httpError(writer, err, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ nonce, err := req.Cookie("nonce")
+ if err != nil {
+ httpError(writer, err, "nonce not found", http.StatusBadRequest)
+ return
+ }
+ if idToken.Nonce != nonce.Value {
+ httpError(writer, err, "nonce did not match", http.StatusBadRequest)
+ 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)
+ err = fmt.Errorf("decoding ID token claims: %w", err)
+ httpError(writer, err, err.Error(), http.StatusInternalServerError)
return
}
if err := validateOIDCAllowedDomains(a.cfg.AllowedDomains, &claims); err != nil {
- http.Error(writer, err.Error(), http.StatusUnauthorized)
+ httpError(writer, err, err.Error(), http.StatusUnauthorized)
return
}
if err := validateOIDCAllowedGroups(a.cfg.AllowedGroups, &claims); err != nil {
- http.Error(writer, err.Error(), http.StatusUnauthorized)
+ httpError(writer, err, err.Error(), http.StatusUnauthorized)
return
}
if err := validateOIDCAllowedUsers(a.cfg.AllowedUsers, &claims); err != nil {
- http.Error(writer, err.Error(), http.StatusUnauthorized)
+ httpError(writer, err, err.Error(), http.StatusUnauthorized)
return
}
user, err := a.createOrUpdateUserFromClaim(&claims)
if err != nil {
- http.Error(writer, err.Error(), http.StatusInternalServerError)
+ httpError(writer, err, err.Error(), http.StatusInternalServerError)
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.handleRegistrationID(user, *registrationId, nodeExpiry)
if err != nil {
- http.Error(writer, err.Error(), http.StatusInternalServerError)
+ httpError(writer, err, err.Error(), http.StatusInternalServerError)
return
}
- // 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
+ if newNode {
+ verb = "Authenticated"
}
- writer.Header().Set("Content-Type", "text/html; charset=utf-8")
- writer.WriteHeader(http.StatusOK)
- _, err = writer.Write(content.Bytes())
- 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)
+ // TODO(kradalby): replace with go-elem
+ content, err := renderOIDCCallbackTemplate(user, verb)
if err != nil {
- http.Error(writer, err.Error(), http.StatusInternalServerError)
+ httpError(writer, err, err.Error(), http.StatusInternalServerError)
return
}
@@ -296,7 +317,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, nil, "login session expired, try again", http.StatusInternalServerError)
return
}
@@ -318,8 +339,21 @@ 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, 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)
}
@@ -383,56 +417,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 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
- }
-
- // 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
+ return nil
}
- 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 ®Info.RegistrationID
}
func (a *AuthProviderOIDC) createOrUpdateUserFromClaim(
@@ -447,7 +445,7 @@ func (a *AuthProviderOIDC) createOrUpdateUserFromClaim(
// This check is for legacy, if the user cannot be found by the OIDC identifier
// look it up by username. This should only be needed once.
- // This branch will presist for a number of versions after the OIDC migration and
+ // This branch will persist for a number of versions after the OIDC migration and
// then be removed following a deprecation.
// TODO(kradalby): Remove when strip_email_domain and migration is removed
// after #2170 is cleaned up.
@@ -490,43 +488,63 @@ func (a *AuthProviderOIDC) createOrUpdateUserFromClaim(
return user, nil
}
-func (a *AuthProviderOIDC) registerNode(
+func (a *AuthProviderOIDC) handleRegistrationID(
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.
+ if newNode {
+ err = nodesChangedHook(a.db, a.polMan, a.notifier)
+ if err != nil {
+ return false, fmt.Errorf("updating resources using node: %w", err)
+ }
+ } else {
+ 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 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)
}
@@ -536,7 +554,7 @@ func renderOIDCCallbackTemplate(
// TODO(kradalby): Reintroduce when strip_email_domain is removed
// after #2170 is cleaned up
-// DEPRECATED: DO NOT USE
+// DEPRECATED: DO NOT USE.
func getUserName(
claims *types.OIDCClaims,
stripEmaildomain bool,
@@ -554,3 +572,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
+}
diff --git a/hscontrol/platform_config.go b/hscontrol/platform_config.go
index dc6174a93d..1855ee2459 100644
--- a/hscontrol/platform_config.go
+++ b/hscontrol/platform_config.go
@@ -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, nil, "No platform specified", http.StatusBadRequest)
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, nil, "Failed to create UUID", http.StatusInternalServerError)
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, nil, "Failed to create UUID", http.StatusInternalServerError)
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, "Could not render Apple macOS template", http.StatusInternalServerError)
return
}
case "macos-app-store":
if err := macosAppStoreTemplate.Execute(&payload, platformConfig); err != nil {
- handleMacError(err)
-
+ httpError(writer, err, "Could not render Apple macOS template", http.StatusInternalServerError)
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, "Could not render Apple iOS template", http.StatusInternalServerError)
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, err, "Invalid platform. Only ios, macos-app-store and macos-standalone are supported", http.StatusInternalServerError)
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, "Could not render platform iOS template", http.StatusInternalServerError)
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 {
diff --git a/hscontrol/policy/acls.go b/hscontrol/policy/acls.go
index 5848ec333c..9029f63daa 100644
--- a/hscontrol/policy/acls.go
+++ b/hscontrol/policy/acls.go
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"io"
+ "iter"
"net/netip"
"os"
"slices"
@@ -61,7 +62,7 @@ func theInternet() *netipx.IPSet {
internetBuilder.RemovePrefix(tsaddr.CGNATRange())
// Delete "cant find DHCP networks"
- internetBuilder.RemovePrefix(netip.MustParsePrefix("fe80::/10")) // link-loca
+ internetBuilder.RemovePrefix(netip.MustParsePrefix("fe80::/10")) // link-local
internetBuilder.RemovePrefix(netip.MustParsePrefix("169.254.0.0/16"))
theInternetSet, _ := internetBuilder.IPSet()
@@ -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 {
- 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() {
+ 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{
- 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 {
@@ -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,37 +1038,52 @@ 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}
+ user, err := findUserFromToken(users, userToken)
+ if err != nil {
+ log.Trace().Caller().Err(err).Msg("could not determine user to filter nodes by")
+ return out
+ }
- break
+ for _, node := range nodes {
+ if node.User.ID == user.ID {
+ out = append(out, node)
}
- if user.Email == userToken {
- potentialUsers = append(potentialUsers, user)
+ }
+
+ 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.Name == userToken {
+
+ if user.Email == token || user.Name == token {
potentialUsers = append(potentialUsers, user)
}
}
- if len(potentialUsers) != 1 {
- return nil
+ if len(potentialUsers) == 0 {
+ return types.User{}, fmt.Errorf("user with token %q not found: %w", token, ErrorNoUserMatching)
}
- user := potentialUsers[0]
-
- for _, node := range nodes {
- if node.User.ID == user.ID {
- out = append(out, node)
- }
+ if len(potentialUsers) > 1 {
+ return types.User{}, fmt.Errorf("multiple users with token %q found: %w", token, ErrorNoUserMatching)
}
- return out
+ return potentialUsers[0], nil
}
// FilterNodesByACL returns the list of peers authorized to be accessed from a given node.
diff --git a/hscontrol/policy/acls_test.go b/hscontrol/policy/acls_test.go
index b00cec12df..750d7b5389 100644
--- a/hscontrol/policy/acls_test.go
+++ b/hscontrol/policy/acls_test.go
@@ -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)
+ }
+ })
+ }
+}
diff --git a/hscontrol/policy/pm.go b/hscontrol/policy/pm.go
index 7dbaed33c9..4e10003ed9 100644
--- a/hscontrol/policy/pm.go
+++ b/hscontrol/policy/pm.go
@@ -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
}
diff --git a/hscontrol/poll.go b/hscontrol/poll.go
index e6047d4550..1eaa48034c 100644
--- a/hscontrol/poll.go
+++ b/hscontrol/poll.go
@@ -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
diff --git a/hscontrol/templates/apple.go b/hscontrol/templates/apple.go
index 8b289d2260..99b1cc8e70 100644
--- a/hscontrol/templates/apple.go
+++ b/hscontrol/templates/apple.go
@@ -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,
diff --git a/hscontrol/templates/register_web.go b/hscontrol/templates/register_web.go
index 8361048a77..271f4e7d78 100644
--- a/hscontrol/templates/register_web.go
+++ b/hscontrol/templates/register_web.go
@@ -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())),
),
),
)
diff --git a/hscontrol/types/common.go b/hscontrol/types/common.go
index 32ad8a67db..3b6c1be11f 100644
--- a/hscontrol/types/common.go
+++ b/hscontrol/types/common.go
@@ -3,8 +3,10 @@ package types
import (
"context"
"errors"
+ "fmt"
"time"
+ "github.com/juanfont/headscale/hscontrol/util"
"tailscale.com/tailcfg"
"tailscale.com/util/ctxkey"
)
@@ -123,3 +125,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{}
+}
diff --git a/hscontrol/types/config.go b/hscontrol/types/config.go
index 2af3989646..00111fd672 100644
--- a/hscontrol/types/config.go
+++ b/hscontrol/types/config.go
@@ -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
@@ -63,6 +66,8 @@ type Config struct {
Log LogConfig
DisableUpdateCheck bool
+ AllowedOrigins CorsConfig
+
Database DatabaseConfig
DERP DERPConfig
@@ -72,7 +77,14 @@ type Config struct {
ACMEURL string
ACMEEmail string
- DNSConfig *tailcfg.DNSConfig
+ // DNSConfig is the headscale representation of the DNS configuration.
+ // It is kept in the config update for some settings that are
+ // not directly converted into a tailcfg.DNSConfig.
+ DNSConfig DNSConfig
+
+ // TailcfgDNSConfig is the tailcfg representation of the DNS configuration,
+ // it can be used directly when sending Netmaps to clients.
+ TailcfgDNSConfig *tailcfg.DNSConfig
UnixSocket string
UnixSocketPermission fs.FileMode
@@ -90,11 +102,12 @@ type Config struct {
}
type DNSConfig struct {
- MagicDNS bool `mapstructure:"magic_dns"`
- BaseDomain string `mapstructure:"base_domain"`
- Nameservers Nameservers
- SearchDomains []string `mapstructure:"search_domains"`
- ExtraRecords []tailcfg.DNSRecord `mapstructure:"extra_records"`
+ MagicDNS bool `mapstructure:"magic_dns"`
+ BaseDomain string `mapstructure:"base_domain"`
+ Nameservers Nameservers
+ SearchDomains []string `mapstructure:"search_domains"`
+ ExtraRecords []tailcfg.DNSRecord `mapstructure:"extra_records"`
+ ExtraRecordsPath string `mapstructure:"extra_records_path"`
}
type Nameservers struct {
@@ -154,6 +167,11 @@ type LetsEncryptConfig struct {
ChallengeType string
}
+type PKCEConfig struct {
+ Enabled bool
+ Method string
+}
+
type OIDCConfig struct {
OnlyStartIfOIDCIsAvailable bool
Issuer string
@@ -168,6 +186,7 @@ type OIDCConfig struct {
Expiry time.Duration
UseExpiryFromToken bool
MapLegacyUsers bool
+ PKCE PKCEConfig
}
type DERPConfig struct {
@@ -191,6 +210,10 @@ type LogTailConfig struct {
Enabled bool
}
+type CorsConfig struct {
+ Origins []string
+}
+
type CLIConfig struct {
Address string
APIKey string
@@ -203,6 +226,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
@@ -214,6 +241,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.
@@ -253,7 +298,6 @@ func LoadConfig(path string, isFile bool) error {
viper.SetDefault("dns.nameservers.global", []string{})
viper.SetDefault("dns.nameservers.split", map[string]string{})
viper.SetDefault("dns.search_domains", []string{})
- viper.SetDefault("dns.extra_records", []tailcfg.DNSRecord{})
viper.SetDefault("derp.server.enabled", false)
viper.SetDefault("derp.server.stun.enabled", true)
@@ -281,7 +325,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)
@@ -292,6 +338,8 @@ func LoadConfig(path string, isFile bool) error {
viper.SetDefault("tuning.batch_change_delay", "800ms")
viper.SetDefault("tuning.node_mapsession_buffered_chan_size", 30)
+ viper.SetDefault("access_control_allow_origin", "")
+
viper.SetDefault("prefixes.allocation", string(IPAllocationStrategySequential))
if err := viper.ReadInConfig(); err != nil {
@@ -329,6 +377,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{
@@ -344,6 +398,10 @@ func validateServerConfig() error {
}
}
+ if viper.IsSet("dns.extra_records") && viper.IsSet("dns.extra_records_path") {
+ log.Fatal().Msg("Fatal config error: dns.extra_records and dns.extra_records_path are mutually exclusive. Please remove one of them from your config file")
+ }
+
// Collect any validation errors and return them all at once
var errorText string
if (viper.GetString("tls_letsencrypt_hostname") != "") &&
@@ -480,6 +538,14 @@ func logtailConfig() LogTailConfig {
}
}
+func corsConfig() CorsConfig {
+ allowedOrigins := viper.GetStringSlice("cors.allowed_origins")
+
+ return CorsConfig{
+ Origins: allowedOrigins,
+ }
+}
+
func policyConfig() PolicyConfig {
policyPath := viper.GetString("policy.path")
policyMode := viper.GetString("policy.mode")
@@ -578,7 +644,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")
@@ -586,13 +652,14 @@ func dns() (DNSConfig, error) {
dns.Nameservers.Global = viper.GetStringSlice("dns.nameservers.global")
dns.Nameservers.Split = viper.GetStringMapStringSlice("dns.nameservers.split")
dns.SearchDomains = viper.GetStringSlice("dns.search_domains")
+ dns.ExtraRecordsPath = viper.GetString("dns.extra_records_path")
if viper.IsSet("dns.extra_records") {
var extraRecords []tailcfg.DNSRecord
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
}
@@ -852,6 +919,8 @@ func LoadServerConfig() (*Config, error) {
GRPCAllowInsecure: viper.GetBool("grpc_allow_insecure"),
DisableUpdateCheck: false,
+ AllowedOrigins: corsConfig(),
+
PrefixV4: prefix4,
PrefixV6: prefix6,
IPAllocation: IPAllocationStrategy(alloc),
@@ -871,7 +940,8 @@ func LoadServerConfig() (*Config, error) {
TLS: tlsConfig(),
- DNSConfig: dnsToTailcfgDNS(dnsConfig),
+ DNSConfig: dnsConfig,
+ TailcfgDNSConfig: dnsToTailcfgDNS(dnsConfig),
ACMEEmail: viper.GetString("acme_email"),
ACMEURL: viper.GetString("acme_url"),
@@ -911,6 +981,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,
diff --git a/hscontrol/types/config_test.go b/hscontrol/types/config_test.go
index 58382ca5ab..511528df58 100644
--- a/hscontrol/types/config_test.go
+++ b/hscontrol/types/config_test.go
@@ -280,9 +280,9 @@ func TestReadConfigFromEnv(t *testing.T) {
// "foo.bar.com": {"1.1.1.1"},
},
},
- ExtraRecords: []tailcfg.DNSRecord{
- // {Name: "prometheus.myvpn.example.com", Type: "A", Value: "100.64.0.4"},
- },
+ // ExtraRecords: []tailcfg.DNSRecord{
+ // {Name: "prometheus.myvpn.example.com", Type: "A", Value: "100.64.0.4"},
+ // },
SearchDomains: []string{"test.com", "bar.com"},
},
},
diff --git a/hscontrol/types/routes.go b/hscontrol/types/routes.go
index 1f6b8a7736..12559fa620 100644
--- a/hscontrol/types/routes.go
+++ b/hscontrol/types/routes.go
@@ -13,8 +13,8 @@ import (
type Route struct {
gorm.Model
- NodeID uint64
- Node Node
+ NodeID uint64 `gorm:"not null"`
+ Node *Node
// TODO(kradalby): change this custom type to netip.Prefix
Prefix netip.Prefix `gorm:"serializer:text"`
@@ -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)
}
diff --git a/hscontrol/types/users.go b/hscontrol/types/users.go
index d2b86ff443..8024735e32 100644
--- a/hscontrol/types/users.go
+++ b/hscontrol/types/users.go
@@ -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,
@@ -119,18 +125,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 +177,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 {
diff --git a/hscontrol/types/users_test.go b/hscontrol/types/users_test.go
new file mode 100644
index 0000000000..e600707753
--- /dev/null
+++ b/hscontrol/types/users_test.go
@@ -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": "",
+ "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)
+ }
+ })
+ }
+}
diff --git a/hscontrol/util/dns.go b/hscontrol/util/dns.go
index bf43eb507a..c87714d095 100644
--- a/hscontrol/util/dns.go
+++ b/hscontrol/util/dns.go
@@ -6,6 +6,7 @@ import (
"net/netip"
"regexp"
"strings"
+ "unicode"
"go4.org/netipx"
"tailscale.com/util/dnsname"
@@ -20,10 +21,49 @@ 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 {
if len(name) > LabelHostnameLength {
return fmt.Errorf(
@@ -39,7 +79,7 @@ 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",
name,
@@ -52,7 +92,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
}
@@ -189,7 +229,6 @@ func GenerateIPv6DNSRootDomain(ipPrefix netip.Prefix) []dnsname.FQDN {
// NormalizeToFQDNRules will replace forbidden chars in user
// it can also return an error if the user doesn't respect RFC 952 and 1123.
func NormalizeToFQDNRules(name string, stripEmailDomain bool) (string, error) {
-
name = strings.ToLower(name)
name = strings.ReplaceAll(name, "'", "")
atIdx := strings.Index(name, "@")
@@ -198,7 +237,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 {
diff --git a/hscontrol/util/string.go b/hscontrol/util/string.go
index ce38b82e87..08769060bc 100644
--- a/hscontrol/util/string.go
+++ b/hscontrol/util/string.go
@@ -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
diff --git a/integration/acl_test.go b/integration/acl_test.go
index 6606a13220..888110aca6 100644
--- a/integration/acl_test.go
+++ b/integration/acl_test.go
@@ -119,8 +119,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
},
},
// Test that when we have two users, which cannot see
@@ -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 {
diff --git a/integration/auth_oidc_test.go b/integration/auth_oidc_test.go
index 54aa05fbee..22790f91b5 100644
--- a/integration/auth_oidc_test.go
+++ b/integration/auth_oidc_test.go
@@ -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)
@@ -130,8 +131,9 @@ func TestOIDCAuthenticationPingAll(t *testing.T) {
want := []v1.User{
{
- Id: 1,
- Name: "user1",
+ Id: 1,
+ Name: "user1",
+ Email: "user1@test.no",
},
{
Id: 2,
@@ -141,8 +143,9 @@ func TestOIDCAuthenticationPingAll(t *testing.T) {
ProviderId: oidcConfig.Issuer + "/user1",
},
{
- Id: 3,
- Name: "user2",
+ Id: 3,
+ Name: "user2",
+ Email: "user2@test.no",
},
{
Id: 4,
@@ -154,7 +157,7 @@ func TestOIDCAuthenticationPingAll(t *testing.T) {
}
sort.Slice(listUsers, func(i, j int) bool {
- return listUsers[i].Id < listUsers[j].Id
+ return listUsers[i].GetId() < listUsers[j].GetId()
})
if diff := cmp.Diff(want, listUsers, cmpopts.IgnoreUnexported(v1.User{}), cmpopts.IgnoreFields(v1.User{}, "CreatedAt")); diff != "" {
@@ -202,7 +205,6 @@ func TestOIDCExpireNodesBasedOnTokenExpiry(t *testing.T) {
spec,
hsic.WithTestName("oidcexpirenodes"),
hsic.WithConfigEnv(oidcMap),
- hsic.WithHostnameAsServerURL(),
)
assertNoErrHeadscaleEnv(t, err)
@@ -260,8 +262,9 @@ func TestOIDC024UserCreation(t *testing.T) {
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 +274,9 @@ func TestOIDC024UserCreation(t *testing.T) {
ProviderId: iss + "/user1",
},
{
- Id: 3,
- Name: "user2",
+ Id: 3,
+ Name: "user2",
+ Email: "user2@test.no",
},
{
Id: 4,
@@ -295,8 +299,9 @@ func TestOIDC024UserCreation(t *testing.T) {
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 +310,9 @@ func TestOIDC024UserCreation(t *testing.T) {
ProviderId: iss + "/user1",
},
{
- Id: 3,
- Name: "user2",
+ Id: 3,
+ Name: "user2",
+ Email: "user2@test.no",
},
{
Id: 4,
@@ -357,8 +363,9 @@ func TestOIDC024UserCreation(t *testing.T) {
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 +374,9 @@ func TestOIDC024UserCreation(t *testing.T) {
ProviderId: iss + "/user1",
},
{
- Id: 3,
- Name: "user2",
+ Id: 3,
+ Name: "user2",
+ Email: "user2@test.no",
},
{
Id: 4,
@@ -421,8 +429,9 @@ func TestOIDC024UserCreation(t *testing.T) {
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 +440,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 +495,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)
@@ -514,7 +523,7 @@ func TestOIDC024UserCreation(t *testing.T) {
assertNoErr(t, err)
sort.Slice(listUsers, func(i, j int) bool {
- return listUsers[i].Id < listUsers[j].Id
+ return listUsers[i].GetId() < listUsers[j].GetId()
})
if diff := cmp.Diff(want, listUsers, cmpopts.IgnoreUnexported(v1.User{}), cmpopts.IgnoreFields(v1.User{}, "CreatedAt")); diff != "" {
@@ -524,6 +533,85 @@ 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)
+
+ // Verify PKCE was used in authentication
+ headscale, err := scenario.Headscale()
+ assertNoErr(t, err)
+
+ var listUsers []v1.User
+ err = executeAndUnmarshal(headscale,
+ []string{
+ "headscale",
+ "users",
+ "list",
+ "--output",
+ "json",
+ },
+ &listUsers,
+ )
+ assertNoErr(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 (s *AuthOIDCScenario) CreateHeadscaleEnv(
users map[string]int,
opts ...hsic.Option,
@@ -657,71 +745,42 @@ func (s *AuthOIDCScenario) runMockOIDC(accessTTL time.Duration, users []mockoidc
}, nil
}
-func (s *AuthOIDCScenario) runTailscaleUp(
- userStr, loginServer string,
-) error {
- headscale, err := s.Headscale()
+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 err
+ 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 {
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)
- }
-
- 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 failed to run tailscale up: %s", tsc.Hostname(), err)
}
- 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 +808,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 {
diff --git a/integration/auth_web_flow_test.go b/integration/auth_web_flow_test.go
index 3ef3142245..72703e953e 100644
--- a/integration/auth_web_flow_test.go
+++ b/integration/auth_web_flow_test.go
@@ -1,13 +1,9 @@
package integration
import (
- "context"
- "crypto/tls"
"errors"
"fmt"
- "io"
"log"
- "net/http"
"net/netip"
"net/url"
"strings"
@@ -47,7 +43,6 @@ func TestAuthWebFlowAuthenticationPingAll(t *testing.T) {
hsic.WithTestName("webauthping"),
hsic.WithEmbeddedDERPServerOnly(),
hsic.WithTLS(),
- hsic.WithHostnameAsServerURL(),
)
assertNoErrHeadscaleEnv(t, err)
@@ -87,7 +82,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()
@@ -135,7 +133,7 @@ func TestAuthWebFlowLogoutAndRelogin(t *testing.T) {
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)
}
}
@@ -227,11 +225,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 +272,11 @@ func (s *AuthWebFlowScenario) runTailscaleUp(
}
func (s *AuthWebFlowScenario) runHeadscaleRegister(userStr string, loginURL *url.URL) error {
- headscale, err := s.Headscale()
- 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)
+ body, err := doLoginURL("web-auth-not-set", loginURL)
if err != nil {
return err
}
- defer resp.Body.Close()
-
// see api.go HTML template
codeSep := strings.Split(string(body), "")
if len(codeSep) != 2 {
diff --git a/integration/cli_test.go b/integration/cli_test.go
index 1870041b5d..59d39278fa 100644
--- a/integration/cli_test.go
+++ b/integration/cli_test.go
@@ -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)
@@ -809,14 +812,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 +830,7 @@ func TestNodeTagCommand(t *testing.T) {
"--user",
"user1",
"--key",
- machineKey,
+ regID,
"--output",
"json",
},
@@ -844,7 +847,7 @@ func TestNodeTagCommand(t *testing.T) {
"user1",
"register",
"--key",
- machineKey,
+ regID,
"--output",
"json",
},
@@ -854,7 +857,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 +889,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 +933,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 +964,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 +1054,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 +1075,7 @@ func TestNodeCommand(t *testing.T) {
"--user",
"node-user",
"--key",
- machineKey,
+ regID,
"--output",
"json",
},
@@ -1055,7 +1092,7 @@ func TestNodeCommand(t *testing.T) {
"node-user",
"register",
"--key",
- machineKey,
+ regID,
"--output",
"json",
},
@@ -1066,7 +1103,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 +1134,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 +1152,7 @@ func TestNodeCommand(t *testing.T) {
"--user",
"other-user",
"--key",
- machineKey,
+ regID,
"--output",
"json",
},
@@ -1132,7 +1169,7 @@ func TestNodeCommand(t *testing.T) {
"other-user",
"register",
"--key",
- machineKey,
+ regID,
"--output",
"json",
},
@@ -1143,7 +1180,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 +1293,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 +1313,7 @@ func TestNodeExpireCommand(t *testing.T) {
"--user",
"node-expire-user",
"--key",
- machineKey,
+ regID,
"--output",
"json",
},
@@ -1294,7 +1330,7 @@ func TestNodeExpireCommand(t *testing.T) {
"node-expire-user",
"register",
"--key",
- machineKey,
+ regID,
"--output",
"json",
},
@@ -1305,7 +1341,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 +1419,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 +1440,7 @@ func TestNodeRenameCommand(t *testing.T) {
"--user",
"node-rename-command",
"--key",
- machineKey,
+ regID,
"--output",
"json",
},
@@ -1422,7 +1457,7 @@ func TestNodeRenameCommand(t *testing.T) {
"node-rename-command",
"register",
"--key",
- machineKey,
+ regID,
"--output",
"json",
},
@@ -1433,7 +1468,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 +1586,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 +1598,7 @@ func TestNodeMoveCommand(t *testing.T) {
"--user",
"old-user",
"--key",
- machineKey,
+ regID.String(),
"--output",
"json",
},
@@ -1580,7 +1615,7 @@ func TestNodeMoveCommand(t *testing.T) {
"old-user",
"register",
"--key",
- machineKey,
+ regID.String(),
"--output",
"json",
},
diff --git a/integration/derp_verify_endpoint_test.go b/integration/derp_verify_endpoint_test.go
index adad5b6a49..bc7a0a7d15 100644
--- a/integration/derp_verify_endpoint_test.go
+++ b/integration/derp_verify_endpoint_test.go
@@ -69,7 +69,6 @@ func TestDERPVerifyEndpoint(t *testing.T) {
hsic.WithHostname(hostname),
hsic.WithPort(headscalePort),
hsic.WithCustomTLS(certHeadscale, keyHeadscale),
- hsic.WithHostnameAsServerURL(),
hsic.WithDERPConfig(derpMap))
assertNoErrHeadscaleEnv(t, err)
diff --git a/integration/dns_test.go b/integration/dns_test.go
index efe702e9d9..05e272f5c7 100644
--- a/integration/dns_test.go
+++ b/integration/dns_test.go
@@ -1,6 +1,7 @@
package integration
import (
+ "encoding/json"
"fmt"
"strings"
"testing"
@@ -9,6 +10,7 @@ import (
"github.com/juanfont/headscale/integration/hsic"
"github.com/juanfont/headscale/integration/tsic"
"github.com/stretchr/testify/assert"
+ "tailscale.com/tailcfg"
)
func TestResolveMagicDNS(t *testing.T) {
@@ -81,6 +83,162 @@ func TestResolveMagicDNS(t *testing.T) {
}
}
+func TestResolveMagicDNSExtraRecordsPath(t *testing.T) {
+ IntegrationSkip(t)
+ t.Parallel()
+
+ scenario, err := NewScenario(dockertestMaxWait())
+ assertNoErr(t, err)
+ defer scenario.ShutdownAssertNoPanics(t)
+
+ spec := map[string]int{
+ "magicdns1": 1,
+ "magicdns2": 1,
+ }
+
+ const erPath = "/tmp/extra_records.json"
+
+ extraRecords := []tailcfg.DNSRecord{
+ {
+ Name: "test.myvpn.example.com",
+ Type: "A",
+ Value: "6.6.6.6",
+ },
+ }
+ b, _ := json.Marshal(extraRecords)
+
+ err = scenario.CreateHeadscaleEnv(spec, []tsic.Option{
+ tsic.WithDockerEntrypoint([]string{
+ "/bin/sh",
+ "-c",
+ "/bin/sleep 3 ; apk add python3 curl bind-tools ; update-ca-certificates ; tailscaled --tun=tsdev",
+ }),
+ },
+ hsic.WithTestName("extrarecords"),
+ hsic.WithConfigEnv(map[string]string{
+ // Disable global nameservers to make the test run offline.
+ "HEADSCALE_DNS_NAMESERVERS_GLOBAL": "",
+ "HEADSCALE_DNS_EXTRA_RECORDS_PATH": erPath,
+ }),
+ hsic.WithFileInContainer(erPath, b),
+ hsic.WithEmbeddedDERPServerOnly(),
+ hsic.WithTLS(),
+ )
+ assertNoErrHeadscaleEnv(t, err)
+
+ allClients, err := scenario.ListTailscaleClients()
+ assertNoErrListClients(t, err)
+
+ err = scenario.WaitForTailscaleSync()
+ assertNoErrSync(t, err)
+
+ // assertClientsState(t, allClients)
+
+ // Poor mans cache
+ _, err = scenario.ListTailscaleClientsFQDNs()
+ assertNoErrListFQDN(t, err)
+
+ _, err = scenario.ListTailscaleClientsIPs()
+ assertNoErrListClientIPs(t, err)
+
+ for _, client := range allClients {
+ assertCommandOutputContains(t, client, []string{"dig", "test.myvpn.example.com"}, "6.6.6.6")
+ }
+
+ hs, err := scenario.Headscale()
+ assertNoErr(t, err)
+
+ // Write the file directly into place from the docker API.
+ b0, _ := json.Marshal([]tailcfg.DNSRecord{
+ {
+ Name: "docker.myvpn.example.com",
+ Type: "A",
+ Value: "2.2.2.2",
+ },
+ })
+
+ err = hs.WriteFile(erPath, b0)
+ assertNoErr(t, err)
+
+ for _, client := range allClients {
+ assertCommandOutputContains(t, client, []string{"dig", "docker.myvpn.example.com"}, "2.2.2.2")
+ }
+
+ // Write a new file and move it to the path to ensure the reload
+ // works when a file is moved atomically into place.
+ extraRecords = append(extraRecords, tailcfg.DNSRecord{
+ Name: "otherrecord.myvpn.example.com",
+ Type: "A",
+ Value: "7.7.7.7",
+ })
+ b2, _ := json.Marshal(extraRecords)
+
+ err = hs.WriteFile(erPath+"2", b2)
+ assertNoErr(t, err)
+ _, err = hs.Execute([]string{"mv", erPath + "2", erPath})
+ assertNoErr(t, err)
+
+ for _, client := range allClients {
+ assertCommandOutputContains(t, client, []string{"dig", "test.myvpn.example.com"}, "6.6.6.6")
+ assertCommandOutputContains(t, client, []string{"dig", "otherrecord.myvpn.example.com"}, "7.7.7.7")
+ }
+
+ // Write a new file and copy it to the path to ensure the reload
+ // works when a file is copied into place.
+ b3, _ := json.Marshal([]tailcfg.DNSRecord{
+ {
+ Name: "copy.myvpn.example.com",
+ Type: "A",
+ Value: "8.8.8.8",
+ },
+ })
+
+ err = hs.WriteFile(erPath+"3", b3)
+ assertNoErr(t, err)
+ _, err = hs.Execute([]string{"cp", erPath + "3", erPath})
+ assertNoErr(t, err)
+
+ for _, client := range allClients {
+ assertCommandOutputContains(t, client, []string{"dig", "copy.myvpn.example.com"}, "8.8.8.8")
+ }
+
+ // Write in place to ensure pipe like behaviour works
+ b4, _ := json.Marshal([]tailcfg.DNSRecord{
+ {
+ Name: "docker.myvpn.example.com",
+ Type: "A",
+ Value: "9.9.9.9",
+ },
+ })
+ command := []string{"echo", fmt.Sprintf("'%s'", string(b4)), ">", erPath}
+ _, err = hs.Execute([]string{"bash", "-c", strings.Join(command, " ")})
+ assertNoErr(t, err)
+
+ for _, client := range allClients {
+ assertCommandOutputContains(t, client, []string{"dig", "docker.myvpn.example.com"}, "9.9.9.9")
+ }
+
+ // Delete the file and create a new one to ensure it is picked up again.
+ _, err = hs.Execute([]string{"rm", erPath})
+ assertNoErr(t, err)
+
+ time.Sleep(2 * time.Second)
+
+ // The same paths should still be available as it is not cleared on delete.
+ for _, client := range allClients {
+ assertCommandOutputContains(t, client, []string{"dig", "docker.myvpn.example.com"}, "9.9.9.9")
+ }
+
+ // Write a new file, the backoff mechanism should make the filewatcher pick it up
+ // again.
+ err = hs.WriteFile(erPath, b3)
+ assertNoErr(t, err)
+
+ for _, client := range allClients {
+ assertCommandOutputContains(t, client, []string{"dig", "copy.myvpn.example.com"}, "8.8.8.8")
+ }
+}
+
// TestValidateResolvConf validates that the resolv.conf file
// ends up as expected in our Tailscale containers.
// All the containers are based on Alpine, meaning Tailscale
diff --git a/integration/dockertestutil/execute.go b/integration/dockertestutil/execute.go
index 9e16f3660c..078b3bc2ca 100644
--- a/integration/dockertestutil/execute.go
+++ b/integration/dockertestutil/execute.go
@@ -25,7 +25,6 @@ type ExecuteCommandOption func(*ExecuteCommandConfig) error
func ExecuteCommandTimeout(timeout time.Duration) ExecuteCommandOption {
return ExecuteCommandOption(func(conf *ExecuteCommandConfig) error {
conf.timeout = timeout
-
return nil
})
}
@@ -67,6 +66,7 @@ func ExecuteCommand(
StdErr: &stderr,
},
)
+
resultChan <- result{exitCode, err}
}()
@@ -88,7 +88,6 @@ func ExecuteCommand(
return stdout.String(), stderr.String(), nil
case <-time.After(execConfig.timeout):
-
return stdout.String(), stderr.String(), fmt.Errorf("command failed, stderr: %s: %w", stderr.String(), ErrDockertestCommandTimeout)
}
}
diff --git a/integration/embedded_derp_test.go b/integration/embedded_derp_test.go
index d5fdb1612a..e17bbacbd6 100644
--- a/integration/embedded_derp_test.go
+++ b/integration/embedded_derp_test.go
@@ -105,7 +105,6 @@ func derpServerScenario(
hsic.WithEmbeddedDERPServerOnly(),
hsic.WithPort(443),
hsic.WithTLS(),
- hsic.WithHostnameAsServerURL(),
hsic.WithConfigEnv(map[string]string{
"HEADSCALE_DERP_AUTO_UPDATE_ENABLED": "true",
"HEADSCALE_DERP_UPDATE_FREQUENCY": "10s",
diff --git a/integration/general_test.go b/integration/general_test.go
index 985c952974..eb26cea903 100644
--- a/integration/general_test.go
+++ b/integration/general_test.go
@@ -44,7 +44,6 @@ func TestPingAllByIP(t *testing.T) {
hsic.WithTestName("pingallbyip"),
hsic.WithEmbeddedDERPServerOnly(),
hsic.WithTLS(),
- hsic.WithHostnameAsServerURL(),
hsic.WithIPAllocationStrategy(types.IPAllocationStrategyRandom),
)
assertNoErrHeadscaleEnv(t, err)
@@ -123,12 +122,9 @@ func TestAuthKeyLogoutAndRelogin(t *testing.T) {
opts := []hsic.Option{hsic.WithTestName("pingallbyip")}
if https {
- opts = []hsic.Option{
- hsic.WithTestName("pingallbyip"),
- hsic.WithEmbeddedDERPServerOnly(),
+ opts = append(opts, []hsic.Option{
hsic.WithTLS(),
- hsic.WithHostnameAsServerURL(),
- }
+ }...)
}
err = scenario.CreateHeadscaleEnv(spec, []tsic.Option{}, opts...)
@@ -172,7 +168,7 @@ func TestAuthKeyLogoutAndRelogin(t *testing.T) {
// https://github.com/tailscale/tailscale/commit/1eaad7d3deb0815e8932e913ca1a862afa34db38
// https://github.com/juanfont/headscale/issues/2164
if !https {
- time.Sleep(3 * time.Minute)
+ time.Sleep(5 * time.Minute)
}
for userName := range spec {
@@ -1050,7 +1046,6 @@ func TestPingAllByIPManyUpDown(t *testing.T) {
hsic.WithTestName("pingallbyipmany"),
hsic.WithEmbeddedDERPServerOnly(),
hsic.WithTLS(),
- hsic.WithHostnameAsServerURL(),
)
assertNoErrHeadscaleEnv(t, err)
@@ -1133,7 +1128,6 @@ func Test2118DeletingOnlineNodePanics(t *testing.T) {
hsic.WithTestName("deletenocrash"),
hsic.WithEmbeddedDERPServerOnly(),
hsic.WithTLS(),
- hsic.WithHostnameAsServerURL(),
)
assertNoErrHeadscaleEnv(t, err)
diff --git a/integration/hsic/config.go b/integration/hsic/config.go
index 509052a300..76a5176c0a 100644
--- a/integration/hsic/config.go
+++ b/integration/hsic/config.go
@@ -26,9 +26,7 @@ func DefaultConfigEnv() map[string]string {
"HEADSCALE_DNS_NAMESERVERS_GLOBAL": "127.0.0.11 1.1.1.1",
"HEADSCALE_PRIVATE_KEY_PATH": "/tmp/private.key",
"HEADSCALE_NOISE_PRIVATE_KEY_PATH": "/tmp/noise_private.key",
- "HEADSCALE_LISTEN_ADDR": "0.0.0.0:8080",
"HEADSCALE_METRICS_LISTEN_ADDR": "0.0.0.0:9090",
- "HEADSCALE_SERVER_URL": "http://headscale:8080",
"HEADSCALE_DERP_URLS": "https://controlplane.tailscale.com/derpmap/default",
"HEADSCALE_DERP_AUTO_UPDATE_ENABLED": "false",
"HEADSCALE_DERP_UPDATE_FREQUENCY": "1m",
diff --git a/integration/hsic/hsic.go b/integration/hsic/hsic.go
index b2a2701e71..e38abd1ce3 100644
--- a/integration/hsic/hsic.go
+++ b/integration/hsic/hsic.go
@@ -7,9 +7,7 @@ import (
"fmt"
"io"
"log"
- "net"
"net/http"
- "net/url"
"os"
"path"
"strconv"
@@ -166,17 +164,6 @@ func WithHostname(hostname string) Option {
}
}
-// WithHostnameAsServerURL sets the Headscale ServerURL based on
-// the Hostname.
-func WithHostnameAsServerURL() Option {
- return func(hsic *HeadscaleInContainer) {
- hsic.env["HEADSCALE_SERVER_URL"] = fmt.Sprintf("http://%s",
- net.JoinHostPort(hsic.GetHostname(),
- fmt.Sprintf("%d", hsic.port)),
- )
- }
-}
-
// WithFileInContainer adds a file to the container at the given path.
func WithFileInContainer(path string, contents []byte) Option {
return func(hsic *HeadscaleInContainer) {
@@ -297,16 +284,6 @@ func New(
portProto := fmt.Sprintf("%d/tcp", hsic.port)
- serverURL, err := url.Parse(hsic.env["HEADSCALE_SERVER_URL"])
- if err != nil {
- return nil, err
- }
-
- if len(hsic.tlsCert) != 0 && len(hsic.tlsKey) != 0 {
- serverURL.Scheme = "https"
- hsic.env["HEADSCALE_SERVER_URL"] = serverURL.String()
- }
-
headscaleBuildOptions := &dockertest.BuildOptions{
Dockerfile: IntegrationTestDockerFileName,
ContextDir: dockerContextPath,
@@ -352,6 +329,12 @@ func New(
hsic.env["HEADSCALE_TLS_CERT_PATH"] = tlsCertPath
hsic.env["HEADSCALE_TLS_KEY_PATH"] = tlsKeyPath
}
+
+ // Server URL and Listen Addr should not be overridable outside of
+ // the configuration passed to docker.
+ hsic.env["HEADSCALE_SERVER_URL"] = hsic.GetEndpoint()
+ hsic.env["HEADSCALE_LISTEN_ADDR"] = fmt.Sprintf("0.0.0.0:%d", hsic.port)
+
for key, value := range hsic.env {
env = append(env, fmt.Sprintf("%s=%s", key, value))
}
@@ -649,7 +632,7 @@ func (t *HeadscaleInContainer) GetHealthEndpoint() string {
// GetEndpoint returns the Headscale endpoint for the HeadscaleInContainer.
func (t *HeadscaleInContainer) GetEndpoint() string {
hostEndpoint := fmt.Sprintf("%s:%d",
- t.GetIP(),
+ t.GetHostname(),
t.port)
if t.hasTLS() {
@@ -702,7 +685,7 @@ func (t *HeadscaleInContainer) WaitForRunning() error {
func (t *HeadscaleInContainer) CreateUser(
user string,
) error {
- command := []string{"headscale", "users", "create", user}
+ command := []string{"headscale", "users", "create", user, fmt.Sprintf("--email=%s@test.no", user)}
_, _, err := dockertestutil.ExecuteCommand(
t.container,
diff --git a/integration/scenario.go b/integration/scenario.go
index 99a2564721..93d1f2affd 100644
--- a/integration/scenario.go
+++ b/integration/scenario.go
@@ -12,6 +12,7 @@ import (
"time"
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
+ "github.com/juanfont/headscale/hscontrol/capver"
"github.com/juanfont/headscale/hscontrol/util"
"github.com/juanfont/headscale/integration/dockertestutil"
"github.com/juanfont/headscale/integration/dsic"
@@ -51,53 +52,6 @@ var (
errNoUserAvailable = errors.New("no user available")
errNoClientFound = errors.New("client not found")
- // Tailscale started adding TS2021 support in CapabilityVersion>=28 (v1.24.0), but
- // proper support in Headscale was only added for CapabilityVersion>=39 clients (v1.30.0).
- tailscaleVersions2021 = map[string]bool{
- "head": true,
- "unstable": true,
- "1.74": true, // CapVer: 106
- "1.72": true, // CapVer: 104
- "1.70": true, // CapVer: 102
- "1.68": true, // CapVer: 97
- "1.66": true, // CapVer: 95
- "1.64": true, // CapVer: 90
- "1.62": true, // CapVer: 88
- "1.60": true, // CapVer: 87
- "1.58": true, // CapVer: 85
- "1.56": true, // Oldest supported version, CapVer: 82
- "1.54": false, // CapVer: 79
- "1.52": false, // CapVer: 79
- "1.50": false, // CapVer: 74
- "1.48": false, // CapVer: 68
- "1.46": false, // CapVer: 65
- "1.44": false, // CapVer: 63
- "1.42": false, // CapVer: 61
- "1.40": false, // CapVer: 61
- "1.38": false, // CapVer: 58
- "1.36": false, // CapVer: 56
- "1.34": false, // CapVer: 51
- "1.32": false, // CapVer: 46
- "1.30": false,
- }
-
- tailscaleVersions2019 = map[string]bool{
- "1.28": false,
- "1.26": false,
- "1.24": false, // Tailscale SSH
- "1.22": false,
- "1.20": false,
- "1.18": false,
- }
-
- // tailscaleVersionsUnavailable = []string{
- // // These versions seem to fail when fetching from apt.
- // "1.14.6",
- // "1.12.4",
- // "1.10.2",
- // "1.8.7",
- // }.
-
// AllVersions represents a list of Tailscale versions the suite
// uses to test compatibility with the ControlServer.
//
@@ -107,10 +61,7 @@ var (
//
// The rest of the version represents Tailscale versions that can be
// found in Tailscale's apt repository.
- AllVersions = append(
- enabledVersions(tailscaleVersions2021),
- enabledVersions(tailscaleVersions2019)...,
- )
+ AllVersions = append([]string{"head", "unstable"}, capver.TailscaleLatestMajorMinor(10, true)...)
// MustTestVersions is the minimum set of versions we should test.
// At the moment, this is arbitrarily chosen as:
@@ -347,6 +298,51 @@ func (s *Scenario) CreateUser(user string) error {
/// Client related stuff
+func (s *Scenario) CreateTailscaleNode(
+ version string,
+ opts ...tsic.Option,
+) (TailscaleClient, error) {
+ headscale, err := s.Headscale()
+ if err != nil {
+ return nil, fmt.Errorf("failed to create tailscale node (version: %s): %w", version, err)
+ }
+
+ cert := headscale.GetCert()
+ hostname := headscale.GetHostname()
+
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ opts = append(opts,
+ tsic.WithCACert(cert),
+ tsic.WithHeadscaleName(hostname),
+ )
+
+ tsClient, err := tsic.New(
+ s.pool,
+ version,
+ s.network,
+ opts...,
+ )
+ if err != nil {
+ return nil, fmt.Errorf(
+ "failed to create tailscale (%s) node: %w",
+ tsClient.Hostname(),
+ err,
+ )
+ }
+
+ err = tsClient.WaitForNeedsLogin()
+ if err != nil {
+ return nil, fmt.Errorf(
+ "failed to wait for tailscaled (%s) to need login: %w",
+ tsClient.Hostname(),
+ err,
+ )
+ }
+
+ return tsClient, nil
+}
+
// CreateTailscaleNodesInUser creates and adds a new TailscaleClient to a
// User in the Scenario.
func (s *Scenario) CreateTailscaleNodesInUser(
@@ -372,18 +368,22 @@ func (s *Scenario) CreateTailscaleNodesInUser(
cert := headscale.GetCert()
hostname := headscale.GetHostname()
+ s.mu.Lock()
opts = append(opts,
tsic.WithCACert(cert),
tsic.WithHeadscaleName(hostname),
)
+ s.mu.Unlock()
user.createWaitGroup.Go(func() error {
+ s.mu.Lock()
tsClient, err := tsic.New(
s.pool,
version,
s.network,
opts...,
)
+ s.mu.Unlock()
if err != nil {
return fmt.Errorf(
"failed to create tailscale (%s) node: %w",
diff --git a/integration/ssh_test.go b/integration/ssh_test.go
index c31cc10885..bc67a73ed7 100644
--- a/integration/ssh_test.go
+++ b/integration/ssh_test.go
@@ -69,9 +69,6 @@ func sshScenario(t *testing.T, policy *policy.ACLPolicy, clientsPerUser int) *Sc
},
hsic.WithACLPolicy(policy),
hsic.WithTestName("ssh"),
- hsic.WithConfigEnv(map[string]string{
- "HEADSCALE_EXPERIMENTAL_FEATURE_SSH": "1",
- }),
)
assertNoErr(t, err)
diff --git a/integration/tsic/tsic.go b/integration/tsic/tsic.go
index e63a7b6ecf..c2cb8515b0 100644
--- a/integration/tsic/tsic.go
+++ b/integration/tsic/tsic.go
@@ -466,7 +466,7 @@ func (t *TailscaleInContainer) Login(
// This login mechanism uses web + command line flow for authentication.
func (t *TailscaleInContainer) LoginWithURL(
loginServer string,
-) (*url.URL, error) {
+) (loginURL *url.URL, err error) {
command := []string{
"tailscale",
"up",
@@ -475,20 +475,27 @@ func (t *TailscaleInContainer) LoginWithURL(
"--accept-routes=false",
}
- _, stderr, err := t.Execute(command)
+ stdout, stderr, err := t.Execute(command)
if errors.Is(err, errTailscaleNotLoggedIn) {
return nil, errTailscaleCannotUpWithoutAuthkey
}
- urlStr := strings.ReplaceAll(stderr, "\nTo authenticate, visit:\n\n\t", "")
+ defer func() {
+ if err != nil {
+ log.Printf("join command: %q", strings.Join(command, " "))
+ }
+ }()
+
+ urlStr := strings.ReplaceAll(stdout+stderr, "\nTo authenticate, visit:\n\n\t", "")
urlStr = strings.TrimSpace(urlStr)
+ if urlStr == "" {
+ return nil, fmt.Errorf("failed to get login URL: stdout: %s, stderr: %s", stdout, stderr)
+ }
+
// parse URL
- loginURL, err := url.Parse(urlStr)
+ loginURL, err = url.Parse(urlStr)
if err != nil {
- log.Printf("Could not parse login URL: %s", err)
- log.Printf("Original join command result: %s", stderr)
-
return nil, err
}
@@ -497,12 +504,17 @@ func (t *TailscaleInContainer) LoginWithURL(
// Logout runs the logout routine on the given Tailscale instance.
func (t *TailscaleInContainer) Logout() error {
- _, _, err := t.Execute([]string{"tailscale", "logout"})
+ stdout, stderr, err := t.Execute([]string{"tailscale", "logout"})
if err != nil {
return err
}
- return nil
+ stdout, stderr, _ = t.Execute([]string{"tailscale", "status"})
+ if !strings.Contains(stdout+stderr, "Logged out.") {
+ return fmt.Errorf("failed to logout, stdout: %s, stderr: %s", stdout, stderr)
+ }
+
+ return t.waitForBackendState("NeedsLogin")
}
// Helper that runs `tailscale up` with no arguments.
@@ -826,28 +838,16 @@ func (t *TailscaleInContainer) FailingPeersAsString() (string, bool, error) {
// WaitForNeedsLogin blocks until the Tailscale (tailscaled) instance has
// started and needs to be logged into.
func (t *TailscaleInContainer) WaitForNeedsLogin() error {
- return t.pool.Retry(func() error {
- status, err := t.Status()
- if err != nil {
- return errTailscaleStatus(t.hostname, err)
- }
-
- // ipnstate.Status.CurrentTailnet was added in Tailscale 1.22.0
- // https://github.com/tailscale/tailscale/pull/3865
- //
- // Before that, we can check the BackendState to see if the
- // tailscaled daemon is connected to the control system.
- if status.BackendState == "NeedsLogin" {
- return nil
- }
-
- return errTailscaledNotReadyForLogin
- })
+ return t.waitForBackendState("NeedsLogin")
}
// WaitForRunning blocks until the Tailscale (tailscaled) instance is logged in
// and ready to be used.
func (t *TailscaleInContainer) WaitForRunning() error {
+ return t.waitForBackendState("Running")
+}
+
+func (t *TailscaleInContainer) waitForBackendState(state string) error {
return t.pool.Retry(func() error {
status, err := t.Status()
if err != nil {
@@ -859,7 +859,7 @@ func (t *TailscaleInContainer) WaitForRunning() error {
//
// Before that, we can check the BackendState to see if the
// tailscaled daemon is connected to the control system.
- if status.BackendState == "Running" {
+ if status.BackendState == state {
return nil
}
diff --git a/integration/utils.go b/integration/utils.go
index ec6aeecf79..0c151ae87c 100644
--- a/integration/utils.go
+++ b/integration/utils.go
@@ -3,6 +3,7 @@ package integration
import (
"bufio"
"bytes"
+ "fmt"
"io"
"os"
"strings"
@@ -10,6 +11,7 @@ import (
"testing"
"time"
+ "github.com/cenkalti/backoff/v4"
"github.com/juanfont/headscale/hscontrol/util"
"github.com/juanfont/headscale/integration/tsic"
"github.com/stretchr/testify/assert"
@@ -302,6 +304,30 @@ func assertValidNetcheck(t *testing.T, client TailscaleClient) {
assert.NotEqualf(t, 0, report.PreferredDERP, "%q does not have a DERP relay", client.Hostname())
}
+// assertCommandOutputContains executes a command for a set time and asserts that the output
+// reaches a desired state.
+// It should be used instead of sleeping before executing.
+func assertCommandOutputContains(t *testing.T, c TailscaleClient, command []string, contains string) {
+ t.Helper()
+
+ err := backoff.Retry(func() error {
+ stdout, stderr, err := c.Execute(command)
+ if err != nil {
+ return fmt.Errorf("executing command, stdout: %q stderr: %q, err: %w", stdout, stderr, err)
+ }
+
+ if !strings.Contains(stdout, contains) {
+ return fmt.Errorf("executing command, expected string %q not found in %q", contains, stdout)
+ }
+
+ return nil
+ }, backoff.NewExponentialBackOff(
+ backoff.WithMaxElapsedTime(10*time.Second)),
+ )
+
+ assert.NoError(t, err)
+}
+
func isSelfClient(client TailscaleClient, addr string) bool {
if addr == client.Hostname() {
return true
diff --git a/mkdocs.yml b/mkdocs.yml
index e28cd593c3..1ca2ba8dae 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -11,7 +11,7 @@ repo_name: juanfont/headscale
repo_url: https://github.com/juanfont/headscale
# Copyright
-copyright: Copyright © 2024 Headscale authors
+copyright: Copyright © 2025 Headscale authors
# Configuration
theme:
@@ -106,7 +106,7 @@ extra:
- icon: fontawesome/brands/discord
link: https://discord.gg/c84AZQhmpx
headscale:
- version: 0.23.0
+ version: 0.24.0
# Extensions
markdown_extensions:
@@ -168,7 +168,6 @@ nav:
- Official releases: setup/install/official.md
- Community packages: setup/install/community.md
- Container: setup/install/container.md
- - Cloud: setup/install/cloud.md
- Build from source: setup/install/source.md
- Upgrade: setup/upgrade.md
- Usage:
diff --git a/proto/headscale/v1/user.proto b/proto/headscale/v1/user.proto
index 591553ddc9..bd71bcb1e8 100644
--- a/proto/headscale/v1/user.proto
+++ b/proto/headscale/v1/user.proto
@@ -15,7 +15,12 @@ message User {
string profile_pic_url = 8;
}
-message CreateUserRequest { string name = 1; }
+message CreateUserRequest {
+ string name = 1;
+ string display_name = 2;
+ string email = 3;
+ string picture_url = 4;
+}
message CreateUserResponse { User user = 1; }