Compare commits

..

74 Commits

Author SHA1 Message Date
Juan Font 3566399f05 Update vouch version 2026-07-17 08:38:37 +00:00
Juan Font 9d51a45093 Add vouched 2026-07-17 08:37:40 +00:00
alaningtrump 048308511c chore: fix function comment to match actual function name
Signed-off-by: alaningtrump <alaningtrump@outlook.com>
2026-07-03 07:27:08 +02:00
Kristoffer Dalby a84945f134 CHANGELOG: shorten 0.29.2 invalid-name entry, set date
Updates #3346
2026-07-01 16:23:24 +02:00
Kristoffer Dalby fef80f3eb0 CHANGELOG: note /ts2021 WebSocket GET fix
Updates #3357
2026-07-01 15:20:01 +02:00
Kristoffer Dalby 01ef350d5f integration: add TS2021 WebSocket tests to CI matrix
Generated by gh-action-integration-generator.

Updates #3357
2026-07-01 15:20:01 +02:00
Kristoffer Dalby f4fba32dc6 integration: test /ts2021 WebSocket GET with a real WASM client
A raw coder/websocket dial and the real tailscale.com js/wasm control client under Node, both against headscale alongside normal Tailscale clients.

Updates #3357
2026-07-01 15:20:01 +02:00
Kristoffer Dalby fc6f216b61 hscontrol: register /ts2021 for WebSocket GET
The chi migration dropped GET; JS/WASM control clients open /ts2021 as a WebSocket GET and were rejected with 405 before reaching NoiseUpgradeHandler.

Fixes #3357
2026-07-01 15:20:01 +02:00
Kristoffer Dalby de9db9c811 CHANGELOG: note 0.29.2 invalid-name map fix
Updates #3346
2026-07-01 15:19:08 +02:00
Kristoffer Dalby 4946d1c88d state: log nodes with map-breaking data at startup
Scan a node-health check registry at boot and log each node whose name
can't form a valid FQDN, with the rename fix. Log-only, no mutation.

Updates #3346
2026-07-01 15:19:08 +02:00
Kristoffer Dalby c497612c99 state: reject renames whose FQDN exceeds the hostname limit
A valid label can still overflow 255 chars under a long base_domain; gate
RenameNode with the new types.ValidateGivenName.

Updates #3346
2026-07-01 15:19:08 +02:00
Kristoffer Dalby 4e4512c4b7 poll: return an HTTP error on long-poll setup failure
A bare return sent an empty 200 the client read as "unexpected EOF" and
retried forever; emit a real error instead.

Updates #3346
2026-07-01 15:19:08 +02:00
Kristoffer Dalby 08956d51a4 mapper: skip peers with invalid names instead of failing the map
A peer whose GivenName fails GetFQDN aborted the whole map for every node
that could see it. Drop and log it; SSH policy errors degrade too.

Fixes #3346
2026-07-01 15:19:08 +02:00
Kristoffer Dalby d528686f14 mapper,policy: add reconnect-storm and lock-concurrency regression tests
TestInitialMapNotStarvedByReconnectStorm reproduces the #3346 stall;
TestPolicyManagerConcurrentReads guards the RLock cache access under -race.

Updates #3346
2026-07-01 14:41:03 +02:00
Kristoffer Dalby 6f317c7576 policy: take RLock for reads so map generation runs concurrently
One exclusive mutex serialized every policy read, so a mass reconnect on
autogroup:self/via/relay policies stalled clients into "unexpected EOF"
retries. Per-node caches become xsync.Maps for lazy population under RLock.

Fixes #3346
2026-07-01 14:41:03 +02:00
Kristoffer Dalby 518b497be9 ci: run TestK8sOperator in the shared sqlite matrix
It needs no special runner (every integration container already runs privileged), so it joins the generated matrix; load br_netfilter only for that test.

Updates #1202
2026-06-26 22:06:26 +02:00
Kristoffer Dalby 8d91e42a9f cmd/hi: share the test-container prefix matcher and check the k3s image
Factor the hs-/ts-/derp-/k3s- prefix set into one helper used by cleanup and docker; add a doctor check for the ghcr k3s image.
2026-06-26 22:06:26 +02:00
Kristoffer Dalby 99fd62477d integration: add a k3s and Tailscale Kubernetes operator test
Run the real operator in a single-container k3s cluster against an in-test Headscale over plain HTTP. k3sic exposes reusable building blocks (InstallOperator, DeployConnector, DeployEchoServer, ExposeServiceToTailnet, DeployProxyGroup); the test covers operator registration, an egress connector, ingress connectivity from a tailnet node, and proxy groups. tls-ca-baking.md records the private-CA TLS variant.

Updates #1202
2026-06-26 22:06:26 +02:00
Kristoffer Dalby c7b162509c integration/hsic: add CreateOAuthClient via the v2 API client
Mints an admin API key and creates a keyType=client OAuth credential through gen/client/v2, exposed on ControlServer. Reusable by tests needing OAuth client credentials.
2026-06-26 22:06:26 +02:00
Kristoffer Dalby a4ec76605e integration/tsic: add WithDERPOverHTTP for the non-TLS embedded DERP
Sets TS_DEBUG_DERP_WS_CLIENT + TS_DEBUG_USE_DERP_HTTP so a client can reach hsic's plain-HTTP embedded DERP over websockets; the counterpart to hsic.WithoutTLS.
2026-06-26 22:06:26 +02:00
Kristoffer Dalby 1867213b69 capver: order TailscaleLatestMajorMinor versions with cmpver
Lexical sort placed v1.100 before v1.98; cmpver compares numerically.
2026-06-26 22:06:26 +02:00
Kristoffer Dalby 622e08f5e6 oidc: harden callback CSRF cookies, state reuse, and issuer config
Defence-in-depth fixes to the OIDC login flow.

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

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

Bound OIDC discovery to the caller's context so a slow or unreachable
issuer fails startup within the timeout instead of hanging, and validate
the issuer URL and required client_id/client_secret at config load so an
unworkable setup fails fast.
2026-06-26 09:18:06 +02:00
Kristoffer Dalby 79bf201ccb changelog: note OAuth clients and scopes 2026-06-26 09:18:06 +02:00
Kristoffer Dalby af46fe8867 hscontrol, integration: test OAuth scope enforcement and the oauth-clients CLI
Add scope-enforcement coverage and integration tests for the
oauth-clients CLI across the test matrix.
2026-06-26 09:18:06 +02:00
Kristoffer Dalby b05f8875c3 hscontrol, cli: serve the v2 API on the local socket and add the oauth-clients command
Serve the v2 API over the local unix socket for the CLI and add the
oauth-clients command that drives it.
2026-06-26 09:18:06 +02:00
Kristoffer Dalby 84715e976c api/v2: add OAuth client-credentials auth and scope enforcement 2026-06-26 09:18:06 +02:00
Kristoffer Dalby ee95cf58d9 hscontrol: add the OAuth client and access-token model
Add the OAuth client type, its database storage, the scope grant package,
policy tag-ownership exposure, and the state operations backing the v2
OAuth client-credentials flow.
2026-06-26 09:18:06 +02:00
Florian Preinstorfer 7b1776a87e Add changelog entry for systemd service refresh 2026-06-25 11:07:10 +02:00
Florian Preinstorfer f6865fb40c Filter @resources from the list of allowed syscalls 2026-06-25 11:07:10 +02:00
Florian Preinstorfer 6e0814a845 Add DevicePolicy and MemoryDenyWriteExecute
I've used those for a long time with my setups.
2026-06-25 11:07:10 +02:00
Florian Preinstorfer 6e6ea3758d Remove CAP_CHOWN from systemd service 2026-06-25 11:07:10 +02:00
Florian Preinstorfer 3846c3f148 Remove X-Restart-Triggers from systemd service file
Its not used by systemd.
2026-06-25 11:07:10 +02:00
Florian Preinstorfer fe536dd799 Slightly restructure the example config comments 2026-06-25 11:07:10 +02:00
Fredrik Ekre 5aeeff98d0 oidc: fix NewProvider ignoring caller context timeout
NewAuthProviderOIDC received a context with a 30-second timeout from
its caller, but immediately discarded it by passing context.Background()
to oidc.NewProvider. This caused both `headscale serve` and
`headscale configtest` to hang indefinitely if the OIDC issuer was
unreachable, rather than timing out and returning an error.

Fix by passing the caller's ctx to oidc.NewProvider and remove the
nolint:contextcheck annotation that was suppressing the linter warning
about this bug. The annotation was introduced in ce580f82 as part of a
bulk lint-suppression pass without addressing the underlying issue.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-24 19:03:00 +02:00
Kristoffer Dalby 66937040f2 policy: allow the tailscale.com/cap/secrets capability
It's setec's official cap; allowlist it so it can be granted via policy.
2026-06-24 18:41:33 +02:00
Kristoffer Dalby 036760df02 hscontrol: collapse key and settings asserts into cmp.Diff 2026-06-24 18:41:06 +02:00
Kristoffer Dalby 83d3828ba1 api/v2: simplify endpoint comments 2026-06-24 18:41:06 +02:00
Kristoffer Dalby dd9f0a3f4f servertest: roundtrip v2 users and 4via6 through the clients
Exercises the user data sources via the Go SDK, tscli, and OpenTofu, and
backfills tailscale_4via6 (provider-local compute), each with the
no-drift gate.
2026-06-24 18:41:06 +02:00
Kristoffer Dalby 961b15313f hscontrol: contract-test the v2 users endpoint
Pins the wire shape: honest constants, deviceCount aggregation, the
{"users":[...]} envelope, ignored type/role filters, and 404 on
unknown/non-numeric/pseudo-user ids.
2026-06-24 18:41:06 +02:00
Kristoffer Dalby 475a5ead0c api/v2: add user get and list (Tailscale compat)
Backs the tailscale_user and tailscale_users data sources. getUser maps
gorm/ErrUserNotFound to 404; unmodelled fields are honest constants,
deviceCount/lastSeen/currentlyConnected aggregate the user's nodes.
2026-06-24 18:41:06 +02:00
Baptiste Fontaine ec0cb6f63a docs: fix typos 2026-06-24 11:23:20 +02:00
Florian Preinstorfer a066a06bef Fix invalid ip syntax
The "ip" field needs to be a list.
2026-06-24 08:27:33 +02:00
Kristoffer Dalby 33a65052c9 servertest: roundtrip the v2 API through go-client, tscli, and opentofu
Exercise the full surface against the three real clients on a live
server, cross-checking get-after-set, server state, and no Terraform
drift.
2026-06-21 04:17:03 +02:00
Kristoffer Dalby 13ffd958ed hscontrol: add v2 API contract tests
Drive each endpoint in-process with humatest and validate both the
wire shapes and the server-side state: keys, devices, ACL, settings.
2026-06-21 04:17:03 +02:00
Kristoffer Dalby d5555c6b8c servertest: add real-listener option and node/key helpers
Add WithRealListener for tests needing a real loopback port, plus
CreateAPIKey and CreateRegisteredNode for the v2 roundtrip.
2026-06-21 04:17:03 +02:00
Kristoffer Dalby 8898c69d0a flake: add opentofu and tscli for v2 API tests
Provide tofu and tscli in the dev shell, and add the Tailscale Go
client the roundtrip tests drive.
2026-06-21 04:17:03 +02:00
Kristoffer Dalby e2e9fe3081 api/v2: add read-only tailnet settings endpoint
Report the settings the k8s operator reads, computed from config;
writes return 501.
2026-06-21 04:17:03 +02:00
Kristoffer Dalby 3cddc07026 api/v2: add Tailscale-compatible ACL endpoint
Serve and replace the policy as HuJSON with content-addressed ETags
and If-Match preconditions; writes are rejected in file mode.
2026-06-21 04:17:03 +02:00
Kristoffer Dalby b3472bb8dd api/v2: add Tailscale-compatible devices API
Map the Tailscale device surface — get, list, delete, set name, tags,
key expiry, routes, and authorization — onto Headscale nodes.
2026-06-21 04:17:03 +02:00
Kristoffer Dalby 6b413fe237 api/v2: soft-revoke auth keys with a configurable collector
Tailscale's keys API has no separate expire verb: DELETE is the revoke.
Map it to a soft revoke so the key stays retrievable as invalid afterwards
instead of vanishing, matching the SDK and Terraform's expectations.

Add a revoked timestamp to pre-auth keys (migration plus schema), mark a
key invalid once revoked, and have the keys DELETE handler stamp it rather
than destroy the row. A background collector reaps revoked keys after a
configurable retention window (preauth_keys.revoked_retention, default
168h), so the table does not grow without bound.
2026-06-21 04:17:03 +02:00
Kristoffer Dalby ff7dd807fe hscontrol: mount the v2 API at /api/v2
Wire the v2 mux into the router beside v1, threading the state, change
sink, and config through the Backend.
2026-06-21 04:17:03 +02:00
Kristoffer Dalby fe08d43978 api/v2: add Tailscale-compatible keys API
Add the v2 foundation — Basic/Bearer auth, scope scaffolding, the
tailnet check, and the Tailscale error shape — plus the auth-key
endpoints mapped onto Headscale pre-auth keys.
2026-06-21 04:17:03 +02:00
Kristoffer Dalby 2ff342764f state: return ErrGivenNameInvalid for invalid node rename
RenameNode wrapped the raw dnsname error, so the v2 API mapped it to a
500. Emit the sentinel so an invalid name surfaces as a 400.
2026-06-21 04:17:03 +02:00
Kristoffer Dalby 3d811f29a6 db: add API-key owner and pre-auth-key description for v2
Give API keys an optional owning user and pre-auth keys a free-text
description, plus the state accessors the v2 API needs to act as a
key's owner and to persist descriptions.
2026-06-21 04:17:03 +02:00
Kristoffer Dalby 4fa6af0102 policy/v2: write the SSH check period as time arithmetic
Express the maximum SSH check period as 7 * 24 * time.Hour instead of a
bare nanosecond constant, matching how the rest of the code spells out
magic durations.
2026-06-21 04:17:03 +02:00
Kristoffer Dalby 96d736ec23 api/v1: build responses from view accessors, not AsStruct
Serialize node, user and pre-auth-key responses through the NodeView,
UserView and PreAuthKeyView accessors instead of AsStruct(), which clones
the whole record on every read. Document the convention in AGENTS.md.
2026-06-21 04:17:03 +02:00
Kristoffer Dalby 339cb392a9 types: add Node StringID and UserView accessors
Add StringID() on Node/NodeView and Username/Display/CreatedAt on
UserView, so the HTTP read paths render ids and read users through view
accessors instead of cloning with AsStruct().
2026-06-21 04:17:03 +02:00
Kristoffer Dalby 24dfcf178f ci: run build, test, lint and format as nix flake checks
Adopt kradalby/flake-checks: flake.nix exposes build, gotest,
golangci-lint and formatting checks over one shared, fileset-filtered
source set, and nix-checks.yml gates each with
nix build .#checks.<system>.<name>. Drop test.yml and lint.yml —
gotestsum, golangci-lint and prettier are now those checks. golangci-lint
runs full-tree; Go formatting stays in it while formatting adds prettier.

hscontrol/servertest is too slow and timing-sensitive for the sandboxed
gotest check, so servertest.yml runs it in the devShell instead.
2026-06-20 21:08:01 +02:00
Kristoffer Dalby 8f314797ce all: fix full-tree golangci-lint findings
The new full-tree golangci-lint check reports issues the --new-from-rev
diff lint hid: nine wsl_v5 whitespace gaps, a prealloc, and an unparam
(setCSRFCookie never errored, so drop the return and update callers).
gocyclo on the central UpdateNodeFromMapRequest and an SA1019 NetMap
deprecation in an integration helper are suppressed with reasons.
2026-06-20 21:08:01 +02:00
Kristoffer Dalby 2632006f5f ci: drop the DeterminateSystems flake.lock update cron
Remove update-flake.yml; flake.lock is no longer auto-bumped by CI.
2026-06-20 21:08:01 +02:00
Kristoffer Dalby 07b3a8ebd3 ci: migrate Nix CI to official installer and hestia cache
Swap nix-quick-install-action -> nix-installer-action and
cache-nix-action -> Mic92/hestia/action across every Nix workflow, drop
the now-unneeded cache keys, and run devShell tooling through a
defaults.run.shell. Add gc.yml to trim the hestia cache daily.
2026-06-20 21:08:01 +02:00
Florian Preinstorfer 3c504af2be Fix placeholder in curl example 2026-06-20 10:41:57 +02:00
Florian Preinstorfer 7af39a6798 Add headscale-panel to webui docs 2026-06-20 10:41:57 +02:00
Kristoffer Dalby 49d6949639 cli: stop doubling the scheme on a remote CLI address
newRemoteClient prepended "https://" unconditionally, so
HEADSCALE_CLI_ADDRESS=https://host became https://https://host and dialed
the host "https". Default a bare host to https, keep an explicit scheme.
2026-06-20 09:55:42 +02:00
Florian Preinstorfer d7150755d4 TLS is not strictly required 2026-06-19 18:52:30 +02:00
Florian Preinstorfer 88234d4046 Replace /swagger with /api/v1/docs 2026-06-19 18:52:30 +02:00
Florian Preinstorfer 15781ed6d8 Fixup redirect for remote-control 2026-06-19 18:52:30 +02:00
Kristoffer Dalby 72adc5ff2a docs: add changelog and refresh the API reference for the v1 API 2026-06-19 15:21:00 +02:00
Kristoffer Dalby 560b6d81ad hscontrol: remove the proto, gRPC and grpc-gateway stack
With the v1 API served by Huma and the CLI on the HTTP client, nothing
uses the gRPC service or its generated code. Delete proto/, the generated
gen/go and gen/openapiv2, grpcv1.go, the buf config, the proto .Proto()
helpers and gRPC config, and the proto build tooling from the flake and
CI.
2026-06-19 15:21:00 +02:00
Kristoffer Dalby 8efa5ad1fe cli: migrate the CLI and integration tests to the v1 HTTP API
Replace the gRPC client with the generated HTTP client across every
command: locally over the unix socket without auth (matching the previous
local gRPC socket), remotely over TLS with a Bearer API key. Output
rendering and integration tests move to the HTTP client types; the
transport changes, the assertions do not.
2026-06-19 15:21:00 +02:00
Kristoffer Dalby ba90048cfb gen/client/v1: add the generated HTTP client
Generate a strongly-typed Go client (oapi-codegen) from the emitted spec, committed under gen/client/v1 as package clientv1. Tests exercise it against the in-memory Huma server over both TCP and the unix socket.
2026-06-19 15:21:00 +02:00
Kristoffer Dalby 1572ac551d test: add golden parity tests for the v1 API
Pin every endpoint's behaviour with golden fixtures captured from the
retiring grpc-gateway (timestamps and secrets neutralised). Error cases
assert their HTTP status independently of the golden via assertStatus, so
a blind regenerate cannot re-pin a wrong code; HEADSCALE_UPDATE_GOLDEN
rewrites the fixtures. A unit test exercises the API-key auth middleware
on the real server router. Exclude the emitted spec and goldens from
pre-commit checks.
2026-06-19 15:21:00 +02:00
Kristoffer Dalby c9bbb5bdec api/v1: add code-first Huma implementation of the v1 API
Reimplement the v1 API as a code-first Huma service in hscontrol/api/v1,
a thin adapter over the state layer. Huma emits the OpenAPI 3.1 document
(openapi/v1/headscale.yaml) from the Go operation and type definitions;
cmd/gen-openapi writes it and the 3.0.3 downgrade the client is generated
from. Responses reproduce the protojson wire contract (string-encoded
64-bit IDs, all fields emitted, RFC3339/null timestamps); errors map to
the correct HTTP status (404/400/409, 500 for server faults) via mapError.

Serve it on the chi router at /api/v1 behind the existing API-key
middleware, and point /swagger at the emitted spec.
2026-06-19 15:21:00 +02:00
Kristoffer Dalby 97eff90ebe db: report a missing pre-auth key on expire and destroy
Expiring or deleting a pre-auth key that does not exist updated zero rows
and returned no error, so the caller could not tell a missing key from a
successful no-op. Return ErrPreAuthKeyNotFound when RowsAffected is zero.
2026-06-19 15:21:00 +02:00
310 changed files with 27677 additions and 29896 deletions
+1 -10
View File
@@ -1,10 +1 @@
* @juanfont @kradalby
*.md @ohdearaugustin @nblock
*.yml @ohdearaugustin @nblock
*.yaml @ohdearaugustin @nblock
Dockerfile* @ohdearaugustin @nblock
.goreleaser.yaml @ohdearaugustin @nblock
/docs/ @ohdearaugustin @nblock
/.github/workflows/ @ohdearaugustin @nblock
/.github/renovate.json @ohdearaugustin @nblock
* @juanfont @kradalby @nblock
+119
View File
@@ -0,0 +1,119 @@
# Vouched (or actively denounced) users for this repository.
#
# Headscale gates pull requests to vouched contributors only. Issues
# remain open to everyone see CONTRIBUTING.md for the rationale and
# the process for getting vouched.
#
# A denounced user (prefix `-`) is explicitly blocked from contributing.
#
# Syntax:
# - One handle per line (without @). Sorted alphabetically.
# - Optionally specify platform: `platform:username` (e.g., `github:nblock`).
# - To denounce, prefix with minus: `-username` or `-platform:username`.
# - Optionally, add a comment after a space following the handle.
#
# Maintainers can vouch for new contributors by commenting `!vouch` (or
# `!vouch @username`) on any issue or PR by the author. `!denounce` blocks.
aalmenar
adoolaard
adriangoransson
Aluxima
AlynxZhou
apollo13
aradng
ArcticLampyrid
bobelev
ChibangLW
christian-heusel
ckiee
codethief
cure
dmeremyanin
dotlambda
dragetd
e-zk
edvardvb
enoperm
EtaoinWu
evenh
eyJhb
felixscheinost
FlorinPeter
fredrikekre
griffer
GrigoriyMikhalkin
gymnae
huskyii
igrikus
ImpostorKeanu
isaac-mcfadyen
iSchluff
ItalyPaleAle
ItsShadowCone
jdewinne
joachimtingvold
jonathanspw
juanfont
kazauwa
korfuri
kradalby
linsomniac
lyc8503
madjam002
majst01
maprambo
marcelmindemann
maxpain
mevansam
mike-lloyd03
motiejus
mpldr
nblock
negbie
Nemo157
nicka101
Niek
nielscil
NoelGisler
noseshimself
ohdearaugustin
oplik0
Orhideous
pallabpain
philippderdiedas
Polsaker
PowershellScripter
ptman
puzpuzpuz
pvinis
qbit
QEDeD
qoke
Qup42
redstonekasi
restanrm
reynico
Riezebos
samson4649
SlackingVeteran
spymobilfon
stavros-k
stblassitude
SuperSandro2000
SysAdminSmith
sysvinit
teleclimber
tgrushka
thebigbone
tianon
TotoTheDragon
tsujamin
unreality
vbrandl
vdovhanych
victorhooi
vsychov
yaneony
yaroslavkasatikov
Zottelchen
+25
View File
@@ -0,0 +1,25 @@
Hi @{author}, thanks for your interest in headscale.
Headscale gates pull requests to vouched contributors only. Your account
isn't on the list yet, so this PR has been auto-closed. This is **not** a
judgment of you or this change.
Headscale is an "Open Source, acknowledged contribution" project: every
contribution starts with a discussion on the issue tracker, **before** any
code is written. See [`CONTRIBUTING.md`](https://github.com/juanfont/headscale/blob/main/CONTRIBUTING.md)
for the full policy and the rationale.
To get vouched:
1. Open an issue describing the change you'd like to make (use case,
implementation sketch, who will maintain it). For bug fixes, a clear
reproduction is enough.
2. Engage with maintainers on the issue. Once we've agreed on scope and
approach, a maintainer will `!vouch @{author}`.
3. You can then reopen this PR (or open a new one) and it will pass the
gate.
Issues remain open to everyone — no vouching needed there, and that's the
intended starting point for any contribution.
The vouched list: [`.github/VOUCHED.td`](https://github.com/juanfont/headscale/blob/main/.github/VOUCHED.td).
+1 -1
View File
@@ -19,4 +19,4 @@ If you find mistakes in the documentation, please submit a fix to the documentat
- [ ] updated documentation if needed
- [ ] updated CHANGELOG.md
<!-- If applicable, please reference the issue using `Fixes #XXX` and add tests to cover your new code. -->
<!-- If applicable, please reference the issue using `Fixes #XXX` and add tests to cover your new code. -->
+10 -14
View File
@@ -10,6 +10,10 @@ concurrency:
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
cancel-in-progress: true
defaults:
run:
shell: nix develop --fallback --command bash -e {0}
jobs:
build-nix:
runs-on: ubuntu-latest
@@ -29,20 +33,16 @@ jobs:
- '**/*.go'
- 'integration_test/'
- 'config-example.yaml'
- uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34
- uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main
if: steps.changed-files.outputs.files == 'true'
- uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3
- uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main
if: steps.changed-files.outputs.files == 'true'
with:
primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix',
'**/flake.lock') }}
restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }}
- name: Check vendor hash
id: vendorhash
if: steps.changed-files.outputs.files == 'true'
run: |
nix develop --fallback --command -- go run ./cmd/vendorhash check | tee check-result
go run ./cmd/vendorhash check | tee check-result
{
grep '^expected_sri=' check-result || true
grep '^actual_sri=' check-result || true
@@ -81,17 +81,13 @@ jobs:
- "GOARCH=amd64 GOOS=darwin"
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34
- uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3
with:
primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix',
'**/flake.lock') }}
restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }}
- uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main
- uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main
- name: Run go cross compile
env:
CGO_ENABLED: 0
run: env ${{ matrix.env }} nix develop --fallback --command -- go build -o "headscale"
run: env ${{ matrix.env }} go build -o "headscale"
./cmd/headscale
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
+7 -7
View File
@@ -12,6 +12,10 @@ concurrency:
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
cancel-in-progress: true
defaults:
run:
shell: nix develop --fallback --command bash -e {0}
jobs:
check-generated:
runs-on: ubuntu-latest
@@ -28,19 +32,15 @@ jobs:
- '*.nix'
- 'go.*'
- '**/*.go'
- 'openapi/**'
- 'tools/**'
- uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34
- uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main
if: steps.changed-files.outputs.files == 'true'
- uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3
- uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main
if: steps.changed-files.outputs.files == 'true'
with:
primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', '**/flake.lock') }}
restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }}
- name: Run make generate
if: steps.changed-files.outputs.files == 'true'
run: nix develop --fallback --command -- make generate
run: make generate
- name: Check for uncommitted changes
if: steps.changed-files.outputs.files == 'true'
+7 -7
View File
@@ -6,6 +6,10 @@ concurrency:
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
cancel-in-progress: true
defaults:
run:
shell: nix develop --fallback --command bash -e {0}
jobs:
check-tests:
runs-on: ubuntu-latest
@@ -24,19 +28,15 @@ jobs:
- '**/*.go'
- 'integration_test/'
- 'config-example.yaml'
- uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34
- uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main
if: steps.changed-files.outputs.files == 'true'
- uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3
- uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main
if: steps.changed-files.outputs.files == 'true'
with:
primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix',
'**/flake.lock') }}
restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }}
- name: Generate and check integration tests
if: steps.changed-files.outputs.files == 'true'
run: |
nix develop --fallback --command bash -c "cd .github/workflows && go generate"
(cd .github/workflows && go generate)
git diff --exit-code .github/workflows/test-integration.yaml
- name: Show missing tests
+11 -15
View File
@@ -16,6 +16,10 @@ concurrency:
group: ${{ github.workflow }}-${{ github.sha }}
cancel-in-progress: true
defaults:
run:
shell: nix develop --fallback --command bash -e {0}
jobs:
container:
if: github.repository == 'juanfont/headscale'
@@ -40,12 +44,8 @@ jobs:
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34
- uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3
with:
primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix',
'**/flake.lock') }}
restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }}
- uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main
- uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main
- name: Set commit timestamp
run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> $GITHUB_ENV
@@ -56,7 +56,7 @@ jobs:
KO_DEFAULTBASEIMAGE: gcr.io/distroless/base-debian13
CGO_ENABLED: "0"
run: |
nix develop --fallback --command -- ko build \
ko build \
--bare \
--platform=linux/amd64,linux/arm64 \
--tags=main-${GITHUB_SHA::7},development \
@@ -68,7 +68,7 @@ jobs:
KO_DEFAULTBASEIMAGE: gcr.io/distroless/base-debian13
CGO_ENABLED: "0"
run: |
nix develop --fallback --command -- ko build \
ko build \
--bare \
--platform=linux/amd64,linux/arm64 \
--tags=main-${GITHUB_SHA::7},development \
@@ -92,19 +92,15 @@ jobs:
- name: Checkout
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34
- uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3
with:
primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix',
'**/flake.lock') }}
restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }}
- uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main
- uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main
- name: Build binary
env:
CGO_ENABLED: "0"
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
run: nix develop --fallback --command -- go build -o headscale ./cmd/headscale
run: go build -o headscale ./cmd/headscale
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
+39
View File
@@ -0,0 +1,39 @@
name: Cache GC
# Garbage collection for the hestia binary cache. Must run on the default
# branch: a PR job's cache scope is read-only towards the default branch and
# dies with the PR, but the default-branch scope grows forever without GC.
concurrency:
group: hestia-gc
cancel-in-progress: false
on:
schedule:
# Daily, off-peak (UTC).
- cron: "23 3 * * *"
workflow_dispatch:
inputs:
dry-run:
description: Plan only; do not repack, touch, or delete anything.
type: boolean
default: false
permissions:
contents: read
jobs:
gc:
runs-on: ubuntu-latest
permissions:
# REST cache deletes need actions:write.
actions: write
contents: read
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main
- uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main
- name: Run garbage collection
run: '"${HESTIA_BIN}" gc ${{ inputs.dry-run && ''--dry-run'' || '''' }}'
env:
GITHUB_TOKEN: ${{ github.token }}
@@ -78,6 +78,13 @@ jobs:
echo '{"storage-driver":"overlay2"}' | sudo tee /etc/docker/daemon.json
sudo systemctl restart docker
docker version
- name: Load br_netfilter for in-cluster service routing
if: inputs.test == 'TestK8sOperator'
# TestK8sOperator runs k3s in a container; without br_netfilter on the
# host, bridged pod-to-pod traffic skips kube-proxy's ClusterIP DNAT and
# in-cluster DNS (kube-dns) is unreachable. The module cannot be loaded
# from inside the unprivileged-module rancher/k3s image, so load it here.
run: sudo modprobe br_netfilter
- name: Login to Docker Hub
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_CI_USERNAME }}
-79
View File
@@ -1,79 +0,0 @@
name: Lint
on: [pull_request]
concurrency:
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
golangci-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 0
- name: Get changed files
id: changed-files
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
with:
filters: |
files:
- '*.nix'
- 'go.*'
- '**/*.go'
- 'integration_test/'
- 'config-example.yaml'
- uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34
if: steps.changed-files.outputs.files == 'true'
- uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3
if: steps.changed-files.outputs.files == 'true'
with:
primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix',
'**/flake.lock') }}
restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }}
- name: golangci-lint
if: steps.changed-files.outputs.files == 'true'
run: nix develop --fallback --command -- golangci-lint run
--new-from-rev=${{github.event.pull_request.base.sha}}
--output.text.path=stdout
--output.text.print-linter-name
--output.text.print-issued-lines
--output.text.colors
prettier-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 2
- name: Get changed files
id: changed-files
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
with:
filters: |
files:
- '*.nix'
- '**/*.md'
- '**/*.yml'
- '**/*.yaml'
- '**/*.ts'
- '**/*.js'
- '**/*.sass'
- '**/*.css'
- '**/*.scss'
- '**/*.html'
- uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34
if: steps.changed-files.outputs.files == 'true'
- uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3
if: steps.changed-files.outputs.files == 'true'
with:
primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix',
'**/flake.lock') }}
restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }}
- name: Prettify code
if: steps.changed-files.outputs.files == 'true'
run: nix develop --fallback --command -- prettier --no-error-on-unmatched-pattern
--ignore-unknown --check **/*.{ts,js,md,yaml,yml,sass,css,scss,html}
+56
View File
@@ -0,0 +1,56 @@
name: Nix Flake Checks
on:
push:
branches:
- main
pull_request:
branches:
- main
concurrency:
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
cancel-in-progress: true
# Each job only runs `nix build .#checks.<system>.<name>`; the check logic lives
# in flake.nix via the flake-checks library. The fileset-filtered checks hit the
# hestia cache when their inputs are unchanged, so no changed-files gating.
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main
- uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main
- name: build
run: nix build -L .#checks.x86_64-linux.build
gotest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main
- uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main
- name: gotest
run: nix build -L .#checks.x86_64-linux.gotest
golangci-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main
- uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main
- name: golangci-lint
run: nix build -L .#checks.x86_64-linux.golangci-lint
formatting:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main
- uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main
- name: formatting
run: nix build -L .#checks.x86_64-linux.formatting
+2 -6
View File
@@ -38,15 +38,11 @@ jobs:
- 'cmd/**'
- 'hscontrol/**'
- uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34
- uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main
if: steps.changed-files.outputs.nix == 'true' || steps.changed-files.outputs.go == 'true'
- uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3
- uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main
if: steps.changed-files.outputs.nix == 'true' || steps.changed-files.outputs.go == 'true'
with:
primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix',
'**/flake.lock') }}
restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }}
- name: Run NixOS module tests
if: steps.changed-files.outputs.nix == 'true' || steps.changed-files.outputs.go == 'true'
+7 -7
View File
@@ -7,6 +7,10 @@ on:
- "*" # triggers only if push new tag version
workflow_dispatch:
defaults:
run:
shell: nix develop --fallback --command bash -e {0}
jobs:
goreleaser:
if: github.repository == 'juanfont/headscale'
@@ -30,14 +34,10 @@ jobs:
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34
- uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3
with:
primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix',
'**/flake.lock') }}
restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }}
- uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main
- uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main
- name: Run goreleaser
run: nix develop --fallback --command -- goreleaser release --clean
run: goreleaser release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+36
View File
@@ -0,0 +1,36 @@
name: Server Tests
on:
push:
branches:
- main
pull_request:
branches:
- main
concurrency:
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
cancel-in-progress: true
# hscontrol/servertest is excluded from the sandboxed gotest flake check: it is
# slow (10s+ convergence cases) and timing-sensitive (race/stress/HA property
# tests), so it runs here in the devShell with a generous timeout instead.
defaults:
run:
shell: nix develop --fallback --command bash -e {0}
permissions:
contents: read
jobs:
servertest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main
- uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main
- name: go test ./hscontrol/servertest
env:
CGO_ENABLED: "0"
run: go test -timeout=20m ./hscontrol/servertest/...
+21 -17
View File
@@ -6,6 +6,9 @@ on: [pull_request]
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
defaults:
run:
shell: nix develop --fallback --command bash -e {0}
jobs:
# build: Builds binaries and Docker images once, uploads as artifacts for reuse.
# build-postgres: Pulls postgres image separately to avoid Docker Hub rate limits.
@@ -36,23 +39,18 @@ jobs:
- '.github/workflows/test-integration.yaml'
- '.github/workflows/integration-test-template.yml'
- 'Dockerfile.*'
- uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34
- uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main
if: steps.changed-files.outputs.files == 'true'
- uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3
- uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main
if: steps.changed-files.outputs.files == 'true'
with:
primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', '**/flake.lock') }}
restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }}
- name: Build binaries and warm Go cache
if: steps.changed-files.outputs.files == 'true'
run: |
# Build all Go binaries in one nix shell to maximize cache reuse
nix develop --fallback --command -- bash -c '
go build -o hi ./cmd/hi
CGO_ENABLED=0 GOOS=linux go build -o headscale ./cmd/headscale
# Build integration test binary to warm the cache with all dependencies
go test -c ./integration -o /dev/null 2>/dev/null || true
'
go build -o hi ./cmd/hi
CGO_ENABLED=0 GOOS=linux go build -o headscale ./cmd/headscale
# Build integration test binary to warm the cache with all dependencies
go test -c ./integration -o /dev/null 2>/dev/null || true
- name: Upload hi binary
if: steps.changed-files.outputs.files == 'true'
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
@@ -128,6 +126,7 @@ jobs:
if: needs.build.outputs.files-changed == 'true'
steps:
- name: Force overlay2 storage driver
shell: bash
run: |
sudo mkdir -p /etc/docker
echo '{"storage-driver":"overlay2"}' | sudo tee /etc/docker/daemon.json
@@ -143,6 +142,7 @@ jobs:
username: ${{ env.DOCKERHUB_USERNAME }}
password: ${{ env.DOCKERHUB_TOKEN }}
- name: Pull and save postgres image
shell: bash
run: |
docker pull postgres:latest
docker tag postgres:latest postgres:${{ github.sha }}
@@ -159,11 +159,8 @@ jobs:
if: needs.build.outputs.files-changed == 'true'
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34
- uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3
with:
primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', '**/flake.lock') }}
restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }}
- uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main
- uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main
- name: Force overlay2 storage driver
run: |
sudo mkdir -p /etc/docker
@@ -182,7 +179,7 @@ jobs:
- name: List Tailscale versions to pre-pull
id: versions
run: |
versions=$(nix develop --fallback --command go run ./cmd/hi list-versions --set=must --exclude=head)
versions=$(go run ./cmd/hi list-versions --set=must --exclude=head)
echo "versions=${versions}" >> "$GITHUB_OUTPUT"
echo "Pre-pulling: ${versions}"
- name: Pull Tailscale images
@@ -244,6 +241,8 @@ jobs:
- TestACLDynamicUnknownUserRemoval
- TestAPIAuthenticationBypass
- TestAPIAuthenticationBypassCurl
- TestRemoteCLIAuthenticationBypass
- TestCLIWithConfigAuthenticationBypass
- TestAuthKeyLogoutAndReloginSameUser
- TestAuthKeyLogoutAndReloginNewUser
- TestAuthKeyLogoutAndReloginSameUserExpiredKey
@@ -276,6 +275,8 @@ jobs:
- TestNodeTagCommand
- TestNodeRouteCommands
- TestNodeBackfillIPsCommand
- TestOAuthClientCommand
- TestOAuthClientCommandValidation
- TestPolicyCheckCommand
- TestSSHTestsRejectFailingPolicy
- TestPolicyCommand
@@ -310,6 +311,7 @@ jobs:
- Test2118DeletingOnlineNodePanics
- TestGrantCapRelay
- TestGrantCapDrive
- TestK8sOperator
- TestEnablingRoutes
- TestHASubnetRouterFailover
- TestSubnetRouteACL
@@ -377,6 +379,8 @@ jobs:
- TestTagsAuthKeyWithoutUserInheritsTags
- TestTagsAuthKeyWithoutUserRejectsAdvertisedTags
- TestTagsAuthKeyConvertToUserViaCLIRegister
- TestTS2021WebSocketGET
- TestTS2021WASMClientUnderNode
- TestTailscaleRustAxum
uses: ./.github/workflows/integration-test-template.yml
secrets: inherit
-47
View File
@@ -1,47 +0,0 @@
name: Tests
on: [push, pull_request]
concurrency:
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 2
- name: Get changed files
id: changed-files
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
with:
filters: |
files:
- '*.nix'
- 'go.*'
- '**/*.go'
- 'integration_test/'
- 'config-example.yaml'
- uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34
if: steps.changed-files.outputs.files == 'true'
- uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3
if: steps.changed-files.outputs.files == 'true'
with:
primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix',
'**/flake.lock') }}
restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }}
- 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 --fallback --command -- gotestsum
-19
View File
@@ -1,19 +0,0 @@
name: update-flake-lock
on:
workflow_dispatch: # allows manual triggering
schedule:
- cron: "0 0 * * 0" # runs weekly on Sunday at 00:00
jobs:
lockfile:
if: github.repository == 'juanfont/headscale'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@21a544727d0c62386e78b4befe52d19ad12692e3 # v17
- name: Update flake.lock
uses: DeterminateSystems/update-flake-lock@428c2b58a4b7414dabd372acb6a03dba1084d3ab # v25
with:
pr-title: "Update flake.lock"
+30
View File
@@ -0,0 +1,30 @@
name: "Vouch - Check PR"
on:
pull_request_target:
types: [opened, reopened]
permissions:
pull-requests: write
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
id: app-token
with:
app-id: ${{ secrets.VOUCH_APP_ID }}
private-key: ${{ secrets.VOUCH_APP_PRIVATE_KEY }}
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
sparse-checkout: .github/pr-unvouched-message
- uses: mitchellh/vouch/action/check-pr@d66fa29a64600490892131ad87597c30c91fcac4 # v1.5.0
with:
pr-number: ${{ github.event.pull_request.number }}
auto-close: true
template-file: .github/pr-unvouched-message
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
@@ -0,0 +1,36 @@
name: "Vouch - Manage by Issue"
on:
issue_comment:
types: [created]
concurrency:
group: vouch-manage
cancel-in-progress: false
jobs:
manage:
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
id: app-token
with:
app-id: ${{ secrets.VOUCH_APP_ID }}
private-key: ${{ secrets.VOUCH_APP_PRIVATE_KEY }}
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
token: ${{ steps.app-token.outputs.token }}
- uses: mitchellh/vouch/action/manage-by-issue@d66fa29a64600490892131ad87597c30c91fcac4 # v1.5.0
with:
repo: ${{ github.repository }}
issue-id: ${{ github.event.issue.number }}
comment-id: ${{ github.event.comment.id }}
vouch-keyword: "!vouch"
denounce-keyword: "!denounce"
unvouch-keyword: "!unvouch"
pull-request: "true"
merge-immediately: "true"
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
@@ -0,0 +1,32 @@
name: "Vouch - Sync CODEOWNERS"
on:
schedule:
- cron: "0 0 * * 1"
workflow_dispatch:
concurrency:
group: vouch-manage
cancel-in-progress: false
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
id: app-token
with:
app-id: ${{ secrets.VOUCH_APP_ID }}
private-key: ${{ secrets.VOUCH_APP_PRIVATE_KEY }}
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
token: ${{ steps.app-token.outputs.token }}
- uses: mitchellh/vouch/action/sync-codeowners@d66fa29a64600490892131ad87597c30c91fcac4 # v1.5.0
with:
repo: ${{ github.repository }}
pull-request: "true"
merge-immediately: "true"
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
+3
View File
@@ -46,6 +46,9 @@ result
integration_test/etc/config.dump.yaml
# OpenAPI spec is served live from the code and emitted on demand, not committed
/openapi/v1/headscale.yaml
# MkDocs
.cache
/site
+3 -2
View File
@@ -2,8 +2,9 @@
# See: https://prek.j178.dev/quickstart/
# See: https://prek.j178.dev/builtin/
# Global exclusions - ignore generated code
exclude: ^gen/
# Global exclusions - ignore generated code (proto output and emitted OpenAPI)
# and recorded golden fixtures.
exclude: ^(gen|openapi)/|^hscontrol/testdata/apiv1_golden/
repos:
# Built-in hooks from pre-commit/pre-commit-hooks
+15 -12
View File
@@ -75,9 +75,9 @@ make dev
# Individual targets
make build # build the headscale binary
make test # go test ./...
make fmt # format Go and docs
make lint # lint Go
make generate # regenerate ogen code (after changes to openapi/)
make fmt # format Go, docs, proto
make lint # lint Go, proto
make generate # regenerate protobuf code (after changes to proto/)
make clean # remove build artefacts
# Direct go test invocations
@@ -133,11 +133,9 @@ headscale/
### `hscontrol/` packages
- `app.go`, `handlers.go`, `noise.go`, `auth.go`, `oidc.go`,
- `app.go`, `handlers.go`, `grpcv1.go`, `noise.go`, `auth.go`, `oidc.go`,
`poll.go`, `metrics.go`, `debug.go`, `tailsql.go`, `platform_config.go`
— top-level server files
- `api/v1/` — the ogen-backed v1 HTTP API handlers (`server.go`,
`convert.go`, `errors.go`, per-resource handler files)
- `state/` — central coordinator (`state.go`) and the copy-on-write
`NodeStore` (`node_store.go`). All cross-subsystem operations go
through `State`.
@@ -211,7 +209,7 @@ both. This is a load-bearing architectural rule.
- `SetTags` validation is enforced by `validateNodeOwnership()` in
`hscontrol/state/tags.go`.
- Examples and edge cases live in `hscontrol/types/node_tags_test.go`
and the SetTags tests in `hscontrol/servertest/apiv1_nodes_test.go`.
and `hscontrol/grpcv1_test.go` (`TestSetTags_*`).
**Don't do this**:
@@ -266,9 +264,9 @@ Key reminders:
Not Conventional Commits. No `feat:`/`chore:`/`docs:` prefixes.
- **Code generation**: changes under `openapi/` require `make generate`
(which runs `ogen`) and should land in a **separate commit** from the
callers that use the regenerated types.
- **Protobuf regeneration**: changes under `proto/` require
`make generate` (which runs `buf generate`) and should land in a
**separate commit** from the callers that use the regenerated types.
- **Formatting** is enforced by `golangci-lint` with `golines` (width 88)
and `gofumpt`. Run `make fmt` or rely on the pre-commit hook.
- **Logging** uses `zerolog`. Prefer single-line chains
@@ -277,6 +275,11 @@ Key reminders:
`e = e.Str("k", v)`. Forgetting to reassign silently drops the field.
- **Tests**: prefer `hscontrol/servertest/` for server-level tests that
don't need Docker — faster than full integration tests.
- **View types in read paths**: response serializers must read through
`NodeView`/`UserView`/`PreAuthKeyView` accessors. `AsStruct()` clones the
whole record on every read — it is only for DB-write/merge clones and mutable
working copies, never to build an API response. `grep AsStruct hscontrol/api`
must come back empty.
## Gotchas
@@ -288,6 +291,6 @@ Key reminders:
- **`.claude/agents/` is deprecated.** Do not create new agent files
there. Put behavioural guidance in this file and procedural guidance
in the nearest README.
- **Do not edit `gen/`** — it is regenerated from `openapi/` by
- **Do not edit `gen/`** — it is regenerated from `proto/` by
`make generate`.
- **Spec changes + code changes should be two commits**, not one.
- **Proto changes + code changes should be two commits**, not one.
+51
View File
@@ -4,6 +4,57 @@
**Minimum supported Tailscale client version: v1.xx.0**
### v1 REST API replaced; gRPC and Protobuf removed
The v1 REST API now provides an OpenAPI 3.1 specification at
`/api/v1/openapi.yaml`, with interactive documentation at `/api/v1/docs`. This
replaces the Swagger 2.0 document and the `/swagger` UI. The Protobuf, gRPC and
grpc-gateway stack behind it is gone, and the `headscale` CLI now talks to the
HTTP API directly.
[#3324](https://github.com/juanfont/headscale/pull/3324)
### OAuth clients and scopes for the v2 API
The v2 API now authenticates with OAuth 2.0 client-credentials, the way the
Tailscale ecosystem does. An OAuth client mints short-lived access tokens whose
scopes limit which operations they may perform and whose tags limit the devices
they may create, so a credential can be issued with only the access it needs.
The `headscale oauth-clients` command manages them. This lets the Tailscale
Terraform provider and Kubernetes operator drive Headscale unchanged; admin API
keys remain all-access.
[#3334](https://github.com/juanfont/headscale/pull/3334)
### BREAKING
#### API
- The gRPC API is removed; all programmatic access now goes through the HTTP API at `/api/v1` [#3324](https://github.com/juanfont/headscale/pull/3324)
- API errors are now RFC 7807 `application/problem+json`, including authentication failures, instead of the previous gRPC-status JSON shape [#3324](https://github.com/juanfont/headscale/pull/3324)
- Errors that previously returned HTTP 500 — unknown users or nodes, malformed input, duplicate names — now return the correct 404, 400 or 409 [#3324](https://github.com/juanfont/headscale/pull/3324)
- The OpenAPI document is OpenAPI 3.1 at `/api/v1/openapi.yaml` (docs at `/api/v1/docs`), replacing Swagger 2.0 at `/swagger` [#3324](https://github.com/juanfont/headscale/pull/3324)
#### CLI
- `--output json` / `--output yaml` now emit the API's shape — camelCase fields, string-encoded IDs, RFC3339 timestamps — instead of the old Protobuf encoding [#3324](https://github.com/juanfont/headscale/pull/3324)
- `headscale policy` renames the database-bypass flag from `--bypass-grpc-and-access-database-directly` to `--bypass-server-and-access-database-directly` [#3324](https://github.com/juanfont/headscale/pull/3324)
### Changes
- Expiring or deleting a non-existent pre-auth key now returns an error instead of silently succeeding [#3324](https://github.com/juanfont/headscale/pull/3324)
- Improve systemd service file hardening [#3341](https://github.com/juanfont/headscale/pull/3341)
## 0.29.2 (2026-07-01)
**Minimum supported Tailscale client version: v1.80.0**
### Changes
- Fix map generation serializing on the policy lock, so a mass reconnect on `autogroup:self`, via or relay policies no longer stalls clients into `unexpected EOF` retry loops [#3358](https://github.com/juanfont/headscale/pull/3358)
- Fix `/ts2021` rejecting the WebSocket `GET` upgrade with 405, which prevented Tailscale JS/WASM control clients from connecting [#3359](https://github.com/juanfont/headscale/pull/3359)
- Gracefully handle nodes with an invalid FQDN (empty or too long) instead of failing map delivery; offending names are logged at startup with the fix command [#3349](https://github.com/juanfont/headscale/pull/3349)
## 0.29.1 (2026-06-18)
**Minimum supported Tailscale client version: v1.80.0**
+30
View File
@@ -0,0 +1,30 @@
# For integration testing only.
#
# Builds the Tailscale control client (integration/wasmic/wasmclient) for
# GOOS=js/GOARCH=wasm and packages it with Go's wasm_exec Node runner. The
# container idles; the integration test execs
# node /app/wasm_exec_node.js /app/client.wasm <control-url>
# to drive a real browser-style WebSocket GET against headscale's /ts2021,
# guarding the regression in issue #3357.
FROM golang:1.26.4-alpine AS build
WORKDIR /src
# Only the module metadata and the wasm client package are needed to build the
# js/wasm binary; its imports (tailscale.com/control/controlhttp, ...) resolve
# from the module proxy.
COPY go.mod go.sum ./
COPY integration/wasmic/wasmclient ./integration/wasmic/wasmclient
RUN GOOS=js GOARCH=wasm go build -o /out/client.wasm ./integration/wasmic/wasmclient \
&& cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" /out/wasm_exec.js \
&& cp "$(go env GOROOT)/lib/wasm/wasm_exec_node.js" /out/wasm_exec_node.js
FROM node:24-alpine
WORKDIR /app
COPY --from=build /out/ /app/
# Idle; the test execs the client on demand with the headscale control URL.
ENTRYPOINT ["tail", "-f", "/dev/null"]
+27 -3
View File
@@ -82,11 +82,35 @@ lint-go: check-deps $(GO_SOURCES) go.mod go.sum
generate: check-deps
@echo "Generating code..."
go generate ./...
$(MAKE) client
# Emit the OpenAPI spec on demand. The server serves it live at /openapi.yaml;
# this is for external consumers or inspection and is not committed.
.PHONY: openapi
openapi:
@echo "Emitting OpenAPI spec from code..."
go run ./cmd/gen-openapi
# Generate the strongly-typed Go HTTP clients (v1 and v2). The served specs are
# OpenAPI 3.1, but oapi-codegen v2 does not yet read 3.1, so each client is
# generated from a transient 3.0.3 downgrade of its document. Pinned so the
# committed clients are reproducible.
.PHONY: client
client:
@echo "Generating API clients..."
@tmp=$$(mktemp -t headscale-openapi-3.0.XXXXXX.yaml); \
go run ./cmd/gen-openapi -downgrade "$$tmp" && \
go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@v2.7.1 \
-generate types,client -package clientv1 -o gen/client/v1/client.gen.go "$$tmp" && \
go run ./cmd/gen-openapi -api v2 -downgrade "$$tmp" && \
go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@v2.7.1 \
-generate types,client -package clientv2 -o gen/client/v2/client.gen.go "$$tmp"; \
status=$$?; rm -f "$$tmp"; exit $$status
# Clean targets
.PHONY: clean
clean:
rm -rf headscale gen
rm -rf headscale gen/client
# Development workflow
.PHONY: dev
@@ -106,9 +130,9 @@ help:
@echo " all - Run lint, test, and build (default)"
@echo " build - Build headscale binary"
@echo " test - Run Go tests"
@echo " fmt - Format all code (Go, docs)"
@echo " fmt - Format all code (Go, docs, markup)"
@echo " lint - Lint all code (Go)"
@echo " generate - Generate code from the OpenAPI spec"
@echo " generate - Generate code (go generate + client)"
@echo " dev - Full development workflow (fmt + lint + test + build)"
@echo " clean - Clean build artifacts"
@echo ""
+9 -3
View File
@@ -92,7 +92,8 @@ Please read the [CONTRIBUTING.md](./CONTRIBUTING.md) file.
### Requirements
To contribute to headscale you would need the latest version of [Go](https://golang.org).
To contribute to headscale you would need the latest version of [Go](https://golang.org)
and [Buf](https://buf.build) (Protobuf generator).
We recommend using [Nix](https://nixos.org/) to setup a development environment. This can
be done with `nix develop`, which will install the tools and give you a shell.
@@ -109,6 +110,9 @@ formatted with [`golines`](https://github.com/segmentio/golines) (width 88) and
Please configure your editor to run the tools while developing and make sure to
run `make lint` and `make fmt` before committing any code.
The **Proto** code is linted with [`buf`](https://docs.buf.build/lint/overview) and
formatted with [`clang-format`](https://clang.llvm.org/docs/ClangFormat.html).
The **docs** are formatted with [`mdformat`](https://mdformat.readthedocs.io).
The **rest** (Markdown, YAML, etc) is formatted with [`prettier`](https://prettier.io).
@@ -118,6 +122,8 @@ Check out the `.golangci.yaml` and `Makefile` to see the specific configuration.
### Install development tools
- Go
- Buf
- Protobuf tools
Install and activate:
@@ -127,8 +133,8 @@ nix develop
### Testing and building
Some parts of the project require generated Go code from the OpenAPI spec
(if changes are made in `openapi/`) and it must be (re-)generated with:
Some parts of the project require the generation of Go code from Protobuf
(if changes are made in `proto/`) and it must be (re-)generated with:
```shell
make generate
+2 -2
View File
@@ -28,8 +28,8 @@ var errHealthTimeout = errors.New("health check timed out")
var errEmptyAuthKey = errors.New("empty auth key in response")
// maxDevPort is the highest --port value that keeps the derived metrics port
// (port+1010) inside the valid 1..65535 TCP range.
// maxDevPort is the highest --port value that keeps the derived metrics
// port (port+1010) inside the valid 1..65535 TCP range.
const maxDevPort = 64525
const devConfig = `---
+75
View File
@@ -0,0 +1,75 @@
// Command gen-openapi emits a Headscale OpenAPI document from the authoritative
// Huma definitions in hscontrol/api/v1 and hscontrol/api/v2. The server also
// serves each spec live (at /openapi.yaml and /api/v2/openapi); this tool emits
// them on demand, and with -downgrade the 3.0.3 form used to generate the typed
// client. The output is not committed.
//
// Usage:
//
// go run ./cmd/gen-openapi # write the v1 3.1 spec to its default path
// go run ./cmd/gen-openapi -api v2 # write the v2 3.1 spec to its default path
// go run ./cmd/gen-openapi -downgrade <path> # write the v1 3.0.3 downgrade (for client gen)
// go run ./cmd/gen-openapi -api v2 -downgrade <path> # the v2 3.0.3 downgrade
package main
import (
"flag"
"log"
"os"
"path/filepath"
apiv1 "github.com/juanfont/headscale/hscontrol/api/v1"
apiv2 "github.com/juanfont/headscale/hscontrol/api/v2"
)
// spec bundles a version's full (3.1) and downgraded (3.0.3) generators with the
// committed output path. outPath is relative to the repository root.
type spec struct {
full func() ([]byte, error)
down func() ([]byte, error)
outPath string
}
// specs maps the -api value to its generators.
var specs = map[string]spec{
"v1": {apiv1.Spec, apiv1.Spec30, "openapi/v1/headscale.yaml"},
"v2": {apiv2.Spec, apiv2.Spec30, "openapi/v2/headscale.yaml"},
}
func main() {
api := flag.String("api", "v1", "which API spec to emit: v1 or v2")
downgrade := flag.String("downgrade", "", "write the OpenAPI 3.0.3 downgrade to this path instead of the committed 3.1 spec")
flag.Parse()
s, ok := specs[*api]
if !ok {
log.Fatalf("unknown -api %q (want v1 or v2)", *api)
}
if *downgrade != "" {
writeSpec(*downgrade, s.down)
return
}
writeSpec(s.outPath, s.full)
}
func writeSpec(path string, gen func() ([]byte, error)) {
spec, err := gen()
if err != nil {
log.Fatalf("generating OpenAPI spec: %v", err)
}
err = os.MkdirAll(filepath.Dir(path), 0o755)
if err != nil {
log.Fatalf("creating output directory: %v", err)
}
err = os.WriteFile(path, spec, 0o600)
if err != nil {
log.Fatalf("writing %s: %v", path, err)
}
log.Printf("wrote %s", path)
}
+80 -39
View File
@@ -2,11 +2,11 @@ package cli
import (
"context"
"errors"
"fmt"
"net/http"
"strconv"
apiv1 "github.com/juanfont/headscale/gen/api/v1"
clientv1 "github.com/juanfont/headscale/gen/client/v1"
"github.com/juanfont/headscale/hscontrol/util"
"github.com/spf13/cobra"
)
@@ -16,8 +16,6 @@ const (
DefaultAPIKeyExpiry = "90d"
)
var errAPIKeyIDNotFound = errors.New("no api key with id")
func init() {
rootCmd.AddCommand(apiKeysCmd)
apiKeysCmd.AddCommand(listAPIKeys)
@@ -46,26 +44,36 @@ var listAPIKeys = &cobra.Command{
Use: cmdList,
Short: "List the Api keys for headscale",
Aliases: []string{"ls", cmdShow},
RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error {
resp, err := client.ListApiKeys(ctx)
RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error {
resp, err := client.ListApiKeysWithResponse(ctx)
if err != nil {
return fmt.Errorf("listing api keys: %w", err)
}
return printListOutput(cmd, resp.ApiKeys, func() error {
rows := make([][]string, 0, len(resp.ApiKeys))
for _, key := range resp.ApiKeys {
expiration := "-"
if resp.StatusCode() != http.StatusOK {
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
}
if key.Expiration.Set {
expiration = ColourTime(key.Expiration.Value)
apiKeys := resp.JSON200.ApiKeys
return printListOutput(cmd, apiKeys, func() error {
rows := make([][]string, 0, len(apiKeys))
for _, key := range apiKeys {
expiration := "-"
if key.Expiration != nil {
expiration = ColourTime(*key.Expiration)
}
var created string
if key.CreatedAt != nil {
created = key.CreatedAt.Format(HeadscaleDateTimeFormat)
}
rows = append(rows, []string{
strconv.FormatUint(key.ID.Value, util.Base10),
key.Prefix.Value,
key.Id,
key.Prefix,
expiration,
key.CreatedAt.Value.Format(HeadscaleDateTimeFormat),
created,
})
}
@@ -82,20 +90,24 @@ Creates a new Api key, the Api key is only visible on creation
and cannot be retrieved again.
If you lose a key, create a new one and revoke (expire) the old one.`,
Aliases: []string{"c", cmdNew},
RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error {
expiration, err := expirationFromFlag(cmd)
RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error {
expiryTime, err := expirationFromFlag(cmd)
if err != nil {
return err
}
resp, err := client.CreateApiKey(ctx, &apiv1.CreateApiKeyReq{
Expiration: expiration,
resp, err := client.CreateApiKeyWithResponse(ctx, clientv1.CreateApiKeyJSONRequestBody{
Expiration: &expiryTime,
})
if err != nil {
return fmt.Errorf("creating api key: %w", err)
}
return printOutput(cmd, resp.ApiKey.Value, resp.ApiKey.Value)
if resp.StatusCode() != http.StatusOK {
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
}
return printOutput(cmd, resp.JSON200.ApiKey, resp.JSON200.ApiKey)
}),
}
@@ -119,21 +131,33 @@ var expireAPIKeyCmd = &cobra.Command{
Use: cmdExpire,
Short: "Expire an ApiKey",
Aliases: []string{"revoke", aliasExp, "e"},
RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error {
RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error {
id, prefix, err := apiKeyIDOrPrefix(cmd)
if err != nil {
return err
}
err = client.ExpireApiKey(ctx, &apiv1.ExpireApiKeyReq{
ID: optUint64(id),
Prefix: optString(prefix),
})
body := clientv1.ExpireApiKeyJSONRequestBody{}
if id != 0 {
idStr := strconv.FormatUint(id, util.Base10)
body.Id = &idStr
}
if prefix != "" {
body.Prefix = &prefix
}
resp, err := client.ExpireApiKeyWithResponse(ctx, body)
if err != nil {
return fmt.Errorf("expiring api key: %w", err)
}
return printOutput(cmd, map[string]string{colResult: "Key expired"}, "Key expired")
if resp.StatusCode() != http.StatusOK {
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
}
return printOutput(cmd, resp.JSON200, "Key expired")
}),
}
@@ -141,42 +165,59 @@ var deleteAPIKeyCmd = &cobra.Command{
Use: cmdDelete,
Short: "Delete an ApiKey",
Aliases: []string{"remove", aliasDel},
RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error {
RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error {
id, prefix, err := apiKeyIDOrPrefix(cmd)
if err != nil {
return err
}
// Delete is routed by prefix in the path, so resolve an --id to its
// prefix first; an empty path segment would 404 at the router.
// The DELETE route addresses the key by its prefix in the path. When the
// user deletes by --id we resolve the id to its (masked) prefix first,
// since the path segment is required and a query-only id cannot be routed.
if prefix == "" {
prefix, err = apiKeyPrefixByID(ctx, client, id)
prefix, err = apiKeyPrefixForID(ctx, client, id)
if err != nil {
return err
}
}
err = client.DeleteApiKey(ctx, apiv1.DeleteApiKeyParams{Prefix: prefix})
resp, err := client.DeleteApiKeyWithResponse(ctx, prefix, &clientv1.DeleteApiKeyParams{})
if err != nil {
return fmt.Errorf("deleting api key: %w", err)
}
return printOutput(cmd, map[string]string{colResult: "Key deleted"}, "Key deleted")
if resp.StatusCode() != http.StatusOK {
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
}
return printOutput(cmd, resp.JSON200, "Key deleted")
}),
}
// apiKeyPrefixByID looks up an API key's prefix by its numeric ID.
func apiKeyPrefixByID(ctx context.Context, client *apiv1.Client, id uint64) (string, error) {
resp, err := client.ListApiKeys(ctx)
// apiKeyPrefixForID resolves an API key id to its display prefix by listing the
// keys. The DELETE endpoint addresses keys by prefix in the URL path, so a
// delete by --id needs the prefix; the returned masked prefix is accepted by
// the server's lookup.
func apiKeyPrefixForID(
ctx context.Context,
client *clientv1.ClientWithResponses,
id uint64,
) (string, error) {
resp, err := client.ListApiKeysWithResponse(ctx)
if err != nil {
return "", fmt.Errorf("listing api keys: %w", err)
}
for _, key := range resp.GetApiKeys() {
if key.GetID().Or(0) == id {
return key.GetPrefix().Or(""), nil
if resp.StatusCode() != http.StatusOK {
return "", apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
}
idStr := strconv.FormatUint(id, util.Base10)
for _, key := range resp.JSON200.ApiKeys {
if key.Id == idStr {
return key.Prefix, nil
}
}
return "", fmt.Errorf("%w: %d", errAPIKeyIDNotFound, id)
return "", fmt.Errorf("%w: api key %d not found", errMissingParameter, id)
}
+44 -36
View File
@@ -3,8 +3,9 @@ package cli
import (
"context"
"fmt"
"net/http"
apiv1 "github.com/juanfont/headscale/gen/api/v1"
clientv1 "github.com/juanfont/headscale/gen/client/v1"
"github.com/spf13/cobra"
)
@@ -33,59 +34,66 @@ var authCmd = &cobra.Command{
var authRegisterCmd = &cobra.Command{
Use: "register",
Short: "Register a node to your network",
RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error {
RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error {
user, _ := cmd.Flags().GetString("user")
authID, _ := cmd.Flags().GetString("auth-id")
resp, err := client.AuthRegister(ctx, &apiv1.AuthRegisterReq{
AuthId: apiv1.NewOptString(authID),
User: apiv1.NewOptString(user),
resp, err := client.AuthRegisterWithResponse(ctx, clientv1.AuthRegisterJSONRequestBody{
AuthId: &authID,
User: &user,
})
if err != nil {
return fmt.Errorf("registering node: %w", err)
}
return printOutput(
cmd,
resp.Node.Value,
fmt.Sprintf("Node %s registered", resp.Node.Value.GivenName.Value),
)
}),
}
// authDecisionRunE builds a RunE for an auth decision command (approve or
// reject) that reads the auth-id flag, invokes the given API call, and prints a
// result. errVerb is used in the error message; okMsg is printed on success.
func authDecisionRunE(
errVerb, okMsg string,
call func(ctx context.Context, client *apiv1.Client, authID string) error,
) func(*cobra.Command, []string) error {
return apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error {
authID, _ := cmd.Flags().GetString("auth-id")
err := call(ctx, client, authID)
if err != nil {
return fmt.Errorf("%s auth request: %w", errVerb, err)
if resp.StatusCode() != http.StatusOK {
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
}
return printOutput(cmd, map[string]string{colResult: okMsg}, okMsg)
})
node := resp.JSON200.Node
return printOutput(
cmd,
node,
fmt.Sprintf("Node %s registered", node.GivenName),
)
}),
}
var authApproveCmd = &cobra.Command{
Use: "approve",
Short: "Approve a pending authentication request",
RunE: authDecisionRunE("approving", "Auth request approved",
func(ctx context.Context, client *apiv1.Client, authID string) error {
return client.AuthApprove(ctx, &apiv1.AuthApproveReq{AuthId: apiv1.NewOptString(authID)})
}),
RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error {
authID, _ := cmd.Flags().GetString("auth-id")
resp, err := client.AuthApproveWithResponse(ctx, clientv1.AuthApproveJSONRequestBody{AuthId: &authID})
if err != nil {
return fmt.Errorf("approving auth request: %w", err)
}
if resp.StatusCode() != http.StatusOK {
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
}
return printOutput(cmd, resp.JSON200, "Auth request approved")
}),
}
var authRejectCmd = &cobra.Command{
Use: "reject",
Short: "Reject a pending authentication request",
RunE: authDecisionRunE("rejecting", "Auth request rejected",
func(ctx context.Context, client *apiv1.Client, authID string) error {
return client.AuthReject(ctx, &apiv1.AuthRejectReq{AuthId: apiv1.NewOptString(authID)})
}),
RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error {
authID, _ := cmd.Flags().GetString("auth-id")
resp, err := client.AuthRejectWithResponse(ctx, clientv1.AuthRejectJSONRequestBody{AuthId: &authID})
if err != nil {
return fmt.Errorf("rejecting auth request: %w", err)
}
if resp.StatusCode() != http.StatusOK {
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
}
return printOutput(cmd, resp.JSON200, "Auth request rejected")
}),
}
-212
View File
@@ -1,212 +0,0 @@
package cli
import (
"context"
"crypto/tls"
"fmt"
"net"
"net/http"
"os"
"strings"
"time"
apiv1 "github.com/juanfont/headscale/gen/api/v1"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/prometheus/common/model"
"github.com/spf13/cobra"
)
// apiRunE wraps a cobra [cobra.Command.RunE] func, injecting a ready v1 API
// client and context. Connection lifecycle is managed by the wrapper.
func apiRunE(
fn func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error,
) func(*cobra.Command, []string) error {
return func(cmd *cobra.Command, args []string) error {
ctx, client, cancel, err := newHeadscaleAPIClient()
if err != nil {
return fmt.Errorf("connecting to headscale: %w", err)
}
defer cancel()
return fn(ctx, client, cmd, args)
}
}
// withAPI opens a v1 API client, runs fn with it, and cancels the context
// afterwards. It is the building block for commands that branch on a flag
// before deciding to talk to the server.
func withAPI(fn func(ctx context.Context, client *apiv1.Client) error) error {
ctx, client, cancel, err := newHeadscaleAPIClient()
if err != nil {
return fmt.Errorf("connecting to headscale: %w", err)
}
defer cancel()
return fn(ctx, client)
}
// newHeadscaleAPIClient builds a v1 HTTP API client. With no configured
// address it talks to the local unix socket (filesystem permissions are the
// trust boundary, no API key needed); otherwise it uses HTTPS with the API key.
func newHeadscaleAPIClient() (context.Context, *apiv1.Client, context.CancelFunc, error) {
cfg, err := types.LoadCLIConfig()
if err != nil {
return nil, nil, nil, fmt.Errorf("loading configuration: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), cfg.CLI.Timeout)
if cfg.CLI.Address == "" {
client, err := localSocketClient(cfg.UnixSocket)
if err != nil {
cancel()
return nil, nil, nil, err
}
return ctx, client, cancel, nil
}
client, err := remoteClient(cfg.CLI.Address, cfg.CLI.APIKey, cfg.CLI.Insecure)
if err != nil {
cancel()
return nil, nil, nil, err
}
return ctx, client, cancel, nil
}
func localSocketClient(socketPath string) (*apiv1.Client, error) {
err := checkSocketPermissions(socketPath)
if err != nil {
return nil, err
}
httpClient := &http.Client{
Transport: &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return dialSocketWaiting(ctx, socketPath)
},
},
}
// The socket bypasses bearer auth; the token is a placeholder.
return apiv1.NewClient("http://unix", cliToken("local-socket"), apiv1.WithClient(httpClient))
}
// dialSocketWaiting connects to the local unix socket, retrying while it is
// still absent. headscale removes and rebinds the socket during startup, so a
// CLI command issued right after "systemctl start" can beat the server to it.
// This mirrors the old blocking dial, which retried until the CLI timeout.
func dialSocketWaiting(ctx context.Context, socketPath string) (net.Conn, error) {
var dialer net.Dialer
for {
conn, err := dialer.DialContext(ctx, "unix", socketPath)
if err == nil {
return conn, nil
}
if ctx.Err() != nil {
return nil, err
}
select {
case <-ctx.Done():
return nil, err
case <-time.After(100 * time.Millisecond):
}
}
}
func remoteClient(address, apiKey string, insecure bool) (*apiv1.Client, error) {
if apiKey == "" {
return nil, errAPIKeyNotSet
}
transport := &http.Transport{}
if insecure {
//nolint:gosec // G402: insecure is an explicit, documented opt-in.
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
}
return apiv1.NewClient(
serverURLFromAddress(address),
cliToken(apiKey),
apiv1.WithClient(&http.Client{Transport: transport}),
)
}
// serverURLFromAddress turns a configured CLI address into a base URL,
// defaulting to https when no scheme is given.
func serverURLFromAddress(address string) string {
if strings.Contains(address, "://") {
return address
}
return "https://" + address
}
// checkSocketPermissions gives a friendlier error than a dial failure when the
// user cannot access the headscale socket.
func checkSocketPermissions(socketPath string) error {
socket, err := os.OpenFile(socketPath, os.O_WRONLY, SocketWritePermissions) //nolint
if err != nil {
if os.IsPermission(err) {
return fmt.Errorf(
"unable to read/write to headscale socket %q, do you have the correct permissions? %w",
socketPath, err,
)
}
// ENXIO and similar are expected for a socket opened with O_WRONLY; the
// real connection uses net.Dial which handles sockets properly.
return nil
}
socket.Close()
return nil
}
// optString / optUint64 / optTime build optional API request values from flag
// inputs, treating zero values as "unset".
func optString(s string) apiv1.OptString {
if s == "" {
return apiv1.OptString{}
}
return apiv1.NewOptString(s)
}
func optUint64(v uint64) apiv1.OptUint64 {
if v == 0 {
return apiv1.OptUint64{}
}
return apiv1.NewOptUint64(v)
}
// expirationFromFlag parses the --expiration flag as a Prometheus-style
// duration (e.g. "90d", "1h") and returns it as an absolute optional timestamp.
// An empty flag yields an unset value.
func expirationFromFlag(cmd *cobra.Command) (apiv1.OptDateTime, error) {
durationStr, _ := cmd.Flags().GetString("expiration")
if durationStr == "" {
return apiv1.OptDateTime{}, nil
}
duration, err := model.ParseDuration(durationStr)
if err != nil {
return apiv1.OptDateTime{}, fmt.Errorf("parsing duration: %w", err)
}
return apiv1.NewOptDateTime(time.Now().UTC().Add(time.Duration(duration))), nil
}
// cliToken is an [apiv1.SecuritySource] that supplies a fixed bearer token.
type cliToken string
func (t cliToken) BearerAuth(context.Context, apiv1.OperationName) (apiv1.BearerAuth, error) {
return apiv1.BearerAuth{Token: string(t)}, nil
}
+13 -8
View File
@@ -3,8 +3,9 @@ package cli
import (
"context"
"fmt"
"net/http"
apiv1 "github.com/juanfont/headscale/gen/api/v1"
clientv1 "github.com/juanfont/headscale/gen/client/v1"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/spf13/cobra"
)
@@ -32,7 +33,7 @@ var debugCmd = &cobra.Command{
var createNodeCmd = &cobra.Command{
Use: "create-node",
Short: "Create a node that can be registered with `auth register <>` command",
RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error {
RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error {
user, _ := cmd.Flags().GetString("user")
name, _ := cmd.Flags().GetString("name")
registrationID, _ := cmd.Flags().GetString("key")
@@ -44,16 +45,20 @@ var createNodeCmd = &cobra.Command{
routes, _ := cmd.Flags().GetStringSlice("route")
resp, err := client.DebugCreateNode(ctx, &apiv1.DebugCreateNodeReq{
Key: apiv1.NewOptString(registrationID),
Name: apiv1.NewOptString(name),
User: apiv1.NewOptString(user),
Routes: routes,
resp, err := client.DebugCreateNodeWithResponse(ctx, clientv1.DebugCreateNodeJSONRequestBody{
Key: &registrationID,
Name: &name,
User: &user,
Routes: &routes,
})
if err != nil {
return fmt.Errorf("creating node: %w", err)
}
return printOutput(cmd, resp.Node.Value, "Node created")
if resp.StatusCode() != http.StatusOK {
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
}
return printOutput(cmd, resp.JSON200.Node, "Node created")
}),
}
+9 -4
View File
@@ -3,8 +3,9 @@ package cli
import (
"context"
"fmt"
"net/http"
apiv1 "github.com/juanfont/headscale/gen/api/v1"
clientv1 "github.com/juanfont/headscale/gen/client/v1"
"github.com/spf13/cobra"
)
@@ -16,12 +17,16 @@ var healthCmd = &cobra.Command{
Use: "health",
Short: "Check the health of the Headscale server",
Long: "Check the health of the Headscale server. This command will return an exit code of 0 if the server is healthy, or 1 if it is not.",
RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error {
resp, err := client.Health(ctx)
RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error {
resp, err := client.HealthWithResponse(ctx)
if err != nil {
return fmt.Errorf("checking health: %w", err)
}
return printOutput(cmd, resp, "")
if resp.StatusCode() != http.StatusOK {
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
}
return printOutput(cmd, resp.JSON200, "")
}),
}
+154 -88
View File
@@ -3,12 +3,13 @@ package cli
import (
"context"
"fmt"
"net/http"
"net/netip"
"strconv"
"strings"
"time"
apiv1 "github.com/juanfont/headscale/gen/api/v1"
clientv1 "github.com/juanfont/headscale/gen/client/v1"
"github.com/juanfont/headscale/hscontrol/util"
"github.com/pterm/pterm"
"github.com/samber/lo"
@@ -66,22 +67,30 @@ var registerNodeCmd = &cobra.Command{
Use: "register",
Short: "Registers a node to your network",
Deprecated: "use 'headscale auth register --auth-id <id> --user <user>' instead",
RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error {
RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error {
user, _ := cmd.Flags().GetString("user")
registrationID, _ := cmd.Flags().GetString("key")
resp, err := client.RegisterNode(ctx, apiv1.RegisterNodeParams{
Key: optString(registrationID),
User: optString(user),
})
params := &clientv1.RegisterNodeParams{
User: &user,
Key: &registrationID,
}
resp, err := client.RegisterNodeWithResponse(ctx, params)
if err != nil {
return fmt.Errorf("registering node: %w", err)
}
if resp.StatusCode() != http.StatusOK {
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
}
node := resp.JSON200.Node
return printOutput(
cmd,
resp.Node.Value,
fmt.Sprintf("Node %s registered", resp.Node.Value.GivenName.Value),
node,
fmt.Sprintf("Node %s registered", node.GivenName),
)
}),
}
@@ -90,16 +99,27 @@ var listNodesCmd = &cobra.Command{
Use: cmdList,
Short: "List nodes",
Aliases: []string{"ls", cmdShow},
RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error {
RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error {
user, _ := cmd.Flags().GetString("user")
resp, err := client.ListNodes(ctx, apiv1.ListNodesParams{User: optString(user)})
params := &clientv1.ListNodesParams{}
if user != "" {
params.User = &user
}
resp, err := client.ListNodesWithResponse(ctx, params)
if err != nil {
return fmt.Errorf("listing nodes: %w", err)
}
return printListOutput(cmd, resp.Nodes, func() error {
tableData, err := nodesToPtables(resp.Nodes)
if resp.StatusCode() != http.StatusOK {
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
}
nodes := resp.JSON200.Nodes
return printListOutput(cmd, nodes, func() error {
tableData, err := nodesToPtables(nodes)
if err != nil {
return fmt.Errorf("converting to table: %w", err)
}
@@ -113,26 +133,32 @@ var listNodeRoutesCmd = &cobra.Command{
Use: "list-routes",
Short: "List routes available on nodes",
Aliases: []string{"lsr", "routes"},
RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error {
RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error {
identifier, _ := cmd.Flags().GetUint64("identifier")
resp, err := client.ListNodes(ctx, apiv1.ListNodesParams{})
resp, err := client.ListNodesWithResponse(ctx, &clientv1.ListNodesParams{})
if err != nil {
return fmt.Errorf("listing nodes: %w", err)
}
nodes := resp.Nodes
if resp.StatusCode() != http.StatusOK {
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
}
nodes := resp.JSON200.Nodes
if identifier != 0 {
for _, node := range resp.Nodes {
if node.ID.Value == identifier {
nodes = []apiv1.Node{node}
idStr := strconv.FormatUint(identifier, util.Base10)
for _, node := range nodes {
if node.Id == idStr {
nodes = []clientv1.Node{node}
break
}
}
}
nodes = lo.Filter(nodes, func(n apiv1.Node, _ int) bool {
nodes = lo.Filter(nodes, func(n clientv1.Node, _ int) bool {
return len(n.SubnetRoutes) > 0 || len(n.ApprovedRoutes) > 0 || len(n.AvailableRoutes) > 0
})
@@ -149,21 +175,27 @@ var expireNodeCmd = &cobra.Command{
Use --disable to disable key expiry (node will never expire).`,
Aliases: []string{"logout", aliasExp, "e"},
RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error {
RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error {
identifier, _ := cmd.Flags().GetUint64("identifier")
disableExpiry, _ := cmd.Flags().GetBool("disable")
nodeID := strconv.FormatUint(identifier, util.Base10)
// Handle disable expiry - node will never expire.
if disableExpiry {
resp, err := client.ExpireNode(ctx, apiv1.ExpireNodeParams{
NodeID: identifier,
DisableExpiry: apiv1.NewOptBool(true),
disable := true
resp, err := client.ExpireNodeWithResponse(ctx, nodeID, clientv1.ExpireNodeJSONRequestBody{
DisableExpiry: &disable,
})
if err != nil {
return fmt.Errorf("disabling node expiry: %w", err)
}
return printOutput(cmd, resp.Node.Value, "Node expiry disabled")
if resp.StatusCode() != http.StatusOK {
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
}
return printOutput(cmd, resp.JSON200.Node, "Node expiry disabled")
}
expiry, _ := cmd.Flags().GetString("expiry")
@@ -181,26 +213,31 @@ Use --disable to disable key expiry (node will never expire).`,
}
}
resp, err := client.ExpireNode(ctx, apiv1.ExpireNodeParams{
NodeID: identifier,
Expiry: apiv1.NewOptDateTime(expiryTime),
resp, err := client.ExpireNodeWithResponse(ctx, nodeID, clientv1.ExpireNodeJSONRequestBody{
Expiry: &expiryTime,
})
if err != nil {
return fmt.Errorf("expiring node: %w", err)
}
if now.Equal(expiryTime) || now.After(expiryTime) {
return printOutput(cmd, resp.Node.Value, "Node expired")
if resp.StatusCode() != http.StatusOK {
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
}
return printOutput(cmd, resp.Node.Value, "Node expiration updated")
node := resp.JSON200.Node
if now.Equal(expiryTime) || now.After(expiryTime) {
return printOutput(cmd, node, "Node expired")
}
return printOutput(cmd, node, "Node expiration updated")
}),
}
var renameNodeCmd = &cobra.Command{
Use: "rename NEW_NAME",
Short: "Renames a node in your network",
RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error {
RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error {
identifier, _ := cmd.Flags().GetUint64("identifier")
newName := ""
@@ -208,15 +245,16 @@ var renameNodeCmd = &cobra.Command{
newName = args[0]
}
resp, err := client.RenameNode(ctx, apiv1.RenameNodeParams{
NodeID: identifier,
NewName: newName,
})
resp, err := client.RenameNodeWithResponse(ctx, strconv.FormatUint(identifier, util.Base10), newName)
if err != nil {
return fmt.Errorf("renaming node: %w", err)
}
return printOutput(cmd, resp.Node.Value, "Node renamed")
if resp.StatusCode() != http.StatusOK {
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
}
return printOutput(cmd, resp.JSON200.Node, "Node renamed")
}),
}
@@ -224,26 +262,35 @@ var deleteNodeCmd = &cobra.Command{
Use: cmdDelete,
Short: "Delete a node",
Aliases: []string{aliasDel},
RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error {
RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error {
identifier, _ := cmd.Flags().GetUint64("identifier")
nodeID := strconv.FormatUint(identifier, util.Base10)
getResp, err := client.GetNode(ctx, apiv1.GetNodeParams{NodeID: identifier})
getResponse, err := client.GetNodeWithResponse(ctx, nodeID)
if err != nil {
return fmt.Errorf("getting node: %w", err)
}
if getResponse.StatusCode() != http.StatusOK {
return apiError(getResponse.StatusCode(), getResponse.ApplicationproblemJSONDefault)
}
if !confirmAction(cmd, fmt.Sprintf(
"Do you want to remove the node %s?",
getResp.Node.Value.Name.Value,
getResponse.JSON200.Node.Name,
)) {
return printOutput(cmd, map[string]string{colResult: "Node not deleted"}, "Node not deleted")
}
err = client.DeleteNode(ctx, apiv1.DeleteNodeParams{NodeID: identifier})
deleteResponse, err := client.DeleteNodeWithResponse(ctx, nodeID)
if err != nil {
return fmt.Errorf("deleting node: %w", err)
}
if deleteResponse.StatusCode() != http.StatusOK {
return apiError(deleteResponse.StatusCode(), deleteResponse.ApplicationproblemJSONDefault)
}
return printOutput(
cmd,
map[string]string{colResult: "Node deleted"},
@@ -267,23 +314,31 @@ all nodes that are missing.
If you remove IPv4 or IPv6 prefixes from the config,
it can be run to remove the IPs that should no longer
be assigned to nodes.`,
RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error {
RunE: func(cmd *cobra.Command, args []string) error {
if !confirmAction(cmd, "Are you sure that you want to assign/remove IPs to/from nodes?") {
return nil
}
resp, err := client.BackfillNodeIPs(ctx, apiv1.BackfillNodeIPsParams{
Confirmed: apiv1.NewOptBool(true),
})
if err != nil {
return fmt.Errorf("backfilling IPs: %w", err)
}
return withClient(func(ctx context.Context, client *clientv1.ClientWithResponses) error {
confirmed := true
return printOutput(cmd, resp.Changes, "Node IPs backfilled successfully")
}),
resp, err := client.BackfillNodeIPsWithResponse(ctx, &clientv1.BackfillNodeIPsParams{
Confirmed: &confirmed,
})
if err != nil {
return fmt.Errorf("backfilling IPs: %w", err)
}
if resp.StatusCode() != http.StatusOK {
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
}
return printOutput(cmd, resp.JSON200, "Node IPs backfilled successfully")
})
},
}
func nodesToPtables(nodes []apiv1.Node) (pterm.TableData, error) {
func nodesToPtables(nodes []clientv1.Node) (pterm.TableData, error) {
tableHeader := []string{
"ID",
"Hostname",
@@ -303,49 +358,46 @@ func nodesToPtables(nodes []apiv1.Node) (pterm.TableData, error) {
tableData[0] = tableHeader
for _, node := range nodes {
ephemeral := node.PreAuthKey.Set && node.PreAuthKey.Value.Ephemeral.Value
// An absent pre-auth key decodes into a zero NodePreAuthKey, so guard
// on Id before reading its flags.
ephemeral := node.PreAuthKey.Id != "" && node.PreAuthKey.Ephemeral
var lastSeenTime string
if node.LastSeen.Set {
lastSeenTime = node.LastSeen.Value.Format(HeadscaleDateTimeFormat)
if node.LastSeen != nil {
lastSeenTime = node.LastSeen.Format(HeadscaleDateTimeFormat)
}
expiryTime := "N/A"
if node.Expiry.Set {
expiryTime = node.Expiry.Value.Format(HeadscaleDateTimeFormat)
if node.Expiry != nil {
expiryTime = node.Expiry.Format(HeadscaleDateTimeFormat)
}
var machineKey key.MachinePublic
err := machineKey.UnmarshalText([]byte(node.MachineKey.Value))
err := machineKey.UnmarshalText([]byte(node.MachineKey))
if err != nil {
machineKey = key.MachinePublic{}
}
var nodeKey key.NodePublic
err = nodeKey.UnmarshalText([]byte(node.NodeKey.Value))
err = nodeKey.UnmarshalText([]byte(node.NodeKey))
if err != nil {
return nil, err
}
online := pterm.LightRed("offline")
if node.Online.Value {
if node.Online {
online = pterm.LightGreen("online")
}
expired := pterm.LightGreen("no")
if node.Expiry.Set && node.Expiry.Value.Before(time.Now()) {
if node.Expiry != nil && node.Expiry.Before(time.Now()) {
expired = pterm.LightRed("yes")
}
tags := strings.Join(node.Tags, "\n")
var user string
if node.User.Set {
user = node.User.Value.Name.Value
}
var ipBuilder strings.Builder
for _, addr := range node.IpAddresses {
@@ -359,28 +411,35 @@ func nodesToPtables(nodes []apiv1.Node) (pterm.TableData, error) {
}
}
ipAddresses := ipBuilder.String()
nodeData := []string{
strconv.FormatUint(node.ID.Value, util.Base10),
node.Name.Value,
node.GivenName.Value,
node.Id,
node.Name,
node.GivenName,
machineKey.ShortString(),
nodeKey.ShortString(),
user,
node.User.Name,
tags,
ipBuilder.String(),
ipAddresses,
strconv.FormatBool(ephemeral),
lastSeenTime,
expiryTime,
online,
expired,
}
tableData = append(tableData, nodeData)
tableData = append(
tableData,
nodeData,
)
}
return tableData, nil
}
func nodeRoutesToPtables(nodes []apiv1.Node) pterm.TableData {
func nodeRoutesToPtables(
nodes []clientv1.Node,
) pterm.TableData {
tableHeader := []string{
"ID",
"Hostname",
@@ -393,13 +452,16 @@ func nodeRoutesToPtables(nodes []apiv1.Node) pterm.TableData {
for _, node := range nodes {
nodeData := []string{
strconv.FormatUint(node.ID.Value, util.Base10),
node.GivenName.Value,
node.Id,
node.GivenName,
strings.Join(node.ApprovedRoutes, "\n"),
strings.Join(node.AvailableRoutes, "\n"),
strings.Join(node.SubnetRoutes, "\n"),
}
tableData = append(tableData, nodeData)
tableData = append(
tableData,
nodeData,
)
}
return tableData
@@ -409,39 +471,43 @@ var tagCmd = &cobra.Command{
Use: "tag",
Short: "Manage the tags of a node",
Aliases: []string{"tags", "t"},
RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error {
RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error {
identifier, _ := cmd.Flags().GetUint64("identifier")
tagsToSet, _ := cmd.Flags().GetStringSlice("tags")
resp, err := client.SetTags(
ctx,
&apiv1.SetTagsReq{Tags: tagsToSet},
apiv1.SetTagsParams{NodeID: identifier},
)
resp, err := client.SetTagsWithResponse(ctx, strconv.FormatUint(identifier, util.Base10), clientv1.SetTagsJSONRequestBody{
Tags: &tagsToSet,
})
if err != nil {
return fmt.Errorf("setting tags: %w", err)
}
return printOutput(cmd, resp.Node.Value, "Node updated")
if resp.StatusCode() != http.StatusOK {
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
}
return printOutput(cmd, resp.JSON200.Node, "Node updated")
}),
}
var approveRoutesCmd = &cobra.Command{
Use: "approve-routes",
Short: "Manage the approved routes of a node",
RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error {
RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error {
identifier, _ := cmd.Flags().GetUint64("identifier")
routes, _ := cmd.Flags().GetStringSlice("routes")
resp, err := client.SetApprovedRoutes(
ctx,
&apiv1.SetApprovedRoutesReq{Routes: routes},
apiv1.SetApprovedRoutesParams{NodeID: identifier},
)
resp, err := client.SetApprovedRoutesWithResponse(ctx, strconv.FormatUint(identifier, util.Base10), clientv1.SetApprovedRoutesJSONRequestBody{
Routes: &routes,
})
if err != nil {
return fmt.Errorf("setting approved routes: %w", err)
}
return printOutput(cmd, resp.Node.Value, "Node updated")
if resp.StatusCode() != http.StatusOK {
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
}
return printOutput(cmd, resp.JSON200.Node, "Node updated")
}),
}
+271
View File
@@ -0,0 +1,271 @@
package cli
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"net"
"net/http"
"strings"
clientv2 "github.com/juanfont/headscale/gen/client/v2"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/spf13/cobra"
)
// oauthTailnet is the single Headscale tailnet the v2 API addresses as "-".
const oauthTailnet = "-"
func init() {
rootCmd.AddCommand(oauthClientsCmd)
oauthClientsCmd.AddCommand(listOAuthClientsCmd)
createOAuthClientCmd.Flags().
StringArrayP("scope", "s", nil, "Scope the client's tokens are granted (repeatable): auth_keys, oauth_keys, devices:core, devices:routes, policy_file, feature_settings (each with a :read variant), or all/all:read")
createOAuthClientCmd.Flags().
StringArrayP("tag", "t", nil, "Tag the client's tokens may assign to devices (repeatable), e.g. tag:k8s-operator")
createOAuthClientCmd.Flags().StringP("description", "d", "", "Human-readable description")
oauthClientsCmd.AddCommand(createOAuthClientCmd)
deleteOAuthClientCmd.Flags().StringP("id", "i", "", "OAuth client id")
oauthClientsCmd.AddCommand(deleteOAuthClientCmd)
}
var oauthClientsCmd = &cobra.Command{
Use: "oauth-clients",
Short: "Manage OAuth clients",
Aliases: []string{"oauth-client", "oauthclients", "oauthclient", "oauth"},
}
var createOAuthClientCmd = &cobra.Command{
Use: "create",
Short: "Create an OAuth client",
Long: `Create a general-purpose OAuth client. It authenticates with the OAuth 2.0
client-credentials grant and mints short-lived, scope-limited access tokens.
The wire format is compatible with Tailscale tooling (the Terraform provider,
the Kubernetes operator, tscli, ...), so those can drive Headscale unchanged.
The client secret is shown ONCE on creation and cannot be retrieved again; if
you lose it, delete the client and create a new one.
Scopes gate what the client's tokens may do; tags are the device tags those
tokens may assign and are required when the scopes include devices:core or
auth_keys.`,
Aliases: []string{"c", cmdNew},
RunE: func(cmd *cobra.Command, _ []string) error {
scopes, _ := cmd.Flags().GetStringArray("scope")
tags, _ := cmd.Flags().GetStringArray("tag")
description, _ := cmd.Flags().GetString("description")
if len(scopes) == 0 {
return fmt.Errorf("at least one --scope is required: %w", errMissingParameter)
}
ctx, client, cancel, err := newV2Client()
if err != nil {
return err
}
defer cancel()
keyType := "client"
resp, err := client.CreateKeyWithResponse(ctx, oauthTailnet, clientv2.CreateKeyRequest{
KeyType: &keyType,
Scopes: &scopes,
Tags: &tags,
Description: &description,
})
if err != nil {
return fmt.Errorf("creating oauth client: %w", err)
}
err = v2Error(resp.HTTPResponse.StatusCode, resp.Body)
if err != nil {
return err
}
key := resp.JSON200
return printOutput(cmd, key,
fmt.Sprintf("OAuth client %s created.\nSecret (shown once, store it now): %s", key.Id, ptrStr(key.Key)))
},
}
var listOAuthClientsCmd = &cobra.Command{
Use: cmdList,
Short: "List OAuth clients",
Aliases: []string{"ls", cmdShow},
RunE: func(cmd *cobra.Command, _ []string) error {
ctx, client, cancel, err := newV2Client()
if err != nil {
return err
}
defer cancel()
resp, err := client.ListKeysWithResponse(ctx, oauthTailnet, nil)
if err != nil {
return fmt.Errorf("listing oauth clients: %w", err)
}
err = v2Error(resp.HTTPResponse.StatusCode, resp.Body)
if err != nil {
return err
}
// The keys endpoint is multiplexed; keep only OAuth clients.
clients := make([]clientv2.Key, 0, len(resp.JSON200.Keys))
for _, k := range resp.JSON200.Keys {
if k.KeyType == "client" {
clients = append(clients, k)
}
}
return printListOutput(cmd, clients, func() error {
rows := make([][]string, 0, len(clients))
for _, c := range clients {
rows = append(rows, []string{
c.Id,
strings.Join(ptrStrs(c.Scopes), ","),
strings.Join(ptrStrs(c.Tags), ","),
ptrStr(c.Description),
c.Created.Format(HeadscaleDateTimeFormat),
})
}
return renderTable([]string{"ID", "Scopes", "Tags", "Description", colCreated}, rows)
})
},
}
var deleteOAuthClientCmd = &cobra.Command{
Use: cmdDelete,
Short: "Delete an OAuth client",
Aliases: []string{"remove", aliasDel},
RunE: func(cmd *cobra.Command, _ []string) error {
id, _ := cmd.Flags().GetString("id")
if id == "" {
return fmt.Errorf("--id is required: %w", errMissingParameter)
}
ctx, client, cancel, err := newV2Client()
if err != nil {
return err
}
defer cancel()
resp, err := client.DeleteKeyWithResponse(ctx, oauthTailnet, id)
if err != nil {
return fmt.Errorf("deleting oauth client: %w", err)
}
err = v2Error(resp.HTTPResponse.StatusCode, resp.Body)
if err != nil {
return err
}
return printOutput(cmd, map[string]string{"id": id}, "OAuth client "+id+" deleted")
},
}
// newV2Client builds a generated v2 API client, selecting the transport the same
// way the v1 client does: over the local unix socket it is unauthenticated
// (local trust); a remote address injects the configured API key as a bearer
// token.
func newV2Client() (context.Context, *clientv2.ClientWithResponses, context.CancelFunc, error) {
cfg, err := types.LoadCLIConfig()
if err != nil {
return nil, nil, nil, fmt.Errorf("loading configuration: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), cfg.CLI.Timeout)
if cfg.CLI.Address == "" {
socketPath := cfg.UnixSocket
httpClient := &http.Client{Transport: &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return dialHeadscaleSocket(ctx, socketPath)
},
}}
client, err := clientv2.NewClientWithResponses("http://local", clientv2.WithHTTPClient(httpClient))
if err != nil {
cancel()
return nil, nil, nil, err
}
return ctx, client, cancel, nil
}
if cfg.CLI.APIKey == "" {
cancel()
return nil, nil, nil, errAPIKeyNotSet
}
transport := &http.Transport{}
if cfg.CLI.Insecure {
//nolint:gosec // intentionally honouring the insecure flag
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
}
apiKey := cfg.CLI.APIKey
client, err := clientv2.NewClientWithResponses(
clientBaseURL(cfg.CLI.Address),
clientv2.WithHTTPClient(&http.Client{Transport: transport}),
clientv2.WithRequestEditorFn(func(_ context.Context, req *http.Request) error {
req.Header.Set("Authorization", "Bearer "+apiKey)
return nil
}),
)
if err != nil {
cancel()
return nil, nil, nil, err
}
return ctx, client, cancel, nil
}
// v2Error turns a non-2xx v2 response into an error. The v2 API emits the
// Tailscale error body ({"message":...}) rather than RFC 7807, so it reads the
// "message" field instead of the generated problem+json types.
func v2Error(status int, body []byte) error {
if status >= http.StatusOK && status < http.StatusMultipleChoices {
return nil
}
var e struct {
Message string `json:"message"`
}
if json.Unmarshal(body, &e) == nil && e.Message != "" {
//nolint:err113 // surfacing the server's message
return fmt.Errorf("api error (%d): %s", status, e.Message)
}
//nolint:err113 // surfacing the server's body
return fmt.Errorf("api error (%d): %s", status, strings.TrimSpace(string(body)))
}
func ptrStr(s *string) string {
if s == nil {
return ""
}
return *s
}
func ptrStrs(s *[]string) []string {
if s == nil {
return nil
}
return *s
}
+34 -13
View File
@@ -4,9 +4,10 @@ import (
"context"
"errors"
"fmt"
"net/http"
"os"
apiv1 "github.com/juanfont/headscale/gen/api/v1"
clientv1 "github.com/juanfont/headscale/gen/client/v1"
"github.com/juanfont/headscale/hscontrol/db"
"github.com/juanfont/headscale/hscontrol/policy"
"github.com/juanfont/headscale/hscontrol/types"
@@ -20,9 +21,8 @@ const (
var errAborted = errors.New("command aborted by user")
// bypassDatabase loads the server config and opens the database directly,
// bypassing the running server. The caller is responsible for closing the
// returned database handle.
// bypassDatabase opens the database directly, bypassing the running server.
// The caller must close the returned handle.
func bypassDatabase() (*db.HSDatabase, error) {
cfg, err := types.LoadServerConfig()
if err != nil {
@@ -90,13 +90,17 @@ var getPolicy = &cobra.Command{
policyData = pol.Data
} else {
err := withAPI(func(ctx context.Context, client *apiv1.Client) error {
resp, err := client.GetPolicy(ctx)
err := withClient(func(ctx context.Context, client *clientv1.ClientWithResponses) error {
resp, err := client.GetPolicyWithResponse(ctx)
if err != nil {
return fmt.Errorf("loading ACL policy: %w", err)
}
policyData = resp.Policy.Value
if resp.StatusCode() != http.StatusOK {
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
}
policyData = resp.JSON200.Policy
return nil
})
@@ -150,14 +154,20 @@ var setPolicy = &cobra.Command{
return fmt.Errorf("setting ACL policy: %w", err)
}
} else {
err := withAPI(func(ctx context.Context, client *apiv1.Client) error {
_, err := client.SetPolicy(ctx, &apiv1.SetPolicyReq{
Policy: apiv1.NewOptString(string(policyBytes)),
policyStr := string(policyBytes)
err := withClient(func(ctx context.Context, client *clientv1.ClientWithResponses) error {
resp, err := client.SetPolicyWithResponse(ctx, clientv1.SetPolicyJSONRequestBody{
Policy: &policyStr,
})
if err != nil {
return fmt.Errorf("setting ACL policy: %w", err)
}
if resp.StatusCode() != http.StatusOK {
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
}
return nil
})
if err != nil {
@@ -223,10 +233,21 @@ var checkPolicy = &cobra.Command{
return nil
}
err = withAPI(func(ctx context.Context, client *apiv1.Client) error {
return client.CheckPolicy(ctx, &apiv1.CheckPolicyReq{
Policy: apiv1.NewOptString(string(policyBytes)),
policyStr := string(policyBytes)
err = withClient(func(ctx context.Context, client *clientv1.ClientWithResponses) error {
resp, err := client.CheckPolicyWithResponse(ctx, clientv1.CheckPolicyJSONRequestBody{
Policy: &policyStr,
})
if err != nil {
return err
}
if resp.StatusCode() != http.StatusOK {
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
}
return nil
})
if err != nil {
return err
+65 -37
View File
@@ -3,10 +3,11 @@ package cli
import (
"context"
"fmt"
"net/http"
"strconv"
"strings"
apiv1 "github.com/juanfont/headscale/gen/api/v1"
clientv1 "github.com/juanfont/headscale/gen/client/v1"
"github.com/juanfont/headscale/hscontrol/util"
"github.com/spf13/cobra"
)
@@ -44,39 +45,40 @@ var listPreAuthKeys = &cobra.Command{
Use: cmdList,
Short: "List all preauthkeys",
Aliases: []string{"ls", cmdShow},
RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error {
resp, err := client.ListPreAuthKeys(ctx)
RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error {
resp, err := client.ListPreAuthKeysWithResponse(ctx)
if err != nil {
return fmt.Errorf("listing preauthkeys: %w", err)
}
return printListOutput(cmd, resp.PreAuthKeys, func() error {
rows := make([][]string, 0, len(resp.PreAuthKeys))
for _, key := range resp.PreAuthKeys {
expiration := "-"
if key.Expiration.Set {
expiration = ColourTime(key.Expiration.Value)
}
if resp.StatusCode() != http.StatusOK {
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
}
var owner string
preAuthKeys := resp.JSON200.PreAuthKeys
return printListOutput(cmd, preAuthKeys, func() error {
rows := make([][]string, 0, len(preAuthKeys))
for _, key := range preAuthKeys {
expiration := ColourTime(key.Expiration)
owner := "-"
switch {
case len(key.AclTags) > 0:
owner = strings.Join(key.AclTags, "\n")
case key.User.Set:
owner = key.User.Value.Name.Value
default:
owner = "-"
case key.User.Id != "":
owner = key.User.Name
}
rows = append(rows, []string{
strconv.FormatUint(key.ID.Value, util.Base10),
key.Key.Value,
strconv.FormatBool(key.Reusable.Value),
strconv.FormatBool(key.Ephemeral.Value),
strconv.FormatBool(key.Used.Value),
key.Id,
key.Key,
strconv.FormatBool(key.Reusable),
strconv.FormatBool(key.Ephemeral),
strconv.FormatBool(key.Used),
expiration,
key.CreatedAt.Value.Format(HeadscaleDateTimeFormat),
key.CreatedAt.Format(HeadscaleDateTimeFormat),
owner,
})
}
@@ -99,29 +101,39 @@ var createPreAuthKeyCmd = &cobra.Command{
Use: "create",
Short: "Creates a new preauthkey",
Aliases: []string{"c", cmdNew},
RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error {
RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error {
user, _ := cmd.Flags().GetUint64("user")
reusable, _ := cmd.Flags().GetBool("reusable")
ephemeral, _ := cmd.Flags().GetBool("ephemeral")
tags, _ := cmd.Flags().GetStringSlice("tags")
expiration, err := expirationFromFlag(cmd)
expiryTime, err := expirationFromFlag(cmd)
if err != nil {
return err
}
resp, err := client.CreatePreAuthKey(ctx, &apiv1.CreatePreAuthKeyReq{
User: optUint64(user),
Reusable: apiv1.NewOptBool(reusable),
Ephemeral: apiv1.NewOptBool(ephemeral),
AclTags: tags,
Expiration: expiration,
})
userStr := strconv.FormatUint(user, util.Base10)
request := clientv1.CreatePreAuthKeyJSONRequestBody{
User: &userStr,
Reusable: &reusable,
Ephemeral: &ephemeral,
AclTags: &tags,
Expiration: &expiryTime,
}
resp, err := client.CreatePreAuthKeyWithResponse(ctx, request)
if err != nil {
return fmt.Errorf("creating preauthkey: %w", err)
}
return printOutput(cmd, resp.PreAuthKey.Value, resp.PreAuthKey.Value.Key.Value)
if resp.StatusCode() != http.StatusOK {
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
}
preAuthKey := resp.JSON200.PreAuthKey
return printOutput(cmd, preAuthKey, preAuthKey.Key)
}),
}
@@ -139,18 +151,26 @@ var expirePreAuthKeyCmd = &cobra.Command{
Use: cmdExpire,
Short: "Expire a preauthkey",
Aliases: []string{"revoke", aliasExp, "e"},
RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error {
RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error {
id, err := preAuthKeyID(cmd)
if err != nil {
return err
}
err = client.ExpirePreAuthKey(ctx, &apiv1.ExpirePreAuthKeyReq{ID: apiv1.NewOptUint64(id)})
idStr := strconv.FormatUint(id, util.Base10)
resp, err := client.ExpirePreAuthKeyWithResponse(ctx, clientv1.ExpirePreAuthKeyJSONRequestBody{
Id: &idStr,
})
if err != nil {
return fmt.Errorf("expiring preauthkey: %w", err)
}
return printOutput(cmd, map[string]string{colResult: "Key expired"}, "Key expired")
if resp.StatusCode() != http.StatusOK {
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
}
return printOutput(cmd, resp.JSON200, "Key expired")
}),
}
@@ -158,17 +178,25 @@ var deletePreAuthKeyCmd = &cobra.Command{
Use: cmdDelete,
Short: "Delete a preauthkey",
Aliases: []string{aliasDel, "rm", "d"},
RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error {
RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error {
id, err := preAuthKeyID(cmd)
if err != nil {
return err
}
err = client.DeletePreAuthKey(ctx, apiv1.DeletePreAuthKeyParams{ID: apiv1.NewOptUint64(id)})
idStr := strconv.FormatUint(id, util.Base10)
resp, err := client.DeletePreAuthKeyWithResponse(ctx, &clientv1.DeletePreAuthKeyParams{
Id: &idStr,
})
if err != nil {
return fmt.Errorf("deleting preauthkey: %w", err)
}
return printOutput(cmd, map[string]string{colResult: "Key deleted"}, "Key deleted")
if resp.StatusCode() != http.StatusOK {
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
}
return printOutput(cmd, resp.JSON200, "Key deleted")
}),
}
+79 -43
View File
@@ -4,11 +4,14 @@ import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
apiv1 "github.com/juanfont/headscale/gen/api/v1"
clientv1 "github.com/juanfont/headscale/gen/client/v1"
"github.com/juanfont/headscale/hscontrol/util"
"github.com/juanfont/headscale/hscontrol/util/zlog/zf"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
)
@@ -43,27 +46,39 @@ func usernameAndIDFromFlag(cmd *cobra.Command) (uint64, string, error) {
// returning the raw flag id and the matched user.
func resolveSingleUser(
ctx context.Context,
client *apiv1.Client,
client *clientv1.ClientWithResponses,
cmd *cobra.Command,
) (uint64, *apiv1.User, error) {
) (uint64, *clientv1.User, error) {
id, username, err := usernameAndIDFromFlag(cmd)
if err != nil {
return 0, nil, err
}
resp, err := client.ListUsers(ctx, apiv1.ListUsersParams{
Name: optString(username),
ID: optUint64(id),
})
params := &clientv1.ListUsersParams{}
if username != "" {
params.Name = &username
}
if id != 0 {
idStr := strconv.FormatUint(id, util.Base10)
params.Id = &idStr
}
resp, err := client.ListUsersWithResponse(ctx, params)
if err != nil {
return 0, nil, fmt.Errorf("listing users: %w", err)
}
if len(resp.Users) != 1 {
if resp.StatusCode() != http.StatusOK {
return 0, nil, apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
}
users := resp.JSON200.Users
if len(users) != 1 {
return 0, nil, errMultipleUsersMatch
}
return id, &resp.Users[0], nil
return id, &users[0], nil
}
func init() {
@@ -100,32 +115,41 @@ var createUserCmd = &cobra.Command{
return nil
},
RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error {
req := &apiv1.CreateUserReq{Name: apiv1.NewOptString(args[0])}
RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error {
userName := args[0]
log.Trace().Interface(zf.Client, client).Msg("obtained API client")
request := clientv1.CreateUserJSONRequestBody{Name: &userName}
if displayName, _ := cmd.Flags().GetString("display-name"); displayName != "" {
req.DisplayName = apiv1.NewOptString(displayName)
request.DisplayName = &displayName
}
if email, _ := cmd.Flags().GetString("email"); email != "" {
req.Email = apiv1.NewOptString(email)
request.Email = &email
}
if pictureURL, _ := cmd.Flags().GetString("picture-url"); pictureURL != "" {
_, err := url.Parse(pictureURL)
if err != nil {
if _, err := url.Parse(pictureURL); err != nil { //nolint:noinlineerr
return fmt.Errorf("invalid picture URL: %w", err)
}
req.PictureUrl = apiv1.NewOptString(pictureURL)
request.PictureUrl = &pictureURL
}
resp, err := client.CreateUser(ctx, req)
log.Trace().Interface(zf.Request, request).Msg("sending CreateUser request")
resp, err := client.CreateUserWithResponse(ctx, request)
if err != nil {
return fmt.Errorf("creating user: %w", err)
}
return printOutput(cmd, resp.User.Value, "User created")
if resp.StatusCode() != http.StatusOK {
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
}
return printOutput(cmd, resp.JSON200.User, "User created")
}),
}
@@ -133,25 +157,29 @@ var destroyUserCmd = &cobra.Command{
Use: "destroy --identifier ID or --name NAME",
Short: "Destroys a user",
Aliases: []string{cmdDelete},
RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error {
RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error {
_, user, err := resolveSingleUser(ctx, client, cmd)
if err != nil {
return err
}
if !confirmAction(cmd, fmt.Sprintf(
"Do you want to remove the user %q (%d) and any associated preauthkeys?",
user.Name.Value, user.ID.Value,
"Do you want to remove the user %q (%s) and any associated preauthkeys?",
user.Name, user.Id,
)) {
return printOutput(cmd, map[string]string{colResult: "User not destroyed"}, "User not destroyed")
}
err = client.DeleteUser(ctx, apiv1.DeleteUserParams{ID: user.ID.Value})
resp, err := client.DeleteUserWithResponse(ctx, user.Id)
if err != nil {
return fmt.Errorf("destroying user: %w", err)
}
return printOutput(cmd, map[string]string{colResult: "User destroyed"}, "User destroyed")
if resp.StatusCode() != http.StatusOK {
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
}
return printOutput(cmd, resp.JSON200, "User destroyed")
}),
}
@@ -159,8 +187,8 @@ var listUsersCmd = &cobra.Command{
Use: cmdList,
Short: "List all the users",
Aliases: []string{"ls", cmdShow},
RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error {
var params apiv1.ListUsersParams
RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error {
params := &clientv1.ListUsersParams{}
id, _ := cmd.Flags().GetInt64("identifier")
username, _ := cmd.Flags().GetString("name")
@@ -169,29 +197,36 @@ var listUsersCmd = &cobra.Command{
// filter by one param at most
switch {
case id > 0:
params.ID = apiv1.NewOptUint64(uint64(id))
idStr := strconv.FormatInt(id, util.Base10)
params.Id = &idStr
case username != "":
params.Name = apiv1.NewOptString(username)
params.Name = &username
case email != "":
params.Email = apiv1.NewOptString(email)
params.Email = &email
}
resp, err := client.ListUsers(ctx, params)
resp, err := client.ListUsersWithResponse(ctx, params)
if err != nil {
return fmt.Errorf("listing users: %w", err)
}
return printListOutput(cmd, resp.Users, func() error {
rows := make([][]string, 0, len(resp.Users))
for _, user := range resp.Users {
if resp.StatusCode() != http.StatusOK {
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
}
users := resp.JSON200.Users
return printListOutput(cmd, users, func() error {
rows := make([][]string, 0, len(users))
for _, user := range users {
rows = append(
rows,
[]string{
strconv.FormatUint(user.ID.Value, util.Base10),
user.DisplayName.Value,
user.Name.Value,
user.Email.Value,
user.CreatedAt.Value.Format(HeadscaleDateTimeFormat),
user.Id,
user.DisplayName,
user.Name,
user.Email,
user.CreatedAt.Format(HeadscaleDateTimeFormat),
},
)
}
@@ -205,7 +240,7 @@ var renameUserCmd = &cobra.Command{
Use: "rename",
Short: "Renames a user",
Aliases: []string{"mv"},
RunE: apiRunE(func(ctx context.Context, client *apiv1.Client, cmd *cobra.Command, args []string) error {
RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error {
id, _, err := resolveSingleUser(ctx, client, cmd)
if err != nil {
return err
@@ -213,14 +248,15 @@ var renameUserCmd = &cobra.Command{
newName, _ := cmd.Flags().GetString("new-name")
resp, err := client.RenameUser(ctx, apiv1.RenameUserParams{
OldID: id,
NewName: newName,
})
resp, err := client.RenameUserWithResponse(ctx, strconv.FormatUint(id, util.Base10), newName)
if err != nil {
return fmt.Errorf("renaming user: %w", err)
}
return printOutput(cmd, resp.User.Value, "User renamed")
if resp.StatusCode() != http.StatusOK {
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
}
return printOutput(cmd, resp.JSON200.User, "User renamed")
}),
}
+231 -40
View File
@@ -1,17 +1,27 @@
package cli
import (
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"os"
"reflect"
"slices"
"strings"
"time"
"github.com/cenkalti/backoff/v5"
clientv1 "github.com/juanfont/headscale/gen/client/v1"
"github.com/juanfont/headscale/hscontrol"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/util"
"github.com/juanfont/headscale/hscontrol/util/zlog/zf"
"github.com/prometheus/common/model"
"github.com/pterm/pterm"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
)
@@ -28,11 +38,45 @@ const (
var (
errAPIKeyNotSet = errors.New("HEADSCALE_CLI_API_KEY environment variable needs to be set")
errMissingParameter = errors.New("missing parameters")
errResponseStatus = errors.New("unexpected response status")
)
// mustMarkRequired marks the named flags as required on cmd, panicking
// if any name does not match a registered flag. This is only called
// from init() where a failure indicates a programming error.
// apiError turns a non-2xx response into an error, surfacing the server's
// RFC7807 problem detail. detail holds the operation context and errors[] the
// wrapped cause (e.g. "name is too long"); both are joined so the server's
// message text is not lost.
func apiError(statusCode int, problem *clientv1.ErrorModel) error {
if problem == nil {
return fmt.Errorf("%w: %d %s", errResponseStatus, statusCode, http.StatusText(statusCode))
}
parts := make([]string, 0, 2)
if problem.Detail != nil && *problem.Detail != "" {
parts = append(parts, *problem.Detail)
}
if problem.Errors != nil {
for _, e := range *problem.Errors {
if e.Message != nil && *e.Message != "" {
parts = append(parts, *e.Message)
}
}
}
if len(parts) == 0 && problem.Title != nil && *problem.Title != "" {
parts = append(parts, *problem.Title)
}
if len(parts) == 0 {
return fmt.Errorf("%w: %d %s", errResponseStatus, statusCode, http.StatusText(statusCode))
}
return fmt.Errorf("%w: %s", errResponseStatus, strings.Join(parts, ": "))
}
// mustMarkRequired marks the named flags as required, panicking on an unknown
// flag. Only called from init(), where a failure is a programming error.
func mustMarkRequired(cmd *cobra.Command, names ...string) {
for _, n := range names {
err := cmd.MarkFlagRequired(n)
@@ -59,37 +103,185 @@ func newHeadscaleServerWithConfig() (*hscontrol.Headscale, error) {
return app, nil
}
// addressableForJSON makes value-typed generated API structs (and slice
// elements) addressable so their pointer-receiver MarshalJSON is used. That
// method omits unset optional fields; stdlib reflection over a value instead
// calls Opt*.MarshalJSON directly, which returns empty for an unset field and
// fails with "unexpected end of JSON input".
func addressableForJSON(v any) any {
rv := reflect.ValueOf(v)
// clientRunE wraps a [cobra.Command.RunE] func, injecting a ready API client
// and a context whose timeout/cancel the wrapper owns.
func clientRunE(
fn func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error,
) func(*cobra.Command, []string) error {
return func(cmd *cobra.Command, args []string) error {
ctx, client, cancel, err := newHeadscaleCLIWithConfig()
if err != nil {
return fmt.Errorf("connecting to headscale: %w", err)
}
defer cancel()
switch rv.Kind() { //nolint:exhaustive // default handles all other kinds
case reflect.Slice, reflect.Array:
out := make([]any, rv.Len())
for i := range out {
out[i] = addressableForJSON(rv.Index(i).Interface())
return fn(ctx, client, cmd, args)
}
}
// withClient runs fn with an API client. For commands that branch on a flag
// before talking to the server, where clientRunE's whole-RunE wrapping does
// not fit.
func withClient(
fn func(ctx context.Context, client *clientv1.ClientWithResponses) error,
) error {
ctx, client, cancel, err := newHeadscaleCLIWithConfig()
if err != nil {
return fmt.Errorf("connecting to headscale: %w", err)
}
defer cancel()
return fn(ctx, client)
}
// newHeadscaleCLIWithConfig builds an HTTP client for the Headscale v1 API.
//
// When cfg.CLI.Address is unset the CLI is assumed to run on the server host
// and talks to the unix socket over HTTP without authentication (local trust).
// Otherwise it talks to the remote TCP address over HTTPS and injects the
// configured API key as a bearer token.
func newHeadscaleCLIWithConfig() (context.Context, *clientv1.ClientWithResponses, context.CancelFunc, error) {
cfg, err := types.LoadCLIConfig()
if err != nil {
return nil, nil, nil, fmt.Errorf("loading configuration: %w", err)
}
log.Debug().
Dur("timeout", cfg.CLI.Timeout).
Msgf("Setting timeout")
ctx, cancel := context.WithTimeout(context.Background(), cfg.CLI.Timeout)
address := cfg.CLI.Address
// If the address is not set, we assume that we are on the server hosting [hscontrol].
if address == "" {
log.Debug().
Str("socket", cfg.UnixSocket).
Msgf("HEADSCALE_CLI_ADDRESS environment is not set, connecting to unix socket.")
client, err := newSocketClient(cfg.UnixSocket)
if err != nil {
cancel()
return nil, nil, nil, err
}
return out
case reflect.Struct:
p := reflect.New(rv.Type())
p.Elem().Set(rv)
log.Trace().Caller().Str(zf.Address, cfg.UnixSocket).Msg("connecting via unix socket")
return p.Interface()
default:
return v
return ctx, client, cancel, nil
}
// Remote connections require an API key for authentication.
apiKey := cfg.CLI.APIKey
if apiKey == "" {
cancel()
return nil, nil, nil, errAPIKeyNotSet
}
client, err := newRemoteClient(address, apiKey, cfg.CLI.Insecure)
if err != nil {
cancel()
return nil, nil, nil, err
}
log.Trace().Caller().Str(zf.Address, address).Msg("connecting via HTTPS")
return ctx, client, cancel, nil
}
// newSocketClient builds an API client that dials the local unix socket. The
// base-URL host is irrelevant; the custom dialer routes every request to the
// socket.
func newSocketClient(socketPath string) (*clientv1.ClientWithResponses, error) {
// Probe for a clearer permission error up front. [os.OpenFile] on a unix
// socket returns ENXIO on Linux (expected); only permission errors are
// actionable. The real connection goes through [net.Dial].
socket, err := os.OpenFile(socketPath, os.O_WRONLY, SocketWritePermissions) //nolint
if err != nil {
if os.IsPermission(err) {
return nil, fmt.Errorf(
"unable to read/write to headscale socket %q, do you have the correct permissions? %w",
socketPath,
err,
)
}
} else {
socket.Close()
}
httpClient := &http.Client{
Transport: &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return dialHeadscaleSocket(ctx, socketPath)
},
},
}
return clientv1.NewClientWithResponses(
"http://local",
clientv1.WithHTTPClient(httpClient),
)
}
// dialHeadscaleSocket connects to the unix socket, retrying until it appears or
// ctx (the CLI timeout) expires. The socket is created late in startup (after
// noise key, database, migrations), so a command run right after the server
// starts can race its creation; retrying preserves the old gRPC client's
// blocking-dial tolerance rather than failing on a not-yet-present socket.
func dialHeadscaleSocket(ctx context.Context, socketPath string) (net.Conn, error) {
b := backoff.NewExponentialBackOff()
b.InitialInterval = 50 * time.Millisecond
b.MaxInterval = 1 * time.Second
return backoff.Retry(ctx, func() (net.Conn, error) {
return util.SocketDialer(ctx, socketPath)
}, backoff.WithBackOff(b))
}
// clientBaseURL turns a configured CLI address into a client base URL. A bare
// host[:port] (the historical form) defaults to https; an address that already
// carries a scheme is used as-is, so an explicit http:// or https:// is honoured
// rather than doubled into https://https://...
func clientBaseURL(address string) string {
if strings.Contains(address, "://") {
return address
}
return "https://" + address
}
// newRemoteClient builds an API client for a remote Headscale over HTTPS,
// honouring insecure (skip TLS verification) and injecting the API key as a
// bearer token on every request.
func newRemoteClient(address, apiKey string, insecure bool) (*clientv1.ClientWithResponses, error) {
transport := &http.Transport{}
if insecure {
transport.TLSClientConfig = &tls.Config{
// turn off gosec as we are intentionally setting insecure.
//nolint:gosec
InsecureSkipVerify: true,
}
}
httpClient := &http.Client{Transport: transport}
return clientv1.NewClientWithResponses(
clientBaseURL(address),
clientv1.WithHTTPClient(httpClient),
clientv1.WithRequestEditorFn(func(_ context.Context, req *http.Request) error {
req.Header.Set("Authorization", "Bearer "+apiKey)
return nil
}),
)
}
// formatOutput serialises result into the requested format. For the
// default (empty) format the human-readable override string is returned.
func formatOutput(result any, override string, outputFormat string) (string, error) {
result = addressableForJSON(result)
switch outputFormat {
case outputFormatJSON:
b, err := json.MarshalIndent(result, "", "\t")
@@ -106,21 +298,7 @@ func formatOutput(result any, override string, outputFormat string) (string, err
return string(b), nil
case outputFormatYAML:
// Route through JSON so types with a custom MarshalJSON (the generated
// API types) serialise by their JSON shape, then convert to YAML.
j, err := json.Marshal(result)
if err != nil {
return "", fmt.Errorf("marshalling output: %w", err)
}
var generic any
err = yaml.Unmarshal(j, &generic)
if err != nil {
return "", fmt.Errorf("converting output to YAML: %w", err)
}
b, err := yaml.Marshal(generic)
b, err := yaml.Marshal(result)
if err != nil {
return "", fmt.Errorf("marshalling YAML output: %w", err)
}
@@ -146,6 +324,19 @@ func printOutput(cmd *cobra.Command, result any, override string) error {
return nil
}
// expirationFromFlag parses the --expiration flag as a Prometheus-style
// duration (e.g. "90d", "1h") and returns an absolute time.
func expirationFromFlag(cmd *cobra.Command) (time.Time, error) {
durationStr, _ := cmd.Flags().GetString("expiration")
duration, err := model.ParseDuration(durationStr)
if err != nil {
return time.Time{}, fmt.Errorf("parsing duration: %w", err)
}
return time.Now().UTC().Add(time.Duration(duration)), nil
}
// confirmAction returns true when the user confirms a prompt, or when
// --force is set. Callers decide what to do when it returns false.
func confirmAction(cmd *cobra.Command, prompt string) bool {
+72
View File
@@ -0,0 +1,72 @@
package cli
import (
"context"
"net"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestDialHeadscaleSocketRetriesUntilPresent proves the CLI socket dialer
// tolerates a not-yet-created socket (the server-still-starting race) by
// retrying until it appears, rather than failing immediately like a bare dial.
func TestDialHeadscaleSocketRetriesUntilPresent(t *testing.T) {
sock := filepath.Join(t.TempDir(), "headscale.sock")
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
type result struct {
conn net.Conn
err error
}
done := make(chan result, 1)
go func() {
conn, err := dialHeadscaleSocket(ctx, sock)
done <- result{conn, err}
}()
// Listen only after the dialer has begun, so its backoff must retry the
// absent socket and connect once it exists.
var lc net.ListenConfig
ln, err := lc.Listen(ctx, "unix", sock)
require.NoError(t, err)
defer ln.Close()
go func() {
if conn, _ := ln.Accept(); conn != nil {
conn.Close()
}
}()
res := <-done
require.NoError(t, res.err)
require.NotNil(t, res.conn)
res.conn.Close()
}
// TestDialHeadscaleSocketRespectsDeadline proves the retry is bounded by the
// context: when the socket never appears, the dialer returns an error around the
// deadline instead of hanging.
func TestDialHeadscaleSocketRespectsDeadline(t *testing.T) {
sock := filepath.Join(t.TempDir(), "absent.sock")
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
start := time.Now()
conn, err := dialHeadscaleSocket(ctx, sock)
require.Error(t, err)
assert.Nil(t, conn)
assert.Less(t, time.Since(start), 5*time.Second, "should stop near the deadline, not hang")
}
+27 -144
View File
@@ -1,152 +1,35 @@
package cli
import (
"encoding/json"
"strings"
"testing"
import "testing"
apiv1 "github.com/juanfont/headscale/gen/api/v1"
"tailscale.com/types/key"
)
// sampleNode builds a fully-populated node with valid machine/node keys so the
// table renderer's UnmarshalText/ParseAddr paths exercise real data.
func sampleNode(t *testing.T) apiv1.Node {
t.Helper()
mkey, err := key.NewMachine().Public().MarshalText()
if err != nil {
t.Fatalf("machine key: %v", err)
func TestClientBaseURL(t *testing.T) {
tests := []struct {
name string
address string
want string
}{
{
name: "bare host defaults to https",
address: "headscale.example.com:50443",
want: "https://headscale.example.com:50443",
},
{
name: "explicit https scheme is kept",
address: "https://headscale.example.com",
want: "https://headscale.example.com",
},
{
name: "explicit http scheme is kept",
address: "http://localhost:8080",
want: "http://localhost:8080",
},
}
nkey, err := key.NewNode().Public().MarshalText()
if err != nil {
t.Fatalf("node key: %v", err)
}
return apiv1.Node{
ID: apiv1.NewOptUint64(7),
Name: apiv1.NewOptString("host7"),
GivenName: apiv1.NewOptString("host7"),
MachineKey: apiv1.NewOptString(string(mkey)),
NodeKey: apiv1.NewOptString(string(nkey)),
User: apiv1.NewOptUser(apiv1.User{Name: apiv1.NewOptString("alice")}),
IpAddresses: []string{"100.64.0.7", "fd7a:115c:a1e0::7"},
Online: apiv1.NewOptBool(true),
// Expiry, LastSeen, Tags deliberately unset.
}
}
// TestFormatOutputValueWithUnsetOptional guards against a regression where the
// generated API types, passed by value with unset optional fields, fail to
// marshal because stdlib reflection calls Opt*.MarshalJSON directly.
func TestFormatOutputValueWithUnsetOptional(t *testing.T) {
cases := map[string]any{
"user": apiv1.User{ID: apiv1.NewOptUint64(1), Name: apiv1.NewOptString("alice")},
"node": sampleNode(t),
"preauthkey": apiv1.PreAuthKey{ID: apiv1.NewOptUint64(2), Key: apiv1.NewOptString("abc")},
"apikey": apiv1.ApiKey{ID: apiv1.NewOptUint64(3), Prefix: apiv1.NewOptString("pref")},
}
formats := []string{outputFormatJSON, outputFormatJSONLine, outputFormatYAML}
for name, val := range cases {
for _, format := range formats {
// Single value.
out, err := formatOutput(val, "", format)
if err != nil {
t.Fatalf("formatOutput(%s, %s) value: %v", name, format, err)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := clientBaseURL(tt.address); got != tt.want {
t.Errorf("clientBaseURL(%q) = %q, want %q", tt.address, got, tt.want)
}
if strings.TrimSpace(out) == "" {
t.Errorf("formatOutput(%s, %s) value: empty output", name, format)
}
// Slice of values (the "list" commands).
sliceOut, err := formatOutput([]any{val}, "", format)
if err != nil {
t.Fatalf("formatOutput(%s, %s) slice: %v", name, format, err)
}
if strings.TrimSpace(sliceOut) == "" {
t.Errorf("formatOutput(%s, %s) slice: empty output", name, format)
}
}
}
}
// TestFormatOutputJSONShape pins that a value-typed object serialises by its
// JSON field names (not the Go struct shape) and omits unset optionals.
func TestFormatOutputJSONShape(t *testing.T) {
user := apiv1.User{ID: apiv1.NewOptUint64(1), Name: apiv1.NewOptString("alice")}
out, err := formatOutput(user, "", outputFormatJSON)
if err != nil {
t.Fatalf("formatOutput: %v", err)
}
var decoded map[string]any
err = json.Unmarshal([]byte(out), &decoded)
if err != nil {
t.Fatalf("output is not valid JSON: %v\n%s", err, out)
}
if decoded["name"] != "alice" || decoded["id"] != float64(1) {
t.Errorf("unexpected JSON shape: %s", out)
}
if _, present := decoded["displayName"]; present {
t.Errorf("unset optional should be omitted, got: %s", out)
}
}
// TestNodesToPtables exercises the default table renderer, whose manual
// MachineKey/NodeKey UnmarshalText and IP ParseAddr have no other coverage.
func TestNodesToPtables(t *testing.T) {
data, err := nodesToPtables([]apiv1.Node{sampleNode(t)})
if err != nil {
t.Fatalf("nodesToPtables: %v", err)
}
if len(data) != 2 {
t.Fatalf("want header + 1 row, got %d rows", len(data))
}
row := strings.Join(data[1], "|")
for _, want := range []string{"host7", "alice", "100.64.0.7", "fd7a:115c:a1e0::7"} {
if !strings.Contains(row, want) {
t.Errorf("row missing %q: %s", want, row)
}
}
}
// TestNodesToPtablesEmptyNodeKey documents that a missing NodeKey makes the
// table render fail loudly rather than silently — so a decode regression that
// drops NodeKey over HTTP is caught at the CLI, not just in -o json.
func TestNodesToPtablesEmptyNodeKey(t *testing.T) {
node := sampleNode(t)
node.NodeKey = apiv1.OptString{}
_, err := nodesToPtables([]apiv1.Node{node})
if err == nil {
t.Error("expected error for empty NodeKey, got nil")
}
}
// TestNodeRoutesToPtables renders the list-routes table, which has zero
// integration coverage.
func TestNodeRoutesToPtables(t *testing.T) {
node := sampleNode(t)
node.SubnetRoutes = []string{"10.0.0.0/24"}
node.ApprovedRoutes = []string{"10.0.0.0/24"}
data := nodeRoutesToPtables([]apiv1.Node{node})
if len(data) != 2 {
t.Fatalf("want header + 1 row, got %d rows", len(data))
}
if !strings.Contains(strings.Join(data[1], "|"), "10.0.0.0/24") {
t.Errorf("route row missing approved route: %v", data[1])
})
}
}
-124
View File
@@ -1,124 +0,0 @@
package cli
import (
"errors"
"testing"
"time"
"github.com/spf13/cobra"
)
func TestUsernameAndIDFromFlag(t *testing.T) {
newCmd := func() *cobra.Command {
cmd := &cobra.Command{}
cmd.Flags().StringP("name", "n", "", "")
cmd.Flags().Int64P("identifier", "i", -1, "")
return cmd
}
t.Run("neither flag is required", func(t *testing.T) {
_, _, err := usernameAndIDFromFlag(newCmd())
if !errors.Is(err, errFlagRequired) {
t.Fatalf("want errFlagRequired, got %v", err)
}
})
t.Run("name only", func(t *testing.T) {
cmd := newCmd()
_ = cmd.Flags().Set("name", "alice")
id, name, err := usernameAndIDFromFlag(cmd)
if err != nil || id != 0 || name != "alice" {
t.Fatalf("got id=%d name=%q err=%v", id, name, err)
}
})
t.Run("id only", func(t *testing.T) {
cmd := newCmd()
_ = cmd.Flags().Set("identifier", "7")
id, name, err := usernameAndIDFromFlag(cmd)
if err != nil || id != 7 || name != "" {
t.Fatalf("got id=%d name=%q err=%v", id, name, err)
}
})
}
func TestApiKeyIDOrPrefix(t *testing.T) {
newCmd := func() *cobra.Command {
cmd := &cobra.Command{}
cmd.Flags().Uint64P("id", "i", 0, "")
cmd.Flags().StringP("prefix", "p", "", "")
return cmd
}
t.Run("neither is an error", func(t *testing.T) {
_, _, err := apiKeyIDOrPrefix(newCmd())
if !errors.Is(err, errMissingParameter) {
t.Fatalf("want errMissingParameter, got %v", err)
}
})
t.Run("both is an error", func(t *testing.T) {
cmd := newCmd()
_ = cmd.Flags().Set("id", "1")
_ = cmd.Flags().Set("prefix", "abc")
_, _, err := apiKeyIDOrPrefix(cmd)
if !errors.Is(err, errMissingParameter) {
t.Fatalf("want errMissingParameter, got %v", err)
}
})
t.Run("id only", func(t *testing.T) {
cmd := newCmd()
_ = cmd.Flags().Set("id", "5")
id, prefix, err := apiKeyIDOrPrefix(cmd)
if err != nil || id != 5 || prefix != "" {
t.Fatalf("got id=%d prefix=%q err=%v", id, prefix, err)
}
})
}
func TestExpirationFromFlag(t *testing.T) {
newCmd := func() *cobra.Command {
cmd := &cobra.Command{}
cmd.Flags().StringP("expiration", "e", "", "")
return cmd
}
t.Run("empty is unset", func(t *testing.T) {
exp, err := expirationFromFlag(newCmd())
if err != nil || exp.Set {
t.Fatalf("want unset, got set=%v err=%v", exp.Set, err)
}
})
t.Run("invalid duration is an error", func(t *testing.T) {
cmd := newCmd()
_ = cmd.Flags().Set("expiration", "not-a-duration")
_, err := expirationFromFlag(cmd)
if err == nil {
t.Fatal("want error for invalid duration")
}
})
t.Run("valid duration is in the future", func(t *testing.T) {
cmd := newCmd()
_ = cmd.Flags().Set("expiration", "1h")
exp, err := expirationFromFlag(cmd)
if err != nil || !exp.Set {
t.Fatalf("want set, got set=%v err=%v", exp.Set, err)
}
if !exp.Value.After(time.Now()) {
t.Errorf("expiration not in the future: %v", exp.Value)
}
})
}
+18 -3
View File
@@ -187,14 +187,29 @@ func removeContainerWithRetry(ctx context.Context, cli *client.Client, container
return err == nil
}
// testContainerNamePrefixes are the name prefixes used by containers that the
// integration test harness creates (headscale, tailscale, DERP, and k3s).
var testContainerNamePrefixes = []string{"hs-", "ts-", "derp-", "k3s-"}
// matchesTestContainerPrefix reports whether name belongs to an integration
// test container, ignoring any leading "/" that Docker prefixes names with.
func matchesTestContainerPrefix(name string) bool {
name = strings.TrimPrefix(name, "/")
for _, prefix := range testContainerNamePrefixes {
if strings.HasPrefix(name, prefix) {
return true
}
}
return false
}
// isTestContainerName reports whether any of the container names belong to an
// integration test container.
func isTestContainerName(names []string) bool {
for _, name := range names {
if strings.Contains(name, "headscale-test-suite") ||
strings.Contains(name, "hs-") ||
strings.Contains(name, "ts-") ||
strings.Contains(name, "derp-") {
matchesTestContainerPrefix(name) {
return true
}
}
+1 -1
View File
@@ -735,7 +735,7 @@ func getCurrentTestContainers(containers []container.Summary, testContainerID st
for _, cont := range containers {
for _, name := range cont.Names {
containerName := strings.TrimPrefix(name, "/")
if strings.HasPrefix(containerName, "hs-") || strings.HasPrefix(containerName, "ts-") {
if matchesTestContainerPrefix(containerName) {
// Check if container has matching run ID label
if cont.Labels != nil && cont.Labels["hi.run-id"] == runID {
testRunContainers = append(testRunContainers, testContainer{
+41
View File
@@ -10,6 +10,7 @@ import (
"strings"
"github.com/juanfont/headscale/integration/dockertestutil"
"github.com/juanfont/headscale/integration/k3sic"
)
const (
@@ -21,6 +22,7 @@ const (
nameDockerContext = "Docker Context"
nameDockerSocket = "Docker Socket"
nameGolangImage = "Golang Image"
nameK3sImage = "K3s Image"
nameGoInstall = "Go Installation"
)
@@ -66,6 +68,7 @@ func runDoctorCheck(ctx context.Context) error {
results = append(results, checkDockerSocket(ctx))
results = append(results, checkDockerHubCredentials())
results = append(results, checkGolangImage(ctx))
results = append(results, checkK3sImage(ctx))
}
// Check 3: Go installation
@@ -242,6 +245,44 @@ func checkGolangImage(ctx context.Context) DoctorResult {
return pass(nameGolangImage, fmt.Sprintf("Golang image %s is now available", imageName))
}
// checkK3sImage verifies the ghcr k3s image used by TestK8sOperator is available
// locally or can be pulled. The image is pinned (see [k3sic.K3sImage]).
func checkK3sImage(ctx context.Context) DoctorResult {
cli, err := createDockerClient(ctx)
if err != nil {
return fail(nameK3sImage, "Cannot create Docker client for image check")
}
defer cli.Close()
imageName := k3sic.K3sImage
available, err := checkImageAvailableLocally(ctx, cli, imageName)
if err != nil {
return fail(
nameK3sImage,
fmt.Sprintf("Cannot check k3s image %s: %v", imageName, err),
"Check Docker daemon status",
"Try: docker images | grep k3s",
)
}
if available {
return pass(nameK3sImage, fmt.Sprintf("K3s image %s is available locally", imageName))
}
err = ensureImageAvailable(ctx, cli, imageName, false)
if err != nil {
return warn(
nameK3sImage,
fmt.Sprintf("K3s image %s not available locally and could not pull: %v", imageName, err),
"Only TestK8sOperator needs this image; other tests are unaffected",
"Try: docker pull "+imageName,
)
}
return pass(nameK3sImage, fmt.Sprintf("K3s image %s is now available", imageName))
}
// checkGoInstallation verifies Go is installed and working.
func checkGoInstallation(ctx context.Context) DoctorResult {
_, err := exec.LookPath("go")
+9 -9
View File
@@ -230,12 +230,12 @@ database:
# ssl: false
### TLS configuration
#
## Let's encrypt / ACME
#
# headscale supports automatically requesting and setting up
# See: https://headscale.net/stable/ref/tls/
## Let's Encrypt / ACME
# Headscale supports automatically requesting and setting up
# TLS for a domain with Let's Encrypt.
#
# URL to ACME directory
acme_url: https://acme-v02.api.letsencrypt.org/directory
@@ -245,15 +245,13 @@ acme_email: ""
# Domain name to request a TLS certificate for:
tls_letsencrypt_hostname: ""
# Path to store certificates and metadata needed by
# letsencrypt
# For production:
# Path to store certificates and metadata needed by letsencrypt
tls_letsencrypt_cache_dir: /var/lib/headscale/cache
# Type of ACME challenge to use, currently supported types:
# HTTP-01 or TLS-ALPN-01
# See: https://headscale.net/stable/ref/tls/
tls_letsencrypt_challenge_type: HTTP-01
# When HTTP-01 challenge is chosen, letsencrypt must set up a
# verification endpoint, and it will be listening on:
# :http = port 80
@@ -279,6 +277,7 @@ policy:
# The mode can be "file" or "database" that defines
# where the policies are stored and read from.
mode: file
# If the mode is set to "file", the path to a HuJSON file containing policies.
path: ""
@@ -460,6 +459,7 @@ taildrop:
# choice.
auto_update:
enabled: false
# Advanced performance tuning parameters.
# The defaults are carefully chosen and should rarely need adjustment.
# Only modify these if you have identified a specific performance issue.
+3 -4
View File
@@ -29,12 +29,12 @@ headscale apikeys expire --prefix <PREFIX>
## REST API
- API endpoint: `/api/v1`, e.g. `https://headscale.example.com/api/v1`
- Documentation: `/swagger`, e.g. `https://headscale.example.com/swagger`
- Documentation: `/api/v1/docs`, e.g. `https://headscale.example.com/api/v1/docs`
- Headscale Version: `/version`, e.g. `https://headscale.example.com/version`
- Authenticate using HTTP Bearer authentication by sending the [API key](#api) with the HTTP `Authorization: Bearer <API_KEY>` header.
Start by [creating an API key](#api) and test it with the examples below. Read the API documentation provided by your
Headscale server at `/swagger` for details.
Headscale server at `/api/v1/docs` for details.
=== "Get details for all users"
@@ -54,7 +54,7 @@ Headscale server at `/swagger` for details.
```console
curl -H "Authorization: Bearer <API_KEY>" \
--json '{"user": "<USER>", "authId": "AUTH_ID>"}' \
--json '{"user": "<USER>", "authId": "<AUTH_ID>"}' \
https://headscale.example.com/api/v1/auth/register
```
@@ -66,7 +66,6 @@ The `headscale` binary can control a Headscale instance from a remote machine ov
- A workstation to run `headscale` (any supported platform, e.g. Linux).
- The Headscale server reachable over HTTP(S).
- Remote access requires an encrypted connection via TLS.
- An [API key](#api) to authenticate with the Headscale server.
### Setup remote control
+2
View File
@@ -23,5 +23,7 @@ Headscale doesn't provide a built-in web interface but users may pick one from t
- [HeadControl](https://github.com/ahmadzip/HeadControl) - Minimal Headscale admin dashboard, built with Go and HTMX
- [Headscale Manager](https://github.com/hkdone/headscalemanager) - Headscale UI for Android
- [Headscale UI](https://github.com/MunMunMiao/headscale-ui) - Headscale UI online and Self-hosting
- [Headscale Panel](https://github.com/headscale-panel/panel) - A modern Headscale management panel with a clean,
network-operations-focused UI
You can ask for support on our [Discord server](https://discord.gg/c84AZQhmpx) in the "web-interfaces" channel.
+3 -3
View File
@@ -158,17 +158,17 @@ devices. Can only be used in policy destinations.
{
"src": ["boss@"],
"dst": ["boss@"],
"ip": "*"
"ip": ["*"]
},
{
"src": ["dev1@"],
"dst": ["dev1@"],
"ip": "*"
"ip": ["*"]
},
{
"src": ["intern1@"],
"dst": ["intern1@"],
"ip": "*"
"ip": ["*"]
}
]
}
+3 -2
View File
@@ -7,8 +7,9 @@
**It might be outdated and it might miss necessary steps**.
Headscale can be built from source using the latest version of [Go](https://golang.org). See the [Contributing
section in the GitHub README](https://github.com/juanfont/headscale#contributing) for more information.
Headscale can be built from source using the latest version of [Go](https://golang.org) and [Buf](https://buf.build)
(Protobuf generator). See the [Contributing section in the GitHub
README](https://github.com/juanfont/headscale#contributing) for more information.
## OpenBSD
+3 -3
View File
@@ -2,7 +2,7 @@
!!! tip "Required update path"
Its required to update from one stable version to the next (e.g. 0.26.0 → 0.27.1 → 0.28.0) without skipping minor
It's required to update from one stable version to the next (e.g. 0.26.0 → 0.27.1 → 0.28.0) without skipping minor
versions in between. You should always pick the latest available patch release.
Update an existing Headscale installation to a new version:
@@ -23,7 +23,7 @@ upgrading. A full backup of Headscale depends on your individual setup, but belo
=== "Standard installation"
A installation that follows our [official releases](install/official.md) setup guide uses the following paths:
An installation that follows our [official releases](install/official.md) setup guide uses the following paths:
- [Configuration file](../ref/configuration.md): `/etc/headscale/config.yaml`
- Data directory: `/var/lib/headscale`
@@ -37,7 +37,7 @@ upgrading. A full backup of Headscale depends on your individual setup, but belo
=== "Container"
A installation that follows our [container](install/container.md) setup guide uses a single source volume directory
An installation that follows our [container](install/container.md) setup guide uses a single source volume directory
that contains the configuration file, data directory and the SQLite database.
```console
-132
View File
@@ -1,132 +0,0 @@
# v1 API: behaviour changes (gRPC/grpc-gateway → ogen OpenAPI 3.0)
This file records every deliberate deviation of the new ogen-based v1 HTTP API
from the previous gRPC + grpc-gateway behaviour. Anything not listed here is
preserved: HTTP verbs and path templates, JSON property names (camelCase,
matching protojson), enum values (`SCREAMING_SNAKE`, e.g.
`REGISTER_METHOD_CLI`), RFC3339 timestamp strings, response envelopes
(`{"user": …}`, `{"nodes": […]}`, …), and the set of operations and their side
effects on the state layer.
Each entry: **what changed**, **why**, **client impact**.
## Wire format
### Integer IDs are JSON numbers, not strings
**What:** `id`, `nodeId`/`node_id`, `oldId`, `user`, and every other integer
identifier is now a JSON number (`"id": 42`). grpc-gateway emitted protojson's
int64-as-string form (`"id": "42"`).
**Why:** native integers are the idiomatic OpenAPI 3.0 / ogen representation,
type-safe end to end, and remove per-field string⇄int conversion. Headscale
identifiers are small and well within the JS safe-integer range.
**Client impact:** HTTP clients that read IDs as strings must read them as
numbers. `headscale … -o json/yaml` output changes accordingly (the CLI now
runs on the generated client).
### Error responses are RFC 7807 problem documents
**What:** errors are returned as `application/problem+json` with a body of
`{type?, title, status, detail, instance?}`. grpc-gateway returned an
`rpcStatus` envelope `{code, message, details[]}` as `application/json`, where
`code` was a gRPC status code and `details` was effectively always empty.
**Why:** RFC 7807 is the standard HTTP error shape; the gRPC `code`/`details`
fields were gRPC implementation leakage with no value over the HTTP status line.
**Client impact:** clients that parsed `{code, message}` must read
`{status, detail}` and the `application/problem+json` content type. The HTTP
status code itself is unchanged for equivalent conditions (e.g. unknown user →
404, invalid argument → 400, bad/My missing API key → 401).
## Behaviour
### Client errors return 4xx consistently
**What:** client mistakes now map to the appropriate 4xx status instead of 500.
Missing resources are `404` (e.g. `RenameUser`, `DeleteUser`, `GetNode`,
`DeleteNode`); invalid input is `400` (e.g. an unparseable route in
`SetApprovedRoutes`, a malformed registration key in `RegisterNode`, an invalid
tag in `SetTags`, an unconfirmed `BackfillNodeIPs`). Many of these gRPC handlers
returned a plain Go error, which grpc-gateway rendered as `500`.
**Why:** a missing resource or bad input is a client error, not a server error;
4xx is the correct, consistent status.
**Client impact:** clients that treated these as 500 should treat them as 400/404.
### Health on database failure
**What:** `GET /api/v1/health` returns `200 {"databaseConnectivity": true}` when
the database is reachable and `500` (problem document) when the ping fails. The
gRPC implementation returned the ping error, which grpc-gateway rendered as a
500; the `databaseConnectivity:false` body was never observable on failure.
**Why:** preserves the observable contract (200 healthy, 500 unhealthy) under
the new error shape.
**Client impact:** none beyond the problem-document error shape above.
## CLI
### Remote CLI connects to the HTTP API, not the gRPC port
**What:** with a configured `cli.address` (or `HEADSCALE_CLI_ADDRESS`), the CLI
now speaks HTTP to the headscale API URL rather than gRPC to `grpc_listen_addr`.
A bare `host:port` is assumed to be `https://host:port`. Locally (no address)
the CLI talks HTTP over the existing unix socket, unchanged in spirit — no API
key needed, filesystem permissions are the trust boundary.
**Why:** the gRPC service and its TCP listener are removed; the CLI runs on the
generated HTTP client.
**Client impact:** point `cli.address` at the headscale HTTP server (the same
URL `server_url` is reachable on) instead of the gRPC address. `cli.api_key`
and `cli.insecure` are unchanged.
### `delete`/`expire` commands print a result message
**What:** commands whose API operation has no response body (user/node/key
delete, key expire, auth approve/reject) print a small
`{"result": "..."}`-style object (or the human-readable message) instead of the
previous empty `{}`.
**Why:** the operations return no content; a result message is more useful than
an empty object.
**Client impact:** scripts parsing the empty `{}` should read the `result`
field (machine-readable output) or rely on the exit code.
### CLI database-bypass flag renamed
**What:** `--bypass-grpc-and-access-database-directly` is now
`--bypass-server-and-access-database-directly`.
**Why:** the gRPC server is gone; the flag bypasses the running server whatever
its transport, so the name no longer mentions gRPC.
**Client impact:** scripts using the old flag name must update it.
### Missing resources return a consistent `404`
**What:** renaming or expiring an unknown node, and expiring or deleting an
unknown pre-auth key, now return `404 Not Found`. Previously the node
operations surfaced as `500` and the pre-auth key operations reported success
without changing anything.
**Why:** a missing resource is a client error, not a server error, and an
expire or delete that matched no row should not report success.
**Client impact:** code that treated these as `500` or as a silent success
should handle `404`.
## Delivery note (not a shipped behaviour change)
The grpc-gateway HTTP facade is replaced wholesale at `/api/v1` by the ogen
server in the foundation commit, rather than path-by-path. During development,
endpoints whose resource group has not yet been migrated return `501` over HTTP;
all are implemented before the branch is complete. The CLI is unaffected during
this window because it still uses the gRPC servers until its own migration. No
intermediate state is released.
-147
View File
@@ -1,147 +0,0 @@
# Plan: Headscale v1 API — gRPC/Protobuf → ogen OpenAPI 3.0
This is the implementation plan for converting the Headscale v1 API from a
Protobuf gRPC service with a grpc-gateway REST facade to an OpenAPI 3.0 spec
driving ogen-generated server stubs and a Go client, backed unchanged by the
`hscontrol/state` layer. It is written so coding agents can execute the
remaining work autonomously; tests are the gate.
See [CHANGES.md](./CHANGES.md) for every deliberate behaviour deviation.
## Locked decisions
- **Integer IDs** are native JSON numbers (`type: integer, format: uint64`), not protojson strings.
- **Errors** are RFC 7807 problem documents (`application/problem+json`).
- **Local CLI** talks HTTP over the existing unix socket (no API key; socket permissions are the trust boundary). Remote stays HTTPS + bearer key.
- Preserved by default: HTTP verbs/paths, camelCase JSON keys, `SCREAMING_SNAKE` enums, RFC3339 timestamps, response envelopes, operation set and state side effects.
## Package layout (v1 self-contained; v2 drops in as a sibling)
| Concern | Path | Package |
| --------------------------- | --------------------------- | -------------------------------- |
| Spec (source of truth) | `openapi/v1/headscale.yaml` | — |
| Generated server+client | `gen/api/v1/` | `apiv1` (imported as `oas`) |
| Handlers / state adapter | `hscontrol/api/v1/` | `apiv1` |
| HTTP-parity tests + harness | `hscontrol/servertest/` | `servertest` / `servertest_test` |
v1 and v2 must not import each other or share generated types — they converge
only on `hscontrol/state`. Codegen: `go generate ./gen/api/v1/` (also run by
`make generate` via `go generate ./...`); CI freshness via
`.github/workflows/check-generated.yml`. Never hand-edit generated code; fix the
spec, never reach for `x-ogen` without recording in CHANGES.md why no spec
change works.
## Architecture
- **Routing.** chi is the top mux. The ogen server (`http.Handler`) is mounted at `/api/v1/*` in `app.go` `createRouter`; chi leaves `r.URL.Path` intact so ogen's router matches the full path. ogen owns `/api/v1` wholesale: un-migrated operations inherit `oas.UnimplementedHandler` (501) until their unit lands. Parallel endpoint agents therefore touch only their handler file + tests — never `app.go`.
- **Auth.** Spec declares `bearerAuth` (http/bearer) global security. `Server.HandleBearerAuth``state.ValidateAPIKey`; any failure → 401. (Unix-socket auth bypass is added in the CLI phase, when the socket serves HTTP.)
- **Errors.** `Server.NewError` (ogen's convenient-error hook for security/decoding) and `errorHandler` (`WithErrorHandler`, for plain handler errors) both route through `classify` in `hscontrol/api/v1/errors.go`: typed `*oas.ErrorStatusCode` pass through; ogen framework errors use their `Code()` (security→401, decode→400); state errors via `mapStateError` (not-found sentinels→404, `ErrPolicyUpdateIsDisabled`→400, else 500). Handlers build expected errors with `apiError`/`notFound`/`badRequest`/`mapStateError`.
- **Side effects.** Handlers replicate the exact `state.*` call sequence and `change(...)` notifications from `hscontrol/grpcv1.go` (policy reload on SetPolicy, route auto-approval on RegisterNode, change broadcasts on node mutations, etc.). The adapter holds `state *state.State`, `cfg *types.Config`, and `change func(...change.Change)` (wired to `Headscale.Change`); it does not import the parent `hscontrol` package (no import cycle).
- **Transitional gRPC.** The gRPC unix-socket and TCP servers keep running through the endpoint phase so the existing CLI works; they (and proto/gateway code) are removed in cutover.
## Operation inventory (backbone) → state mapping
All paths keep the current `google.api.http` templates. Source of truth for
behaviour: `hscontrol/grpcv1.go`.
**User**`hscontrol/api/v1/users.go`
- `POST /api/v1/user` CreateUser → `state.CreateUser`; `change(policyChanged)`.
- `GET /api/v1/user` ListUsers(`id`,`name`,`email` query) → `ListUsersWithFilter` (by name|email|id) else `ListAllUsers`; sort by id.
- `POST /api/v1/user/{old_id}/rename/{new_name}` RenameUser → `GetUserByID`,`RenameUser`,`change(c)`,`GetUserByName`.
- `DELETE /api/v1/user/{id}` DeleteUser → `GetUserByID`,`DeleteUser`,`change`.
**PreAuthKey**`preauthkeys.go`
- `POST /api/v1/preauthkey` CreatePreAuthKey → validate each `aclTags` (`validateTag`), optional `GetUserByID`, `CreatePreAuthKey(userID, reusable, ephemeral, &expiration, aclTags)`.
- `GET /api/v1/preauthkey` ListPreAuthKeys → `ListPreAuthKeys`; sort by id.
- `POST /api/v1/preauthkey/expire` ExpirePreAuthKey → `ExpirePreAuthKey(id)`.
- `DELETE /api/v1/preauthkey` DeletePreAuthKey(`id` query) → `DeletePreAuthKey(id)`.
**Node**`nodes.go`
- `GET /api/v1/node` ListNodes(`user` query) → `GetUserByName`+`ListNodesByUser` | `ListNodes`; `nodesToProto` (tagged→`TaggedDevices` user; `subnetRoutes` = primary+exit routes); sort by id.
- `POST /api/v1/node/register` RegisterNode(`user`,`key` query) → `AuthIDFromString`,`GetUserByName`,`HandleNodeFromAuthPath(...,RegisterMethodCLI)`,`AutoApproveRoutes`,`change(nodeChange,routeChange)`.
- `POST /api/v1/node/backfillips` BackfillNodeIPs(`confirmed` query) → require confirmed else error; `BackfillNodeIPs`.
- `GET /api/v1/node/{node_id}` GetNode → `GetNodeByID`; not found → 404.
- `DELETE /api/v1/node/{node_id}` DeleteNode → `GetNodeByID`(404),`DeleteNode`,`change`.
- `POST /api/v1/node/{node_id}/tags` SetTags → non-empty tags else 400; `validateTag` each; `GetNodeByID`(404); `SetNodeTags`(invalid→400); `change`.
- `POST /api/v1/node/{node_id}/approve_routes` SetApprovedRoutes → parse prefixes; exit-route expansion (AllIPv4/AllIPv6 → add both); sort+compact; `SetApprovedRoutes`(invalid→400); `change`; set `subnetRoutes` = `GetNodePrimaryRoutes`.
- `POST /api/v1/node/{node_id}/expire` ExpireNode(`expiry`,`disable_expiry` query) → both set → 400; disable → `SetNodeExpiry(nil)`; else `SetNodeExpiry(&expiry)` (default now); `change`.
- `POST /api/v1/node/{node_id}/rename/{new_name}` RenameNode → `RenameNode`,`change`.
- `POST /api/v1/debug/node` DebugCreateNode → `GetUserByName`,`StringToIPPrefix`,`AuthIDFromString`,`SetAuthCacheEntry`; echo synthetic node.
**Auth**`auth.go`
- `POST /api/v1/auth/register` AuthRegister → delegates to RegisterNode(`key`=authId,`user`).
- `POST /api/v1/auth/approve` AuthApprove → `AuthIDFromString`(400),`GetAuthCacheEntry`(404),`FinishAuth({})`.
- `POST /api/v1/auth/reject` AuthReject → `AuthIDFromString`(400),`GetAuthCacheEntry`(404),`FinishAuth({Err})`.
**ApiKey**`apikeys.go`
- `POST /api/v1/apikey` CreateApiKey → `CreateAPIKey(&expiration)`; returns raw `apiKey` string.
- `GET /api/v1/apikey` ListApiKeys → `ListAPIKeys`; sort by id.
- `POST /api/v1/apikey/expire` ExpireApiKey → `getAPIKey` (id xor prefix; neither/both→400),`ExpireAPIKey`.
- `DELETE /api/v1/apikey/{prefix}` DeleteApiKey(`id` query) → `getAPIKey`,`DestroyAPIKey`.
**Policy**`policy.go`
- `GET /api/v1/policy` GetPolicy → DB mode: `GetPolicy`; file mode: read `cfg.Policy.Path`.
- `PUT /api/v1/policy` SetPolicy → DB mode only (`ErrPolicyUpdateIsDisabled`→400); `ListNodes`,`SetPolicy`,`SSHPolicy(node0)`,`SetPolicyInDB`,`ReloadPolicy`,`change(cs...)`.
- `POST /api/v1/policy/check` CheckPolicy → `ListAllUsers`,`ListNodes`,`NewPolicyManager`+`SetPolicy` (no persist; invalid→400).
**Health**`health.go` (done) — `PingDB`; ok→200, fail→500.
## Handler authoring recipe (for endpoint agents)
For each resource group, in this order, then `/commit` (Go style: `api: <imperative>`):
1. **Tests first.** Add `hscontrol/servertest/apiv1_<resource>_test.go` (package `servertest_test`). Use `srv := servertest.NewServer(t)`, `client := srv.APIClient(t, srv.CreateAPIKey(t))`, seed state via `srv.State()` / existing `servertest` helpers. Assert: success payloads (status, envelope, field values), every error path (404/400/401 with `*apiv1.ErrorStatusCode` status), and state side effects (re-read via `srv.State()`). Cover real behaviour, not just happy paths. Where the new contract intentionally differs from the old, the assertion encodes the new value **and** there is a matching CHANGES.md entry.
1. **Implement** the group's handler methods on `*apiv1.Server` in `hscontrol/api/v1/<resource>.go`, mirroring the `grpcv1.go` logic and side effects exactly. Convert between ogen types (`*XxxReq`, `XxxParams`, `*XxxOK`, `oas.User`/`oas.Node`/…) and state types. Build `oas.OptXxx` for optional fields; map state results to the response envelope. Errors: `notFound`/`badRequest`/`mapStateError`/`apiError`.
1. **CLI tests** for the group's commands belong to the CLI phase (`integration/`); reference them in the unit's acceptance.
1. **CHANGES.md**: add an entry for any deviation; if a test had to encode a non-preserved behaviour, it must correspond to an entry.
1. Run `goimports -w`, `go test ./hscontrol/servertest/ -run TestAPIv1_<Resource>`, then `make fmt`/`make lint` on touched files. Commit.
A reusable proto→ogen conversion (`oas.User` from `types.User`, etc.) should live in `hscontrol/api/v1/convert.go`; the first agent to need a converter adds it there and others reuse it. Do not duplicate.
## Work units & dependency map
```
F1 spec → F2 codegen+CI → F3 server/adapter/auth/errors → F4 harness [DONE]
┌────────────────────────────────────────────────────┐
E1 User E2 PreAuthKey E3 Node E4 Auth E5 ApiKey E6 Policy E7 Health(done)
└────────────────────────────────────────────────────┘ (parallel; touch only own handler+tests)
F2 → C0 CLI client adapter → C1..C8 per-command (each needs its E-unit) (parallel)
all E* + all C* → X1 flip/auth → X2 remove proto/gRPC/gateway → X3 docs+served spec → X4 reconcile
```
- **E1E7**: each owns its handler file + `apiv1_<resource>_test.go`. Acceptance: that group's HTTP-parity tests green; behaviour matches `grpcv1.go` modulo CHANGES.md.
- **C0**: rework `cmd/headscale/cli/utils.go` dial point to build `apiv1.Client` (local HTTP-over-unix, no key; remote HTTPS+`HEADSCALE_CLI_API_KEY`+`cli.insecure`). Serve ogen over the unix socket; add socket auth-bypass. **C1C8**: convert each `cmd/headscale/cli/*.go` off the gRPC client; gate with `integration/` CLI tests. Acceptance: no `v1.HeadscaleServiceClient` usage remains.
- **X1**: ogen is already the sole `/api/v1` handler; ensure no chi auth duplication, remove any dead gateway wiring. **X2**: delete `proto/headscale/v1`, `gen/go`, `gen/openapiv2`, `hscontrol/grpcv1.go`(+test), gRPC servers, `buf.*`; drop grpc/grpc-gateway deps (verify Noise is independent). **X3**: serve `openapi/v1/headscale.yaml` at `/swagger`; update `swagger.go` embed + `docs/`. **X4**: reconcile PLAN.md/CHANGES.md to the built state; DoD checklist green.
## Status
- [x] **F1** spec — `openapi/v1/headscale.yaml` (all 27 ops, native int64, RFC7807 `Problem`, bearerAuth). ogen generates cleanly.
- [x] **F2** codegen — `gen/api/v1/generate.go` (`go generate`), ogen tool dep in `go.mod`, CI freshness covers `openapi/**`.
- [x] **F3** server/adapter/auth/errors — `hscontrol/api/v1/{server,errors,auth,health}.go`; mounted at `/api/v1`; gateway unmounted; gRPC servers retained.
- [x] **F4** harness — `servertest.APIClient`/`CreateAPIKey`; Health parity + 401 tests green.
- [x] **E1E7** endpoints — all 27 operations implemented in `hscontrol/api/v1/{users,apikeys,preauthkeys,nodes,auth,policy,health}.go` with HTTP-parity tests in `hscontrol/servertest/apiv1_*_test.go` (30 tests, all green).
- [x] **C0C8** CLI migration — server serves the API over the unix socket (auth bypassed via `apiv1.WithSocketAuth`); every `cmd/headscale/cli/*.go` command runs on the generated `apiv1.Client`; no gRPC client usage remains.
- [x] **X1/X2** proto/gRPC removal — `grpcv1.go`, the TCP gRPC server + interceptor, the `.Proto()`/`RegisterMethodToV1Enum` builders, `proto/`, `gen/go/`, `gen/openapiv2/`, and `buf.gen.yaml` are deleted; `convert.go` reads the state views directly; `integration/` decodes into `apiv1` types. `grep` finds zero proto references in any `.go` file. grpc/grpc-gateway are no longer direct go.mod deps. Proto lint/format targets removed from the Makefile.
- [x] **X3** served spec — `/swagger` serves the embedded `openapi/v1/headscale.yaml` (3.0).
- [x] **X4** reconcile — this document and CHANGES.md reflect the built state.
The conversion is complete. The full `go test ./...` suite is validatable here for
everything except the Docker-based `integration/` package, which is
compile-clean (`go vet ./integration/...`) and ported to the new wire format; its
end-to-end run is gated on Docker (`go run ./cmd/hi`).
## Definition of done
Every v1 op served by ogen over the state layer; HTTP + CLI tests cover every
endpoint and pass; CLI on the generated client (no gRPC client); proto/gRPC +
gateway + generated code removed; generated code matches committed spec
(CI green); docs + `/swagger` point at the 3.0 spec; v1 self-contained, no
cross-version coupling; current behaviour preserved except deliberate,
test-matched, CHANGES.md-recorded deviations.
Generated
+78 -1
View File
@@ -1,5 +1,27 @@
{
"nodes": {
"flake-checks": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": [
"nixpkgs"
],
"treefmt-nix": "treefmt-nix"
},
"locked": {
"lastModified": 1781951072,
"narHash": "sha256-hA9u6hB4QzpReP8hudxqiaa4vLhoRvtXi6YmUgJRGEs=",
"owner": "kradalby",
"repo": "flake-checks",
"rev": "3d2882efec5cf10f8b5a8a035d93ba6ba9f21717",
"type": "github"
},
"original": {
"owner": "kradalby",
"repo": "flake-checks",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
@@ -18,6 +40,24 @@
"type": "github"
}
},
"flake-utils_2": {
"inputs": {
"systems": "systems_2"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1781153106,
@@ -36,7 +76,8 @@
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"flake-checks": "flake-checks",
"flake-utils": "flake-utils_2",
"nixpkgs": "nixpkgs"
}
},
@@ -54,6 +95,42 @@
"repo": "default",
"type": "github"
}
},
"systems_2": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"treefmt-nix": {
"inputs": {
"nixpkgs": [
"flake-checks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1780220602,
"narHash": "sha256-eynAfOmbmxJnkp7YewvCEbShNnnYJ9gLLqkzsYtBPeM=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "db947814a175b7ca6ded66e21383d938df01c227",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "treefmt-nix",
"type": "github"
}
}
},
"root": "root",
+67 -1
View File
@@ -9,12 +9,17 @@
# once it ships go_1_26 >= 1.26.4.
nixpkgs.url = "github:NixOS/nixpkgs/staging-next-26.05";
flake-utils.url = "github:numtide/flake-utils";
# Reusable Go flake checks (build/test/lint/format); CI runs them via
# `nix build .#checks.<system>.<name>` instead of bespoke per-tool steps.
flake-checks.url = "github:kradalby/flake-checks";
flake-checks.inputs.nixpkgs.follows = "nixpkgs";
};
outputs =
{ self
, nixpkgs
, flake-utils
, flake-checks
, ...
}:
let
@@ -152,6 +157,11 @@
yq-go
ripgrep
postgresql
# External clients exercised by the Tailscale-compatible v2 API
# roundtrip tests (TestAPIv2). Binaries: tofu, tscli.
opentofu
tscli
python314Packages.mdformat
python314Packages.mdformat-footnote
python314Packages.mdformat-frontmatter
@@ -176,6 +186,59 @@
contents = [ pkgs.headscale ];
config.Entrypoint = [ (pkgs.headscale + "/bin/headscale") ];
};
# Go flake checks from the flake-checks library. CI gates on
# `nix build .#checks.<system>.<name>`; the logic lives here, not in
# bespoke workflow steps. Linux-only: parts of the tree are
# Linux-specific and the pure unit subset is validated by CI.
fc = flake-checks.lib;
common = {
inherit pkgs;
root = ./.;
pname = "headscale";
version = headscaleVersion;
vendorHash = (builtins.fromJSON (builtins.readFile ./flakehashes.json)).vendor.sri;
goPkg = pkgs.go_1_26;
# //go:embed targets and test-read files outside the default whitelist.
embedDirs = [ ./hscontrol/assets ./hscontrol/db/schema.sql ./config-example.yaml ];
extraSrc = [
./hscontrol/testdata
./hscontrol/types/testdata
./hscontrol/db/testdata
./hscontrol/policy/v2/testdata
];
};
goChecks = {
build = fc.goBuild (common // { subPackages = [ "cmd/headscale" ]; });
# The pure unit subset. ./integration (Docker) and
# ./hscontrol/servertest (slow: 10s+ convergence plus race/stress/HA
# property tests — run by the servertest workflow instead) are dropped
# from the test set but kept in source so cmd/hi and friends still
# compile; TestPostgres* needs a server (the SQLite equivalents still
# run). CGO off matches the build.
gotest = fc.goTest (common // {
testExclude = [ "/integration" "/hscontrol/servertest" ];
goSkip = [ "TestPostgres" ];
testEnv = "export CGO_ENABLED=0";
});
# Full-tree golangci-lint (golines, gofumpt, etc.); uses the overlay's
# golangci-lint built against the pinned Go.
golangci-lint = fc.goLint common;
# nixpkgs-fmt + prettier, excluding generated output. goFmt = "off":
# Go formatting (golines, gofumpt) is enforced by the golangci-lint
# check, not treefmt. prettierExts matches the old prettier-lint glob
# (no json: testdata fixtures are hand-formatted).
formatting = fc.goFormat (common // {
goFmt = "off";
prettier = true;
prettierExts = [ "ts" "js" "md" "yaml" "yml" "sass" "css" "scss" "html" ];
# Mirror .prettierignore (docs/ are mkdocs-flavoured; gen/ generated).
fmtExclude = [ ./gen ./docs ];
});
};
in
{
# `nix develop`
@@ -221,6 +284,9 @@
checks = {
headscale = pkgs.testers.nixosTest (import ./nix/tests/headscale.nix);
};
}
# The Go build/test checks are gated to Linux: parts of the tree are
# Linux-specific and the pure unit subset is validated by CI.
// pkgs.lib.optionalAttrs pkgs.stdenv.isLinux goChecks;
});
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"vendor": {
"goModSum": "sha256-pE9YS5dCh93GWjRWlbDTmPLhvXbHIoHEKU/6qiN2ex4=",
"sri": "sha256-KRgUvdDEVfkkwcEU6mxGWyCGXn/IMw9ZBVQmVGQZUcY="
"goModSum": "sha256-SJml8RXGmb2p0g1nOsHn86FA1hwgd5ZffLSkUj5zek8=",
"sri": "sha256-pjGNuVtgFFzWNq/2cK7a4iyF13AfcHz098nk92a9Ido="
}
}
-8
View File
@@ -1,8 +0,0 @@
// Package apiv1 contains the ogen-generated server and client for the
// Headscale v1 HTTP API. The code is generated from the committed OpenAPI 3.0
// spec at openapi/v1/headscale.yaml — the single source of truth. Do not edit
// generated files by hand; run `make generate` (or `go generate ./...`) after
// changing the spec.
//
//go:generate go run github.com/ogen-go/ogen/cmd/ogen --target . --package apiv1 --clean ../../../openapi/v1/headscale.yaml
package apiv1
-318
View File
@@ -1,318 +0,0 @@
// Code generated by ogen, DO NOT EDIT.
package apiv1
import (
"net/http"
"strings"
ht "github.com/ogen-go/ogen/http"
"github.com/ogen-go/ogen/middleware"
"github.com/ogen-go/ogen/ogenerrors"
"github.com/ogen-go/ogen/otelogen"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/trace"
)
var (
// Allocate option closure once.
clientSpanKind = trace.WithSpanKind(trace.SpanKindClient)
// Allocate option closure once.
serverSpanKind = trace.WithSpanKind(trace.SpanKindServer)
)
type (
optionFunc[C any] func(*C)
otelOptionFunc func(*otelConfig)
)
type otelConfig struct {
TracerProvider trace.TracerProvider
Tracer trace.Tracer
MeterProvider metric.MeterProvider
Meter metric.Meter
Attributes []attribute.KeyValue
}
func (cfg *otelConfig) initOTEL() {
if cfg.TracerProvider == nil {
cfg.TracerProvider = otel.GetTracerProvider()
}
if cfg.MeterProvider == nil {
cfg.MeterProvider = otel.GetMeterProvider()
}
cfg.Tracer = cfg.TracerProvider.Tracer(otelogen.Name,
trace.WithInstrumentationVersion(otelogen.SemVersion()),
)
cfg.Meter = cfg.MeterProvider.Meter(otelogen.Name,
metric.WithInstrumentationVersion(otelogen.SemVersion()),
)
}
// ErrorHandler is error handler.
type ErrorHandler = ogenerrors.ErrorHandler
type serverConfig struct {
otelConfig
NotFound http.HandlerFunc
MethodNotAllowed func(w http.ResponseWriter, r *http.Request, allowed string)
ErrorHandler ErrorHandler
Prefix string
Middleware Middleware
MaxMultipartMemory int64
}
// ServerOption is server config option.
type ServerOption interface {
applyServer(*serverConfig)
}
var _ ServerOption = (optionFunc[serverConfig])(nil)
func (o optionFunc[C]) applyServer(c *C) {
o(c)
}
var _ ServerOption = (otelOptionFunc)(nil)
func (o otelOptionFunc) applyServer(c *serverConfig) {
o(&c.otelConfig)
}
func newServerConfig(opts ...ServerOption) serverConfig {
cfg := serverConfig{
NotFound: http.NotFound,
MethodNotAllowed: nil,
ErrorHandler: ogenerrors.DefaultErrorHandler,
Middleware: nil,
MaxMultipartMemory: 32 << 20, // 32 MB
}
for _, opt := range opts {
opt.applyServer(&cfg)
}
cfg.initOTEL()
return cfg
}
type baseServer struct {
cfg serverConfig
requests metric.Int64Counter
errors metric.Int64Counter
duration metric.Float64Histogram
}
func (s baseServer) notFound(w http.ResponseWriter, r *http.Request) {
s.cfg.NotFound(w, r)
}
type notAllowedParams struct {
allowedMethods string
allowedHeaders map[string]string
acceptPost string
acceptPatch string
}
func (s baseServer) notAllowed(w http.ResponseWriter, r *http.Request, params notAllowedParams) {
h := w.Header()
isOptions := r.Method == "OPTIONS"
if isOptions {
h.Set("Access-Control-Allow-Methods", params.allowedMethods)
if params.allowedHeaders != nil {
m := r.Header.Get("Access-Control-Request-Method")
if m != "" {
allowedHeaders, ok := params.allowedHeaders[strings.ToUpper(m)]
if ok {
h.Set("Access-Control-Allow-Headers", allowedHeaders)
}
}
}
if params.acceptPost != "" {
h.Set("Accept-Post", params.acceptPost)
}
if params.acceptPatch != "" {
h.Set("Accept-Patch", params.acceptPatch)
}
}
if s.cfg.MethodNotAllowed != nil {
s.cfg.MethodNotAllowed(w, r, params.allowedMethods)
return
}
status := http.StatusNoContent
if !isOptions {
h.Set("Allow", params.allowedMethods)
status = http.StatusMethodNotAllowed
}
w.WriteHeader(status)
}
func (cfg serverConfig) baseServer() (s baseServer, err error) {
s = baseServer{cfg: cfg}
if s.requests, err = otelogen.ServerRequestCountCounter(s.cfg.Meter); err != nil {
return s, err
}
if s.errors, err = otelogen.ServerErrorsCountCounter(s.cfg.Meter); err != nil {
return s, err
}
if s.duration, err = otelogen.ServerDurationHistogram(s.cfg.Meter); err != nil {
return s, err
}
return s, nil
}
type clientConfig struct {
otelConfig
Client ht.Client
}
// ClientOption is client config option.
type ClientOption interface {
applyClient(*clientConfig)
}
var _ ClientOption = (optionFunc[clientConfig])(nil)
func (o optionFunc[C]) applyClient(c *C) {
o(c)
}
var _ ClientOption = (otelOptionFunc)(nil)
func (o otelOptionFunc) applyClient(c *clientConfig) {
o(&c.otelConfig)
}
func newClientConfig(opts ...ClientOption) clientConfig {
cfg := clientConfig{
Client: http.DefaultClient,
}
for _, opt := range opts {
opt.applyClient(&cfg)
}
cfg.initOTEL()
return cfg
}
type baseClient struct {
cfg clientConfig
requests metric.Int64Counter
errors metric.Int64Counter
duration metric.Float64Histogram
}
func (cfg clientConfig) baseClient() (c baseClient, err error) {
c = baseClient{cfg: cfg}
if c.requests, err = otelogen.ClientRequestCountCounter(c.cfg.Meter); err != nil {
return c, err
}
if c.errors, err = otelogen.ClientErrorsCountCounter(c.cfg.Meter); err != nil {
return c, err
}
if c.duration, err = otelogen.ClientDurationHistogram(c.cfg.Meter); err != nil {
return c, err
}
return c, nil
}
// Option is config option.
type Option interface {
ServerOption
ClientOption
}
// WithTracerProvider specifies a tracer provider to use for creating a tracer.
//
// If none is specified, the global provider is used.
func WithTracerProvider(provider trace.TracerProvider) Option {
return otelOptionFunc(func(cfg *otelConfig) {
if provider != nil {
cfg.TracerProvider = provider
}
})
}
// WithMeterProvider specifies a meter provider to use for creating a meter.
//
// If none is specified, the otel.GetMeterProvider() is used.
func WithMeterProvider(provider metric.MeterProvider) Option {
return otelOptionFunc(func(cfg *otelConfig) {
if provider != nil {
cfg.MeterProvider = provider
}
})
}
// WithAttributes specifies default otel attributes.
func WithAttributes(attributes ...attribute.KeyValue) Option {
return otelOptionFunc(func(cfg *otelConfig) {
cfg.Attributes = attributes
})
}
// WithClient specifies http client to use.
func WithClient(client ht.Client) ClientOption {
return optionFunc[clientConfig](func(cfg *clientConfig) {
if client != nil {
cfg.Client = client
}
})
}
// WithNotFound specifies Not Found handler to use.
func WithNotFound(notFound http.HandlerFunc) ServerOption {
return optionFunc[serverConfig](func(cfg *serverConfig) {
if notFound != nil {
cfg.NotFound = notFound
}
})
}
// WithMethodNotAllowed specifies Method Not Allowed handler to use.
func WithMethodNotAllowed(methodNotAllowed func(w http.ResponseWriter, r *http.Request, allowed string)) ServerOption {
return optionFunc[serverConfig](func(cfg *serverConfig) {
if methodNotAllowed != nil {
cfg.MethodNotAllowed = methodNotAllowed
}
})
}
// WithErrorHandler specifies error handler to use.
func WithErrorHandler(h ErrorHandler) ServerOption {
return optionFunc[serverConfig](func(cfg *serverConfig) {
if h != nil {
cfg.ErrorHandler = h
}
})
}
// WithPathPrefix specifies server path prefix.
func WithPathPrefix(prefix string) ServerOption {
return optionFunc[serverConfig](func(cfg *serverConfig) {
cfg.Prefix = prefix
})
}
// WithMiddleware specifies middlewares to use.
func WithMiddleware(m ...Middleware) ServerOption {
return optionFunc[serverConfig](func(cfg *serverConfig) {
switch len(m) {
case 0:
cfg.Middleware = nil
case 1:
cfg.Middleware = m[0]
default:
cfg.Middleware = middleware.ChainMiddlewares(m...)
}
})
}
// WithMaxMultipartMemory specifies limit of memory for storing file parts.
// File parts which can't be stored in memory will be stored on disk in temporary files.
func WithMaxMultipartMemory(max int64) ServerOption {
return optionFunc[serverConfig](func(cfg *serverConfig) {
if max > 0 {
cfg.MaxMultipartMemory = max
}
})
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-42
View File
@@ -1,42 +0,0 @@
// Code generated by ogen, DO NOT EDIT.
package apiv1
import (
"context"
"go.opentelemetry.io/otel/attribute"
)
// Labeler is used to allow adding custom attributes to the server request metrics.
type Labeler struct {
attrs []attribute.KeyValue
}
// Add attributes to the Labeler.
func (l *Labeler) Add(attrs ...attribute.KeyValue) {
l.attrs = append(l.attrs, attrs...)
}
// AttributeSet returns the attributes added to the Labeler as an attribute.Set.
func (l *Labeler) AttributeSet() attribute.Set {
return attribute.NewSet(l.attrs...)
}
type labelerContextKey struct{}
// LabelerFromContext retrieves the Labeler from the provided context, if present.
//
// If no Labeler was found in the provided context a new, empty Labeler is returned and the second
// return value is false. In this case it is safe to use the Labeler but any attributes added to
// it will not be used.
func LabelerFromContext(ctx context.Context) (*Labeler, bool) {
if l, ok := ctx.Value(labelerContextKey{}).(*Labeler); ok {
return l, true
}
return &Labeler{}, false
}
func contextWithLabeler(ctx context.Context, l *Labeler) context.Context {
return context.WithValue(ctx, labelerContextKey{}, l)
}
-10
View File
@@ -1,10 +0,0 @@
// Code generated by ogen, DO NOT EDIT.
package apiv1
import (
"github.com/ogen-go/ogen/middleware"
)
// Middleware is middleware type.
type Middleware = middleware.Middleware
-38
View File
@@ -1,38 +0,0 @@
// Code generated by ogen, DO NOT EDIT.
package apiv1
// OperationName is the ogen operation name
type OperationName = string
const (
AuthApproveOperation OperationName = "AuthApprove"
AuthRegisterOperation OperationName = "AuthRegister"
AuthRejectOperation OperationName = "AuthReject"
BackfillNodeIPsOperation OperationName = "BackfillNodeIPs"
CheckPolicyOperation OperationName = "CheckPolicy"
CreateApiKeyOperation OperationName = "CreateApiKey"
CreatePreAuthKeyOperation OperationName = "CreatePreAuthKey"
CreateUserOperation OperationName = "CreateUser"
DebugCreateNodeOperation OperationName = "DebugCreateNode"
DeleteApiKeyOperation OperationName = "DeleteApiKey"
DeleteNodeOperation OperationName = "DeleteNode"
DeletePreAuthKeyOperation OperationName = "DeletePreAuthKey"
DeleteUserOperation OperationName = "DeleteUser"
ExpireApiKeyOperation OperationName = "ExpireApiKey"
ExpireNodeOperation OperationName = "ExpireNode"
ExpirePreAuthKeyOperation OperationName = "ExpirePreAuthKey"
GetNodeOperation OperationName = "GetNode"
GetPolicyOperation OperationName = "GetPolicy"
HealthOperation OperationName = "Health"
ListApiKeysOperation OperationName = "ListApiKeys"
ListNodesOperation OperationName = "ListNodes"
ListPreAuthKeysOperation OperationName = "ListPreAuthKeys"
ListUsersOperation OperationName = "ListUsers"
RegisterNodeOperation OperationName = "RegisterNode"
RenameNodeOperation OperationName = "RenameNode"
RenameUserOperation OperationName = "RenameUser"
SetApprovedRoutesOperation OperationName = "SetApprovedRoutes"
SetPolicyOperation OperationName = "SetPolicy"
SetTagsOperation OperationName = "SetTags"
)
File diff suppressed because it is too large Load Diff
-938
View File
@@ -1,938 +0,0 @@
// Code generated by ogen, DO NOT EDIT.
package apiv1
import (
"bytes"
"io"
"mime"
"net/http"
"github.com/go-faster/errors"
"github.com/go-faster/jx"
"github.com/ogen-go/ogen/ogenerrors"
"github.com/ogen-go/ogen/validate"
)
func (s *Server) decodeAuthApproveRequest(r *http.Request) (
req *AuthApproveReq,
rawBody []byte,
close func() error,
rerr error,
) {
var closers []func() error
close = func() error {
var merr error
// Close in reverse order, to match defer behavior.
for i := len(closers) - 1; i >= 0; i-- {
c := closers[i]
merr = errors.Join(merr, c())
}
return merr
}
defer func() {
if rerr != nil {
rerr = errors.Join(rerr, close())
}
}()
ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil {
return req, rawBody, close, errors.Wrap(err, "parse media type")
}
switch {
case ct == "application/json":
if r.ContentLength == 0 {
return req, rawBody, close, validate.ErrBodyRequired
}
buf, err := io.ReadAll(r.Body)
defer func() {
_ = r.Body.Close()
}()
if err != nil {
return req, rawBody, close, err
}
// Reset the body to allow for downstream reading.
r.Body = io.NopCloser(bytes.NewBuffer(buf))
if len(buf) == 0 {
return req, rawBody, close, validate.ErrBodyRequired
}
rawBody = append(rawBody, buf...)
d := jx.DecodeBytes(buf)
var request AuthApproveReq
if err := func() error {
if err := request.Decode(d); err != nil {
return err
}
if err := d.Skip(); err != io.EOF {
return errors.New("unexpected trailing data")
}
return nil
}(); err != nil {
err = &ogenerrors.DecodeBodyError{
ContentType: ct,
Body: buf,
Err: err,
}
return req, rawBody, close, err
}
return &request, rawBody, close, nil
default:
return req, rawBody, close, validate.InvalidContentType(ct)
}
}
func (s *Server) decodeAuthRegisterRequest(r *http.Request) (
req *AuthRegisterReq,
rawBody []byte,
close func() error,
rerr error,
) {
var closers []func() error
close = func() error {
var merr error
// Close in reverse order, to match defer behavior.
for i := len(closers) - 1; i >= 0; i-- {
c := closers[i]
merr = errors.Join(merr, c())
}
return merr
}
defer func() {
if rerr != nil {
rerr = errors.Join(rerr, close())
}
}()
ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil {
return req, rawBody, close, errors.Wrap(err, "parse media type")
}
switch {
case ct == "application/json":
if r.ContentLength == 0 {
return req, rawBody, close, validate.ErrBodyRequired
}
buf, err := io.ReadAll(r.Body)
defer func() {
_ = r.Body.Close()
}()
if err != nil {
return req, rawBody, close, err
}
// Reset the body to allow for downstream reading.
r.Body = io.NopCloser(bytes.NewBuffer(buf))
if len(buf) == 0 {
return req, rawBody, close, validate.ErrBodyRequired
}
rawBody = append(rawBody, buf...)
d := jx.DecodeBytes(buf)
var request AuthRegisterReq
if err := func() error {
if err := request.Decode(d); err != nil {
return err
}
if err := d.Skip(); err != io.EOF {
return errors.New("unexpected trailing data")
}
return nil
}(); err != nil {
err = &ogenerrors.DecodeBodyError{
ContentType: ct,
Body: buf,
Err: err,
}
return req, rawBody, close, err
}
return &request, rawBody, close, nil
default:
return req, rawBody, close, validate.InvalidContentType(ct)
}
}
func (s *Server) decodeAuthRejectRequest(r *http.Request) (
req *AuthRejectReq,
rawBody []byte,
close func() error,
rerr error,
) {
var closers []func() error
close = func() error {
var merr error
// Close in reverse order, to match defer behavior.
for i := len(closers) - 1; i >= 0; i-- {
c := closers[i]
merr = errors.Join(merr, c())
}
return merr
}
defer func() {
if rerr != nil {
rerr = errors.Join(rerr, close())
}
}()
ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil {
return req, rawBody, close, errors.Wrap(err, "parse media type")
}
switch {
case ct == "application/json":
if r.ContentLength == 0 {
return req, rawBody, close, validate.ErrBodyRequired
}
buf, err := io.ReadAll(r.Body)
defer func() {
_ = r.Body.Close()
}()
if err != nil {
return req, rawBody, close, err
}
// Reset the body to allow for downstream reading.
r.Body = io.NopCloser(bytes.NewBuffer(buf))
if len(buf) == 0 {
return req, rawBody, close, validate.ErrBodyRequired
}
rawBody = append(rawBody, buf...)
d := jx.DecodeBytes(buf)
var request AuthRejectReq
if err := func() error {
if err := request.Decode(d); err != nil {
return err
}
if err := d.Skip(); err != io.EOF {
return errors.New("unexpected trailing data")
}
return nil
}(); err != nil {
err = &ogenerrors.DecodeBodyError{
ContentType: ct,
Body: buf,
Err: err,
}
return req, rawBody, close, err
}
return &request, rawBody, close, nil
default:
return req, rawBody, close, validate.InvalidContentType(ct)
}
}
func (s *Server) decodeCheckPolicyRequest(r *http.Request) (
req *CheckPolicyReq,
rawBody []byte,
close func() error,
rerr error,
) {
var closers []func() error
close = func() error {
var merr error
// Close in reverse order, to match defer behavior.
for i := len(closers) - 1; i >= 0; i-- {
c := closers[i]
merr = errors.Join(merr, c())
}
return merr
}
defer func() {
if rerr != nil {
rerr = errors.Join(rerr, close())
}
}()
ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil {
return req, rawBody, close, errors.Wrap(err, "parse media type")
}
switch {
case ct == "application/json":
if r.ContentLength == 0 {
return req, rawBody, close, validate.ErrBodyRequired
}
buf, err := io.ReadAll(r.Body)
defer func() {
_ = r.Body.Close()
}()
if err != nil {
return req, rawBody, close, err
}
// Reset the body to allow for downstream reading.
r.Body = io.NopCloser(bytes.NewBuffer(buf))
if len(buf) == 0 {
return req, rawBody, close, validate.ErrBodyRequired
}
rawBody = append(rawBody, buf...)
d := jx.DecodeBytes(buf)
var request CheckPolicyReq
if err := func() error {
if err := request.Decode(d); err != nil {
return err
}
if err := d.Skip(); err != io.EOF {
return errors.New("unexpected trailing data")
}
return nil
}(); err != nil {
err = &ogenerrors.DecodeBodyError{
ContentType: ct,
Body: buf,
Err: err,
}
return req, rawBody, close, err
}
return &request, rawBody, close, nil
default:
return req, rawBody, close, validate.InvalidContentType(ct)
}
}
func (s *Server) decodeCreateApiKeyRequest(r *http.Request) (
req *CreateApiKeyReq,
rawBody []byte,
close func() error,
rerr error,
) {
var closers []func() error
close = func() error {
var merr error
// Close in reverse order, to match defer behavior.
for i := len(closers) - 1; i >= 0; i-- {
c := closers[i]
merr = errors.Join(merr, c())
}
return merr
}
defer func() {
if rerr != nil {
rerr = errors.Join(rerr, close())
}
}()
ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil {
return req, rawBody, close, errors.Wrap(err, "parse media type")
}
switch {
case ct == "application/json":
if r.ContentLength == 0 {
return req, rawBody, close, validate.ErrBodyRequired
}
buf, err := io.ReadAll(r.Body)
defer func() {
_ = r.Body.Close()
}()
if err != nil {
return req, rawBody, close, err
}
// Reset the body to allow for downstream reading.
r.Body = io.NopCloser(bytes.NewBuffer(buf))
if len(buf) == 0 {
return req, rawBody, close, validate.ErrBodyRequired
}
rawBody = append(rawBody, buf...)
d := jx.DecodeBytes(buf)
var request CreateApiKeyReq
if err := func() error {
if err := request.Decode(d); err != nil {
return err
}
if err := d.Skip(); err != io.EOF {
return errors.New("unexpected trailing data")
}
return nil
}(); err != nil {
err = &ogenerrors.DecodeBodyError{
ContentType: ct,
Body: buf,
Err: err,
}
return req, rawBody, close, err
}
return &request, rawBody, close, nil
default:
return req, rawBody, close, validate.InvalidContentType(ct)
}
}
func (s *Server) decodeCreatePreAuthKeyRequest(r *http.Request) (
req *CreatePreAuthKeyReq,
rawBody []byte,
close func() error,
rerr error,
) {
var closers []func() error
close = func() error {
var merr error
// Close in reverse order, to match defer behavior.
for i := len(closers) - 1; i >= 0; i-- {
c := closers[i]
merr = errors.Join(merr, c())
}
return merr
}
defer func() {
if rerr != nil {
rerr = errors.Join(rerr, close())
}
}()
ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil {
return req, rawBody, close, errors.Wrap(err, "parse media type")
}
switch {
case ct == "application/json":
if r.ContentLength == 0 {
return req, rawBody, close, validate.ErrBodyRequired
}
buf, err := io.ReadAll(r.Body)
defer func() {
_ = r.Body.Close()
}()
if err != nil {
return req, rawBody, close, err
}
// Reset the body to allow for downstream reading.
r.Body = io.NopCloser(bytes.NewBuffer(buf))
if len(buf) == 0 {
return req, rawBody, close, validate.ErrBodyRequired
}
rawBody = append(rawBody, buf...)
d := jx.DecodeBytes(buf)
var request CreatePreAuthKeyReq
if err := func() error {
if err := request.Decode(d); err != nil {
return err
}
if err := d.Skip(); err != io.EOF {
return errors.New("unexpected trailing data")
}
return nil
}(); err != nil {
err = &ogenerrors.DecodeBodyError{
ContentType: ct,
Body: buf,
Err: err,
}
return req, rawBody, close, err
}
return &request, rawBody, close, nil
default:
return req, rawBody, close, validate.InvalidContentType(ct)
}
}
func (s *Server) decodeCreateUserRequest(r *http.Request) (
req *CreateUserReq,
rawBody []byte,
close func() error,
rerr error,
) {
var closers []func() error
close = func() error {
var merr error
// Close in reverse order, to match defer behavior.
for i := len(closers) - 1; i >= 0; i-- {
c := closers[i]
merr = errors.Join(merr, c())
}
return merr
}
defer func() {
if rerr != nil {
rerr = errors.Join(rerr, close())
}
}()
ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil {
return req, rawBody, close, errors.Wrap(err, "parse media type")
}
switch {
case ct == "application/json":
if r.ContentLength == 0 {
return req, rawBody, close, validate.ErrBodyRequired
}
buf, err := io.ReadAll(r.Body)
defer func() {
_ = r.Body.Close()
}()
if err != nil {
return req, rawBody, close, err
}
// Reset the body to allow for downstream reading.
r.Body = io.NopCloser(bytes.NewBuffer(buf))
if len(buf) == 0 {
return req, rawBody, close, validate.ErrBodyRequired
}
rawBody = append(rawBody, buf...)
d := jx.DecodeBytes(buf)
var request CreateUserReq
if err := func() error {
if err := request.Decode(d); err != nil {
return err
}
if err := d.Skip(); err != io.EOF {
return errors.New("unexpected trailing data")
}
return nil
}(); err != nil {
err = &ogenerrors.DecodeBodyError{
ContentType: ct,
Body: buf,
Err: err,
}
return req, rawBody, close, err
}
return &request, rawBody, close, nil
default:
return req, rawBody, close, validate.InvalidContentType(ct)
}
}
func (s *Server) decodeDebugCreateNodeRequest(r *http.Request) (
req *DebugCreateNodeReq,
rawBody []byte,
close func() error,
rerr error,
) {
var closers []func() error
close = func() error {
var merr error
// Close in reverse order, to match defer behavior.
for i := len(closers) - 1; i >= 0; i-- {
c := closers[i]
merr = errors.Join(merr, c())
}
return merr
}
defer func() {
if rerr != nil {
rerr = errors.Join(rerr, close())
}
}()
ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil {
return req, rawBody, close, errors.Wrap(err, "parse media type")
}
switch {
case ct == "application/json":
if r.ContentLength == 0 {
return req, rawBody, close, validate.ErrBodyRequired
}
buf, err := io.ReadAll(r.Body)
defer func() {
_ = r.Body.Close()
}()
if err != nil {
return req, rawBody, close, err
}
// Reset the body to allow for downstream reading.
r.Body = io.NopCloser(bytes.NewBuffer(buf))
if len(buf) == 0 {
return req, rawBody, close, validate.ErrBodyRequired
}
rawBody = append(rawBody, buf...)
d := jx.DecodeBytes(buf)
var request DebugCreateNodeReq
if err := func() error {
if err := request.Decode(d); err != nil {
return err
}
if err := d.Skip(); err != io.EOF {
return errors.New("unexpected trailing data")
}
return nil
}(); err != nil {
err = &ogenerrors.DecodeBodyError{
ContentType: ct,
Body: buf,
Err: err,
}
return req, rawBody, close, err
}
return &request, rawBody, close, nil
default:
return req, rawBody, close, validate.InvalidContentType(ct)
}
}
func (s *Server) decodeExpireApiKeyRequest(r *http.Request) (
req *ExpireApiKeyReq,
rawBody []byte,
close func() error,
rerr error,
) {
var closers []func() error
close = func() error {
var merr error
// Close in reverse order, to match defer behavior.
for i := len(closers) - 1; i >= 0; i-- {
c := closers[i]
merr = errors.Join(merr, c())
}
return merr
}
defer func() {
if rerr != nil {
rerr = errors.Join(rerr, close())
}
}()
ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil {
return req, rawBody, close, errors.Wrap(err, "parse media type")
}
switch {
case ct == "application/json":
if r.ContentLength == 0 {
return req, rawBody, close, validate.ErrBodyRequired
}
buf, err := io.ReadAll(r.Body)
defer func() {
_ = r.Body.Close()
}()
if err != nil {
return req, rawBody, close, err
}
// Reset the body to allow for downstream reading.
r.Body = io.NopCloser(bytes.NewBuffer(buf))
if len(buf) == 0 {
return req, rawBody, close, validate.ErrBodyRequired
}
rawBody = append(rawBody, buf...)
d := jx.DecodeBytes(buf)
var request ExpireApiKeyReq
if err := func() error {
if err := request.Decode(d); err != nil {
return err
}
if err := d.Skip(); err != io.EOF {
return errors.New("unexpected trailing data")
}
return nil
}(); err != nil {
err = &ogenerrors.DecodeBodyError{
ContentType: ct,
Body: buf,
Err: err,
}
return req, rawBody, close, err
}
return &request, rawBody, close, nil
default:
return req, rawBody, close, validate.InvalidContentType(ct)
}
}
func (s *Server) decodeExpirePreAuthKeyRequest(r *http.Request) (
req *ExpirePreAuthKeyReq,
rawBody []byte,
close func() error,
rerr error,
) {
var closers []func() error
close = func() error {
var merr error
// Close in reverse order, to match defer behavior.
for i := len(closers) - 1; i >= 0; i-- {
c := closers[i]
merr = errors.Join(merr, c())
}
return merr
}
defer func() {
if rerr != nil {
rerr = errors.Join(rerr, close())
}
}()
ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil {
return req, rawBody, close, errors.Wrap(err, "parse media type")
}
switch {
case ct == "application/json":
if r.ContentLength == 0 {
return req, rawBody, close, validate.ErrBodyRequired
}
buf, err := io.ReadAll(r.Body)
defer func() {
_ = r.Body.Close()
}()
if err != nil {
return req, rawBody, close, err
}
// Reset the body to allow for downstream reading.
r.Body = io.NopCloser(bytes.NewBuffer(buf))
if len(buf) == 0 {
return req, rawBody, close, validate.ErrBodyRequired
}
rawBody = append(rawBody, buf...)
d := jx.DecodeBytes(buf)
var request ExpirePreAuthKeyReq
if err := func() error {
if err := request.Decode(d); err != nil {
return err
}
if err := d.Skip(); err != io.EOF {
return errors.New("unexpected trailing data")
}
return nil
}(); err != nil {
err = &ogenerrors.DecodeBodyError{
ContentType: ct,
Body: buf,
Err: err,
}
return req, rawBody, close, err
}
return &request, rawBody, close, nil
default:
return req, rawBody, close, validate.InvalidContentType(ct)
}
}
func (s *Server) decodeSetApprovedRoutesRequest(r *http.Request) (
req *SetApprovedRoutesReq,
rawBody []byte,
close func() error,
rerr error,
) {
var closers []func() error
close = func() error {
var merr error
// Close in reverse order, to match defer behavior.
for i := len(closers) - 1; i >= 0; i-- {
c := closers[i]
merr = errors.Join(merr, c())
}
return merr
}
defer func() {
if rerr != nil {
rerr = errors.Join(rerr, close())
}
}()
ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil {
return req, rawBody, close, errors.Wrap(err, "parse media type")
}
switch {
case ct == "application/json":
if r.ContentLength == 0 {
return req, rawBody, close, validate.ErrBodyRequired
}
buf, err := io.ReadAll(r.Body)
defer func() {
_ = r.Body.Close()
}()
if err != nil {
return req, rawBody, close, err
}
// Reset the body to allow for downstream reading.
r.Body = io.NopCloser(bytes.NewBuffer(buf))
if len(buf) == 0 {
return req, rawBody, close, validate.ErrBodyRequired
}
rawBody = append(rawBody, buf...)
d := jx.DecodeBytes(buf)
var request SetApprovedRoutesReq
if err := func() error {
if err := request.Decode(d); err != nil {
return err
}
if err := d.Skip(); err != io.EOF {
return errors.New("unexpected trailing data")
}
return nil
}(); err != nil {
err = &ogenerrors.DecodeBodyError{
ContentType: ct,
Body: buf,
Err: err,
}
return req, rawBody, close, err
}
return &request, rawBody, close, nil
default:
return req, rawBody, close, validate.InvalidContentType(ct)
}
}
func (s *Server) decodeSetPolicyRequest(r *http.Request) (
req *SetPolicyReq,
rawBody []byte,
close func() error,
rerr error,
) {
var closers []func() error
close = func() error {
var merr error
// Close in reverse order, to match defer behavior.
for i := len(closers) - 1; i >= 0; i-- {
c := closers[i]
merr = errors.Join(merr, c())
}
return merr
}
defer func() {
if rerr != nil {
rerr = errors.Join(rerr, close())
}
}()
ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil {
return req, rawBody, close, errors.Wrap(err, "parse media type")
}
switch {
case ct == "application/json":
if r.ContentLength == 0 {
return req, rawBody, close, validate.ErrBodyRequired
}
buf, err := io.ReadAll(r.Body)
defer func() {
_ = r.Body.Close()
}()
if err != nil {
return req, rawBody, close, err
}
// Reset the body to allow for downstream reading.
r.Body = io.NopCloser(bytes.NewBuffer(buf))
if len(buf) == 0 {
return req, rawBody, close, validate.ErrBodyRequired
}
rawBody = append(rawBody, buf...)
d := jx.DecodeBytes(buf)
var request SetPolicyReq
if err := func() error {
if err := request.Decode(d); err != nil {
return err
}
if err := d.Skip(); err != io.EOF {
return errors.New("unexpected trailing data")
}
return nil
}(); err != nil {
err = &ogenerrors.DecodeBodyError{
ContentType: ct,
Body: buf,
Err: err,
}
return req, rawBody, close, err
}
return &request, rawBody, close, nil
default:
return req, rawBody, close, validate.InvalidContentType(ct)
}
}
func (s *Server) decodeSetTagsRequest(r *http.Request) (
req *SetTagsReq,
rawBody []byte,
close func() error,
rerr error,
) {
var closers []func() error
close = func() error {
var merr error
// Close in reverse order, to match defer behavior.
for i := len(closers) - 1; i >= 0; i-- {
c := closers[i]
merr = errors.Join(merr, c())
}
return merr
}
defer func() {
if rerr != nil {
rerr = errors.Join(rerr, close())
}
}()
ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil {
return req, rawBody, close, errors.Wrap(err, "parse media type")
}
switch {
case ct == "application/json":
if r.ContentLength == 0 {
return req, rawBody, close, validate.ErrBodyRequired
}
buf, err := io.ReadAll(r.Body)
defer func() {
_ = r.Body.Close()
}()
if err != nil {
return req, rawBody, close, err
}
// Reset the body to allow for downstream reading.
r.Body = io.NopCloser(bytes.NewBuffer(buf))
if len(buf) == 0 {
return req, rawBody, close, validate.ErrBodyRequired
}
rawBody = append(rawBody, buf...)
d := jx.DecodeBytes(buf)
var request SetTagsReq
if err := func() error {
if err := request.Decode(d); err != nil {
return err
}
if err := d.Skip(); err != io.EOF {
return errors.New("unexpected trailing data")
}
return nil
}(); err != nil {
err = &ogenerrors.DecodeBodyError{
ContentType: ct,
Body: buf,
Err: err,
}
return req, rawBody, close, err
}
return &request, rawBody, close, nil
default:
return req, rawBody, close, validate.InvalidContentType(ct)
}
}
-193
View File
@@ -1,193 +0,0 @@
// Code generated by ogen, DO NOT EDIT.
package apiv1
import (
"bytes"
"net/http"
"github.com/go-faster/jx"
ht "github.com/ogen-go/ogen/http"
)
func encodeAuthApproveRequest(
req *AuthApproveReq,
r *http.Request,
) error {
const contentType = "application/json"
e := new(jx.Encoder)
{
req.Encode(e)
}
encoded := e.Bytes()
ht.SetBody(r, bytes.NewReader(encoded), contentType)
return nil
}
func encodeAuthRegisterRequest(
req *AuthRegisterReq,
r *http.Request,
) error {
const contentType = "application/json"
e := new(jx.Encoder)
{
req.Encode(e)
}
encoded := e.Bytes()
ht.SetBody(r, bytes.NewReader(encoded), contentType)
return nil
}
func encodeAuthRejectRequest(
req *AuthRejectReq,
r *http.Request,
) error {
const contentType = "application/json"
e := new(jx.Encoder)
{
req.Encode(e)
}
encoded := e.Bytes()
ht.SetBody(r, bytes.NewReader(encoded), contentType)
return nil
}
func encodeCheckPolicyRequest(
req *CheckPolicyReq,
r *http.Request,
) error {
const contentType = "application/json"
e := new(jx.Encoder)
{
req.Encode(e)
}
encoded := e.Bytes()
ht.SetBody(r, bytes.NewReader(encoded), contentType)
return nil
}
func encodeCreateApiKeyRequest(
req *CreateApiKeyReq,
r *http.Request,
) error {
const contentType = "application/json"
e := new(jx.Encoder)
{
req.Encode(e)
}
encoded := e.Bytes()
ht.SetBody(r, bytes.NewReader(encoded), contentType)
return nil
}
func encodeCreatePreAuthKeyRequest(
req *CreatePreAuthKeyReq,
r *http.Request,
) error {
const contentType = "application/json"
e := new(jx.Encoder)
{
req.Encode(e)
}
encoded := e.Bytes()
ht.SetBody(r, bytes.NewReader(encoded), contentType)
return nil
}
func encodeCreateUserRequest(
req *CreateUserReq,
r *http.Request,
) error {
const contentType = "application/json"
e := new(jx.Encoder)
{
req.Encode(e)
}
encoded := e.Bytes()
ht.SetBody(r, bytes.NewReader(encoded), contentType)
return nil
}
func encodeDebugCreateNodeRequest(
req *DebugCreateNodeReq,
r *http.Request,
) error {
const contentType = "application/json"
e := new(jx.Encoder)
{
req.Encode(e)
}
encoded := e.Bytes()
ht.SetBody(r, bytes.NewReader(encoded), contentType)
return nil
}
func encodeExpireApiKeyRequest(
req *ExpireApiKeyReq,
r *http.Request,
) error {
const contentType = "application/json"
e := new(jx.Encoder)
{
req.Encode(e)
}
encoded := e.Bytes()
ht.SetBody(r, bytes.NewReader(encoded), contentType)
return nil
}
func encodeExpirePreAuthKeyRequest(
req *ExpirePreAuthKeyReq,
r *http.Request,
) error {
const contentType = "application/json"
e := new(jx.Encoder)
{
req.Encode(e)
}
encoded := e.Bytes()
ht.SetBody(r, bytes.NewReader(encoded), contentType)
return nil
}
func encodeSetApprovedRoutesRequest(
req *SetApprovedRoutesReq,
r *http.Request,
) error {
const contentType = "application/json"
e := new(jx.Encoder)
{
req.Encode(e)
}
encoded := e.Bytes()
ht.SetBody(r, bytes.NewReader(encoded), contentType)
return nil
}
func encodeSetPolicyRequest(
req *SetPolicyReq,
r *http.Request,
) error {
const contentType = "application/json"
e := new(jx.Encoder)
{
req.Encode(e)
}
encoded := e.Bytes()
ht.SetBody(r, bytes.NewReader(encoded), contentType)
return nil
}
func encodeSetTagsRequest(
req *SetTagsReq,
r *http.Request,
) error {
const contentType = "application/json"
e := new(jx.Encoder)
{
req.Encode(e)
}
encoded := e.Bytes()
ht.SetBody(r, bytes.NewReader(encoded), contentType)
return nil
}
File diff suppressed because it is too large Load Diff
-415
View File
@@ -1,415 +0,0 @@
// Code generated by ogen, DO NOT EDIT.
package apiv1
import (
"net/http"
"github.com/go-faster/errors"
"github.com/go-faster/jx"
ht "github.com/ogen-go/ogen/http"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
)
func encodeAuthApproveResponse(response *AuthApproveOK, w http.ResponseWriter, span trace.Span) error {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(200)
e := new(jx.Encoder)
response.Encode(e)
if _, err := e.WriteTo(w); err != nil {
return errors.Wrap(err, "write")
}
return nil
}
func encodeAuthRegisterResponse(response *AuthRegisterOK, w http.ResponseWriter, span trace.Span) error {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(200)
e := new(jx.Encoder)
response.Encode(e)
if _, err := e.WriteTo(w); err != nil {
return errors.Wrap(err, "write")
}
return nil
}
func encodeAuthRejectResponse(response *AuthRejectOK, w http.ResponseWriter, span trace.Span) error {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(200)
e := new(jx.Encoder)
response.Encode(e)
if _, err := e.WriteTo(w); err != nil {
return errors.Wrap(err, "write")
}
return nil
}
func encodeBackfillNodeIPsResponse(response *BackfillNodeIPsOK, w http.ResponseWriter, span trace.Span) error {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(200)
e := new(jx.Encoder)
response.Encode(e)
if _, err := e.WriteTo(w); err != nil {
return errors.Wrap(err, "write")
}
return nil
}
func encodeCheckPolicyResponse(response *CheckPolicyOK, w http.ResponseWriter, span trace.Span) error {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(200)
e := new(jx.Encoder)
response.Encode(e)
if _, err := e.WriteTo(w); err != nil {
return errors.Wrap(err, "write")
}
return nil
}
func encodeCreateApiKeyResponse(response *CreateApiKeyOK, w http.ResponseWriter, span trace.Span) error {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(200)
e := new(jx.Encoder)
response.Encode(e)
if _, err := e.WriteTo(w); err != nil {
return errors.Wrap(err, "write")
}
return nil
}
func encodeCreatePreAuthKeyResponse(response *CreatePreAuthKeyOK, w http.ResponseWriter, span trace.Span) error {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(200)
e := new(jx.Encoder)
response.Encode(e)
if _, err := e.WriteTo(w); err != nil {
return errors.Wrap(err, "write")
}
return nil
}
func encodeCreateUserResponse(response *CreateUserOK, w http.ResponseWriter, span trace.Span) error {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(200)
e := new(jx.Encoder)
response.Encode(e)
if _, err := e.WriteTo(w); err != nil {
return errors.Wrap(err, "write")
}
return nil
}
func encodeDebugCreateNodeResponse(response *DebugCreateNodeOK, w http.ResponseWriter, span trace.Span) error {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(200)
e := new(jx.Encoder)
response.Encode(e)
if _, err := e.WriteTo(w); err != nil {
return errors.Wrap(err, "write")
}
return nil
}
func encodeDeleteApiKeyResponse(response *DeleteApiKeyOK, w http.ResponseWriter, span trace.Span) error {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(200)
e := new(jx.Encoder)
response.Encode(e)
if _, err := e.WriteTo(w); err != nil {
return errors.Wrap(err, "write")
}
return nil
}
func encodeDeleteNodeResponse(response *DeleteNodeOK, w http.ResponseWriter, span trace.Span) error {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(200)
e := new(jx.Encoder)
response.Encode(e)
if _, err := e.WriteTo(w); err != nil {
return errors.Wrap(err, "write")
}
return nil
}
func encodeDeletePreAuthKeyResponse(response *DeletePreAuthKeyOK, w http.ResponseWriter, span trace.Span) error {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(200)
e := new(jx.Encoder)
response.Encode(e)
if _, err := e.WriteTo(w); err != nil {
return errors.Wrap(err, "write")
}
return nil
}
func encodeDeleteUserResponse(response *DeleteUserOK, w http.ResponseWriter, span trace.Span) error {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(200)
e := new(jx.Encoder)
response.Encode(e)
if _, err := e.WriteTo(w); err != nil {
return errors.Wrap(err, "write")
}
return nil
}
func encodeExpireApiKeyResponse(response *ExpireApiKeyOK, w http.ResponseWriter, span trace.Span) error {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(200)
e := new(jx.Encoder)
response.Encode(e)
if _, err := e.WriteTo(w); err != nil {
return errors.Wrap(err, "write")
}
return nil
}
func encodeExpireNodeResponse(response *ExpireNodeOK, w http.ResponseWriter, span trace.Span) error {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(200)
e := new(jx.Encoder)
response.Encode(e)
if _, err := e.WriteTo(w); err != nil {
return errors.Wrap(err, "write")
}
return nil
}
func encodeExpirePreAuthKeyResponse(response *ExpirePreAuthKeyOK, w http.ResponseWriter, span trace.Span) error {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(200)
e := new(jx.Encoder)
response.Encode(e)
if _, err := e.WriteTo(w); err != nil {
return errors.Wrap(err, "write")
}
return nil
}
func encodeGetNodeResponse(response *GetNodeOK, w http.ResponseWriter, span trace.Span) error {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(200)
e := new(jx.Encoder)
response.Encode(e)
if _, err := e.WriteTo(w); err != nil {
return errors.Wrap(err, "write")
}
return nil
}
func encodeGetPolicyResponse(response *GetPolicyOK, w http.ResponseWriter, span trace.Span) error {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(200)
e := new(jx.Encoder)
response.Encode(e)
if _, err := e.WriteTo(w); err != nil {
return errors.Wrap(err, "write")
}
return nil
}
func encodeHealthResponse(response *HealthOK, w http.ResponseWriter, span trace.Span) error {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(200)
e := new(jx.Encoder)
response.Encode(e)
if _, err := e.WriteTo(w); err != nil {
return errors.Wrap(err, "write")
}
return nil
}
func encodeListApiKeysResponse(response *ListApiKeysOK, w http.ResponseWriter, span trace.Span) error {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(200)
e := new(jx.Encoder)
response.Encode(e)
if _, err := e.WriteTo(w); err != nil {
return errors.Wrap(err, "write")
}
return nil
}
func encodeListNodesResponse(response *ListNodesOK, w http.ResponseWriter, span trace.Span) error {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(200)
e := new(jx.Encoder)
response.Encode(e)
if _, err := e.WriteTo(w); err != nil {
return errors.Wrap(err, "write")
}
return nil
}
func encodeListPreAuthKeysResponse(response *ListPreAuthKeysOK, w http.ResponseWriter, span trace.Span) error {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(200)
e := new(jx.Encoder)
response.Encode(e)
if _, err := e.WriteTo(w); err != nil {
return errors.Wrap(err, "write")
}
return nil
}
func encodeListUsersResponse(response *ListUsersOK, w http.ResponseWriter, span trace.Span) error {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(200)
e := new(jx.Encoder)
response.Encode(e)
if _, err := e.WriteTo(w); err != nil {
return errors.Wrap(err, "write")
}
return nil
}
func encodeRegisterNodeResponse(response *RegisterNodeOK, w http.ResponseWriter, span trace.Span) error {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(200)
e := new(jx.Encoder)
response.Encode(e)
if _, err := e.WriteTo(w); err != nil {
return errors.Wrap(err, "write")
}
return nil
}
func encodeRenameNodeResponse(response *RenameNodeOK, w http.ResponseWriter, span trace.Span) error {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(200)
e := new(jx.Encoder)
response.Encode(e)
if _, err := e.WriteTo(w); err != nil {
return errors.Wrap(err, "write")
}
return nil
}
func encodeRenameUserResponse(response *RenameUserOK, w http.ResponseWriter, span trace.Span) error {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(200)
e := new(jx.Encoder)
response.Encode(e)
if _, err := e.WriteTo(w); err != nil {
return errors.Wrap(err, "write")
}
return nil
}
func encodeSetApprovedRoutesResponse(response *SetApprovedRoutesOK, w http.ResponseWriter, span trace.Span) error {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(200)
e := new(jx.Encoder)
response.Encode(e)
if _, err := e.WriteTo(w); err != nil {
return errors.Wrap(err, "write")
}
return nil
}
func encodeSetPolicyResponse(response *SetPolicyOK, w http.ResponseWriter, span trace.Span) error {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(200)
e := new(jx.Encoder)
response.Encode(e)
if _, err := e.WriteTo(w); err != nil {
return errors.Wrap(err, "write")
}
return nil
}
func encodeSetTagsResponse(response *SetTagsOK, w http.ResponseWriter, span trace.Span) error {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(200)
e := new(jx.Encoder)
response.Encode(e)
if _, err := e.WriteTo(w); err != nil {
return errors.Wrap(err, "write")
}
return nil
}
func encodeErrorResponse(response *ErrorStatusCode, w http.ResponseWriter, span trace.Span) error {
w.Header().Set("Content-Type", "application/problem+json")
code := response.StatusCode
if code == 0 {
// Set default status code.
code = http.StatusOK
}
w.WriteHeader(code)
if code >= http.StatusInternalServerError {
span.SetStatus(codes.Error, http.StatusText(code))
}
e := new(jx.Encoder)
response.Response.Encode(e)
if _, err := e.WriteTo(w); err != nil {
return errors.Wrap(err, "write")
}
if code >= http.StatusInternalServerError {
return errors.Wrapf(ht.ErrInternalServerErrorResponse, "code: %d, message: %s", code, http.StatusText(code))
}
return nil
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-119
View File
@@ -1,119 +0,0 @@
// Code generated by ogen, DO NOT EDIT.
package apiv1
import (
"context"
"net/http"
"strings"
"github.com/go-faster/errors"
"github.com/ogen-go/ogen/ogenerrors"
)
// SecurityHandler is handler for security parameters.
type SecurityHandler interface {
// HandleBearerAuth handles bearerAuth security.
HandleBearerAuth(ctx context.Context, operationName OperationName, t BearerAuth) (context.Context, error)
}
func findAuthorization(h http.Header, prefix string) (string, bool) {
v, ok := h["Authorization"]
if !ok {
return "", false
}
for _, vv := range v {
scheme, value, ok := strings.Cut(vv, " ")
if !ok || !strings.EqualFold(scheme, prefix) {
continue
}
return value, true
}
return "", false
}
// operationRolesBearerAuth is a private map storing roles per operation.
var operationRolesBearerAuth = map[string][]string{
AuthApproveOperation: []string{},
AuthRegisterOperation: []string{},
AuthRejectOperation: []string{},
BackfillNodeIPsOperation: []string{},
CheckPolicyOperation: []string{},
CreateApiKeyOperation: []string{},
CreatePreAuthKeyOperation: []string{},
CreateUserOperation: []string{},
DebugCreateNodeOperation: []string{},
DeleteApiKeyOperation: []string{},
DeleteNodeOperation: []string{},
DeletePreAuthKeyOperation: []string{},
DeleteUserOperation: []string{},
ExpireApiKeyOperation: []string{},
ExpireNodeOperation: []string{},
ExpirePreAuthKeyOperation: []string{},
GetNodeOperation: []string{},
GetPolicyOperation: []string{},
HealthOperation: []string{},
ListApiKeysOperation: []string{},
ListNodesOperation: []string{},
ListPreAuthKeysOperation: []string{},
ListUsersOperation: []string{},
RegisterNodeOperation: []string{},
RenameNodeOperation: []string{},
RenameUserOperation: []string{},
SetApprovedRoutesOperation: []string{},
SetPolicyOperation: []string{},
SetTagsOperation: []string{},
}
// GetRolesForBearerAuth returns the required roles for the given operation.
//
// This is useful for authorization scenarios where you need to know which roles
// are required for an operation.
//
// Example:
//
// requiredRoles := GetRolesForBearerAuth(AddPetOperation)
//
// Returns nil if the operation has no role requirements or if the operation is unknown.
func GetRolesForBearerAuth(operation string) []string {
roles, ok := operationRolesBearerAuth[operation]
if !ok {
return nil
}
// Return a copy to prevent external modification
result := make([]string, len(roles))
copy(result, roles)
return result
}
func (s *Server) securityBearerAuth(ctx context.Context, operationName OperationName, req *http.Request) (context.Context, bool, error) {
var t BearerAuth
token, ok := findAuthorization(req.Header, "Bearer")
if !ok {
return ctx, false, nil
}
t.Token = token
t.Roles = operationRolesBearerAuth[operationName]
rctx, err := s.sec.HandleBearerAuth(ctx, operationName, t)
if errors.Is(err, ogenerrors.ErrSkipServerSecurity) {
return nil, false, nil
} else if err != nil {
return nil, false, err
}
return rctx, true, err
}
// SecuritySource is provider of security values (tokens, passwords, etc.).
type SecuritySource interface {
// BearerAuth provides bearerAuth security value.
BearerAuth(ctx context.Context, operationName OperationName) (BearerAuth, error)
}
func (s *Client) securityBearerAuth(ctx context.Context, operationName OperationName, req *http.Request) error {
t, err := s.sec.BearerAuth(ctx, operationName)
if err != nil {
return errors.Wrap(err, "security source \"BearerAuth\"")
}
req.Header.Set("Authorization", "Bearer "+t.Token)
return nil
}
-210
View File
@@ -1,210 +0,0 @@
// Code generated by ogen, DO NOT EDIT.
package apiv1
import (
"context"
)
// Handler handles operations described by OpenAPI v3 specification.
type Handler interface {
// AuthApprove implements AuthApprove operation.
//
// Approve a pending auth session.
//
// POST /api/v1/auth/approve
AuthApprove(ctx context.Context, req *AuthApproveReq) error
// AuthRegister implements AuthRegister operation.
//
// Register a node via an auth id (alias of RegisterNode).
//
// POST /api/v1/auth/register
AuthRegister(ctx context.Context, req *AuthRegisterReq) (*AuthRegisterOK, error)
// AuthReject implements AuthReject operation.
//
// Reject a pending auth session.
//
// POST /api/v1/auth/reject
AuthReject(ctx context.Context, req *AuthRejectReq) error
// BackfillNodeIPs implements BackfillNodeIPs operation.
//
// Backfill missing IP addresses for all nodes.
//
// POST /api/v1/node/backfillips
BackfillNodeIPs(ctx context.Context, params BackfillNodeIPsParams) (*BackfillNodeIPsOK, error)
// CheckPolicy implements CheckPolicy operation.
//
// Validate a policy against live users and nodes without storing it.
//
// POST /api/v1/policy/check
CheckPolicy(ctx context.Context, req *CheckPolicyReq) error
// CreateApiKey implements CreateApiKey operation.
//
// Create an API key.
//
// POST /api/v1/apikey
CreateApiKey(ctx context.Context, req *CreateApiKeyReq) (*CreateApiKeyOK, error)
// CreatePreAuthKey implements CreatePreAuthKey operation.
//
// Create a pre-auth key.
//
// POST /api/v1/preauthkey
CreatePreAuthKey(ctx context.Context, req *CreatePreAuthKeyReq) (*CreatePreAuthKeyOK, error)
// CreateUser implements CreateUser operation.
//
// Create a user.
//
// POST /api/v1/user
CreateUser(ctx context.Context, req *CreateUserReq) (*CreateUserOK, error)
// DebugCreateNode implements DebugCreateNode operation.
//
// Create a debug node and cache its registration (testing only).
//
// POST /api/v1/debug/node
DebugCreateNode(ctx context.Context, req *DebugCreateNodeReq) (*DebugCreateNodeOK, error)
// DeleteApiKey implements DeleteApiKey operation.
//
// Delete an API key by prefix (or id).
//
// DELETE /api/v1/apikey/{prefix}
DeleteApiKey(ctx context.Context, params DeleteApiKeyParams) error
// DeleteNode implements DeleteNode operation.
//
// Delete a node.
//
// DELETE /api/v1/node/{node_id}
DeleteNode(ctx context.Context, params DeleteNodeParams) error
// DeletePreAuthKey implements DeletePreAuthKey operation.
//
// Delete a pre-auth key.
//
// DELETE /api/v1/preauthkey
DeletePreAuthKey(ctx context.Context, params DeletePreAuthKeyParams) error
// DeleteUser implements DeleteUser operation.
//
// Delete a user.
//
// DELETE /api/v1/user/{id}
DeleteUser(ctx context.Context, params DeleteUserParams) error
// ExpireApiKey implements ExpireApiKey operation.
//
// Expire an API key by id or prefix.
//
// POST /api/v1/apikey/expire
ExpireApiKey(ctx context.Context, req *ExpireApiKeyReq) error
// ExpireNode implements ExpireNode operation.
//
// Expire a node, or disable its expiry.
//
// POST /api/v1/node/{node_id}/expire
ExpireNode(ctx context.Context, params ExpireNodeParams) (*ExpireNodeOK, error)
// ExpirePreAuthKey implements ExpirePreAuthKey operation.
//
// Expire a pre-auth key.
//
// POST /api/v1/preauthkey/expire
ExpirePreAuthKey(ctx context.Context, req *ExpirePreAuthKeyReq) error
// GetNode implements GetNode operation.
//
// Get a node by id.
//
// GET /api/v1/node/{node_id}
GetNode(ctx context.Context, params GetNodeParams) (*GetNodeOK, error)
// GetPolicy implements GetPolicy operation.
//
// Get the current ACL policy.
//
// GET /api/v1/policy
GetPolicy(ctx context.Context) (*GetPolicyOK, error)
// Health implements Health operation.
//
// Report server health, including database connectivity.
//
// GET /api/v1/health
Health(ctx context.Context) (*HealthOK, error)
// ListApiKeys implements ListApiKeys operation.
//
// List all API keys.
//
// GET /api/v1/apikey
ListApiKeys(ctx context.Context) (*ListApiKeysOK, error)
// ListNodes implements ListNodes operation.
//
// List nodes, optionally filtered by user.
//
// GET /api/v1/node
ListNodes(ctx context.Context, params ListNodesParams) (*ListNodesOK, error)
// ListPreAuthKeys implements ListPreAuthKeys operation.
//
// List all pre-auth keys.
//
// GET /api/v1/preauthkey
ListPreAuthKeys(ctx context.Context) (*ListPreAuthKeysOK, error)
// ListUsers implements ListUsers operation.
//
// List users, optionally filtered by id, name, or email.
//
// GET /api/v1/user
ListUsers(ctx context.Context, params ListUsersParams) (*ListUsersOK, error)
// RegisterNode implements RegisterNode operation.
//
// Register a node to a user using a registration id.
//
// POST /api/v1/node/register
RegisterNode(ctx context.Context, params RegisterNodeParams) (*RegisterNodeOK, error)
// RenameNode implements RenameNode operation.
//
// Rename a node.
//
// POST /api/v1/node/{node_id}/rename/{new_name}
RenameNode(ctx context.Context, params RenameNodeParams) (*RenameNodeOK, error)
// RenameUser implements RenameUser operation.
//
// Rename a user.
//
// POST /api/v1/user/{old_id}/rename/{new_name}
RenameUser(ctx context.Context, params RenameUserParams) (*RenameUserOK, error)
// SetApprovedRoutes implements SetApprovedRoutes operation.
//
// Set the approved subnet routes of a node.
//
// POST /api/v1/node/{node_id}/approve_routes
SetApprovedRoutes(ctx context.Context, req *SetApprovedRoutesReq, params SetApprovedRoutesParams) (*SetApprovedRoutesOK, error)
// SetPolicy implements SetPolicy operation.
//
// Set the ACL policy (database policy mode only).
//
// PUT /api/v1/policy
SetPolicy(ctx context.Context, req *SetPolicyReq) (*SetPolicyOK, error)
// SetTags implements SetTags operation.
//
// Set the tags of a node (converts it to a tagged node).
//
// POST /api/v1/node/{node_id}/tags
SetTags(ctx context.Context, req *SetTagsReq, params SetTagsParams) (*SetTagsOK, error)
// NewError creates *ErrorStatusCode from error returned by handler.
//
// Used for common default response.
NewError(ctx context.Context, err error) *ErrorStatusCode
}
// Server implements http server based on OpenAPI v3 specification and
// calls Handler to handle requests.
type Server struct {
h Handler
sec SecurityHandler
baseServer
}
// NewServer creates new Server.
func NewServer(h Handler, sec SecurityHandler, opts ...ServerOption) (*Server, error) {
s, err := newServerConfig(opts...).baseServer()
if err != nil {
return nil, err
}
return &Server{
h: h,
sec: sec,
baseServer: s,
}, nil
}
-283
View File
@@ -1,283 +0,0 @@
// Code generated by ogen, DO NOT EDIT.
package apiv1
import (
"context"
ht "github.com/ogen-go/ogen/http"
)
// UnimplementedHandler is no-op Handler which returns http.ErrNotImplemented.
type UnimplementedHandler struct{}
var _ Handler = UnimplementedHandler{}
// AuthApprove implements AuthApprove operation.
//
// Approve a pending auth session.
//
// POST /api/v1/auth/approve
func (UnimplementedHandler) AuthApprove(ctx context.Context, req *AuthApproveReq) error {
return ht.ErrNotImplemented
}
// AuthRegister implements AuthRegister operation.
//
// Register a node via an auth id (alias of RegisterNode).
//
// POST /api/v1/auth/register
func (UnimplementedHandler) AuthRegister(ctx context.Context, req *AuthRegisterReq) (r *AuthRegisterOK, _ error) {
return r, ht.ErrNotImplemented
}
// AuthReject implements AuthReject operation.
//
// Reject a pending auth session.
//
// POST /api/v1/auth/reject
func (UnimplementedHandler) AuthReject(ctx context.Context, req *AuthRejectReq) error {
return ht.ErrNotImplemented
}
// BackfillNodeIPs implements BackfillNodeIPs operation.
//
// Backfill missing IP addresses for all nodes.
//
// POST /api/v1/node/backfillips
func (UnimplementedHandler) BackfillNodeIPs(ctx context.Context, params BackfillNodeIPsParams) (r *BackfillNodeIPsOK, _ error) {
return r, ht.ErrNotImplemented
}
// CheckPolicy implements CheckPolicy operation.
//
// Validate a policy against live users and nodes without storing it.
//
// POST /api/v1/policy/check
func (UnimplementedHandler) CheckPolicy(ctx context.Context, req *CheckPolicyReq) error {
return ht.ErrNotImplemented
}
// CreateApiKey implements CreateApiKey operation.
//
// Create an API key.
//
// POST /api/v1/apikey
func (UnimplementedHandler) CreateApiKey(ctx context.Context, req *CreateApiKeyReq) (r *CreateApiKeyOK, _ error) {
return r, ht.ErrNotImplemented
}
// CreatePreAuthKey implements CreatePreAuthKey operation.
//
// Create a pre-auth key.
//
// POST /api/v1/preauthkey
func (UnimplementedHandler) CreatePreAuthKey(ctx context.Context, req *CreatePreAuthKeyReq) (r *CreatePreAuthKeyOK, _ error) {
return r, ht.ErrNotImplemented
}
// CreateUser implements CreateUser operation.
//
// Create a user.
//
// POST /api/v1/user
func (UnimplementedHandler) CreateUser(ctx context.Context, req *CreateUserReq) (r *CreateUserOK, _ error) {
return r, ht.ErrNotImplemented
}
// DebugCreateNode implements DebugCreateNode operation.
//
// Create a debug node and cache its registration (testing only).
//
// POST /api/v1/debug/node
func (UnimplementedHandler) DebugCreateNode(ctx context.Context, req *DebugCreateNodeReq) (r *DebugCreateNodeOK, _ error) {
return r, ht.ErrNotImplemented
}
// DeleteApiKey implements DeleteApiKey operation.
//
// Delete an API key by prefix (or id).
//
// DELETE /api/v1/apikey/{prefix}
func (UnimplementedHandler) DeleteApiKey(ctx context.Context, params DeleteApiKeyParams) error {
return ht.ErrNotImplemented
}
// DeleteNode implements DeleteNode operation.
//
// Delete a node.
//
// DELETE /api/v1/node/{node_id}
func (UnimplementedHandler) DeleteNode(ctx context.Context, params DeleteNodeParams) error {
return ht.ErrNotImplemented
}
// DeletePreAuthKey implements DeletePreAuthKey operation.
//
// Delete a pre-auth key.
//
// DELETE /api/v1/preauthkey
func (UnimplementedHandler) DeletePreAuthKey(ctx context.Context, params DeletePreAuthKeyParams) error {
return ht.ErrNotImplemented
}
// DeleteUser implements DeleteUser operation.
//
// Delete a user.
//
// DELETE /api/v1/user/{id}
func (UnimplementedHandler) DeleteUser(ctx context.Context, params DeleteUserParams) error {
return ht.ErrNotImplemented
}
// ExpireApiKey implements ExpireApiKey operation.
//
// Expire an API key by id or prefix.
//
// POST /api/v1/apikey/expire
func (UnimplementedHandler) ExpireApiKey(ctx context.Context, req *ExpireApiKeyReq) error {
return ht.ErrNotImplemented
}
// ExpireNode implements ExpireNode operation.
//
// Expire a node, or disable its expiry.
//
// POST /api/v1/node/{node_id}/expire
func (UnimplementedHandler) ExpireNode(ctx context.Context, params ExpireNodeParams) (r *ExpireNodeOK, _ error) {
return r, ht.ErrNotImplemented
}
// ExpirePreAuthKey implements ExpirePreAuthKey operation.
//
// Expire a pre-auth key.
//
// POST /api/v1/preauthkey/expire
func (UnimplementedHandler) ExpirePreAuthKey(ctx context.Context, req *ExpirePreAuthKeyReq) error {
return ht.ErrNotImplemented
}
// GetNode implements GetNode operation.
//
// Get a node by id.
//
// GET /api/v1/node/{node_id}
func (UnimplementedHandler) GetNode(ctx context.Context, params GetNodeParams) (r *GetNodeOK, _ error) {
return r, ht.ErrNotImplemented
}
// GetPolicy implements GetPolicy operation.
//
// Get the current ACL policy.
//
// GET /api/v1/policy
func (UnimplementedHandler) GetPolicy(ctx context.Context) (r *GetPolicyOK, _ error) {
return r, ht.ErrNotImplemented
}
// Health implements Health operation.
//
// Report server health, including database connectivity.
//
// GET /api/v1/health
func (UnimplementedHandler) Health(ctx context.Context) (r *HealthOK, _ error) {
return r, ht.ErrNotImplemented
}
// ListApiKeys implements ListApiKeys operation.
//
// List all API keys.
//
// GET /api/v1/apikey
func (UnimplementedHandler) ListApiKeys(ctx context.Context) (r *ListApiKeysOK, _ error) {
return r, ht.ErrNotImplemented
}
// ListNodes implements ListNodes operation.
//
// List nodes, optionally filtered by user.
//
// GET /api/v1/node
func (UnimplementedHandler) ListNodes(ctx context.Context, params ListNodesParams) (r *ListNodesOK, _ error) {
return r, ht.ErrNotImplemented
}
// ListPreAuthKeys implements ListPreAuthKeys operation.
//
// List all pre-auth keys.
//
// GET /api/v1/preauthkey
func (UnimplementedHandler) ListPreAuthKeys(ctx context.Context) (r *ListPreAuthKeysOK, _ error) {
return r, ht.ErrNotImplemented
}
// ListUsers implements ListUsers operation.
//
// List users, optionally filtered by id, name, or email.
//
// GET /api/v1/user
func (UnimplementedHandler) ListUsers(ctx context.Context, params ListUsersParams) (r *ListUsersOK, _ error) {
return r, ht.ErrNotImplemented
}
// RegisterNode implements RegisterNode operation.
//
// Register a node to a user using a registration id.
//
// POST /api/v1/node/register
func (UnimplementedHandler) RegisterNode(ctx context.Context, params RegisterNodeParams) (r *RegisterNodeOK, _ error) {
return r, ht.ErrNotImplemented
}
// RenameNode implements RenameNode operation.
//
// Rename a node.
//
// POST /api/v1/node/{node_id}/rename/{new_name}
func (UnimplementedHandler) RenameNode(ctx context.Context, params RenameNodeParams) (r *RenameNodeOK, _ error) {
return r, ht.ErrNotImplemented
}
// RenameUser implements RenameUser operation.
//
// Rename a user.
//
// POST /api/v1/user/{old_id}/rename/{new_name}
func (UnimplementedHandler) RenameUser(ctx context.Context, params RenameUserParams) (r *RenameUserOK, _ error) {
return r, ht.ErrNotImplemented
}
// SetApprovedRoutes implements SetApprovedRoutes operation.
//
// Set the approved subnet routes of a node.
//
// POST /api/v1/node/{node_id}/approve_routes
func (UnimplementedHandler) SetApprovedRoutes(ctx context.Context, req *SetApprovedRoutesReq, params SetApprovedRoutesParams) (r *SetApprovedRoutesOK, _ error) {
return r, ht.ErrNotImplemented
}
// SetPolicy implements SetPolicy operation.
//
// Set the ACL policy (database policy mode only).
//
// PUT /api/v1/policy
func (UnimplementedHandler) SetPolicy(ctx context.Context, req *SetPolicyReq) (r *SetPolicyOK, _ error) {
return r, ht.ErrNotImplemented
}
// SetTags implements SetTags operation.
//
// Set the tags of a node (converts it to a tagged node).
//
// POST /api/v1/node/{node_id}/tags
func (UnimplementedHandler) SetTags(ctx context.Context, req *SetTagsReq, params SetTagsParams) (r *SetTagsOK, _ error) {
return r, ht.ErrNotImplemented
}
// NewError creates *ErrorStatusCode from error returned by handler.
//
// Used for common default response.
func (UnimplementedHandler) NewError(ctx context.Context, err error) (r *ErrorStatusCode) {
r = new(ErrorStatusCode)
return r
}
-332
View File
@@ -1,332 +0,0 @@
// Code generated by ogen, DO NOT EDIT.
package apiv1
import (
"fmt"
"github.com/go-faster/errors"
"github.com/ogen-go/ogen/validate"
)
func (s *AuthRegisterOK) Validate() error {
if s == nil {
return validate.ErrNilPointer
}
var failures []validate.FieldError
if err := func() error {
if value, ok := s.Node.Get(); ok {
if err := func() error {
if err := value.Validate(); err != nil {
return err
}
return nil
}(); err != nil {
return err
}
}
return nil
}(); err != nil {
failures = append(failures, validate.FieldError{
Name: "node",
Error: err,
})
}
if len(failures) > 0 {
return &validate.Error{Fields: failures}
}
return nil
}
func (s *DebugCreateNodeOK) Validate() error {
if s == nil {
return validate.ErrNilPointer
}
var failures []validate.FieldError
if err := func() error {
if value, ok := s.Node.Get(); ok {
if err := func() error {
if err := value.Validate(); err != nil {
return err
}
return nil
}(); err != nil {
return err
}
}
return nil
}(); err != nil {
failures = append(failures, validate.FieldError{
Name: "node",
Error: err,
})
}
if len(failures) > 0 {
return &validate.Error{Fields: failures}
}
return nil
}
func (s *ExpireNodeOK) Validate() error {
if s == nil {
return validate.ErrNilPointer
}
var failures []validate.FieldError
if err := func() error {
if value, ok := s.Node.Get(); ok {
if err := func() error {
if err := value.Validate(); err != nil {
return err
}
return nil
}(); err != nil {
return err
}
}
return nil
}(); err != nil {
failures = append(failures, validate.FieldError{
Name: "node",
Error: err,
})
}
if len(failures) > 0 {
return &validate.Error{Fields: failures}
}
return nil
}
func (s *GetNodeOK) Validate() error {
if s == nil {
return validate.ErrNilPointer
}
var failures []validate.FieldError
if err := func() error {
if value, ok := s.Node.Get(); ok {
if err := func() error {
if err := value.Validate(); err != nil {
return err
}
return nil
}(); err != nil {
return err
}
}
return nil
}(); err != nil {
failures = append(failures, validate.FieldError{
Name: "node",
Error: err,
})
}
if len(failures) > 0 {
return &validate.Error{Fields: failures}
}
return nil
}
func (s *ListNodesOK) Validate() error {
if s == nil {
return validate.ErrNilPointer
}
var failures []validate.FieldError
if err := func() error {
var failures []validate.FieldError
for i, elem := range s.Nodes {
if err := func() error {
if err := elem.Validate(); err != nil {
return err
}
return nil
}(); err != nil {
failures = append(failures, validate.FieldError{
Name: fmt.Sprintf("[%d]", i),
Error: err,
})
}
}
if len(failures) > 0 {
return &validate.Error{Fields: failures}
}
return nil
}(); err != nil {
failures = append(failures, validate.FieldError{
Name: "nodes",
Error: err,
})
}
if len(failures) > 0 {
return &validate.Error{Fields: failures}
}
return nil
}
func (s *Node) Validate() error {
if s == nil {
return validate.ErrNilPointer
}
var failures []validate.FieldError
if err := func() error {
if value, ok := s.RegisterMethod.Get(); ok {
if err := func() error {
if err := value.Validate(); err != nil {
return err
}
return nil
}(); err != nil {
return err
}
}
return nil
}(); err != nil {
failures = append(failures, validate.FieldError{
Name: "registerMethod",
Error: err,
})
}
if len(failures) > 0 {
return &validate.Error{Fields: failures}
}
return nil
}
func (s RegisterMethod) Validate() error {
switch s {
case "REGISTER_METHOD_UNSPECIFIED":
return nil
case "REGISTER_METHOD_AUTH_KEY":
return nil
case "REGISTER_METHOD_CLI":
return nil
case "REGISTER_METHOD_OIDC":
return nil
default:
return errors.Errorf("invalid value: %v", s)
}
}
func (s *RegisterNodeOK) Validate() error {
if s == nil {
return validate.ErrNilPointer
}
var failures []validate.FieldError
if err := func() error {
if value, ok := s.Node.Get(); ok {
if err := func() error {
if err := value.Validate(); err != nil {
return err
}
return nil
}(); err != nil {
return err
}
}
return nil
}(); err != nil {
failures = append(failures, validate.FieldError{
Name: "node",
Error: err,
})
}
if len(failures) > 0 {
return &validate.Error{Fields: failures}
}
return nil
}
func (s *RenameNodeOK) Validate() error {
if s == nil {
return validate.ErrNilPointer
}
var failures []validate.FieldError
if err := func() error {
if value, ok := s.Node.Get(); ok {
if err := func() error {
if err := value.Validate(); err != nil {
return err
}
return nil
}(); err != nil {
return err
}
}
return nil
}(); err != nil {
failures = append(failures, validate.FieldError{
Name: "node",
Error: err,
})
}
if len(failures) > 0 {
return &validate.Error{Fields: failures}
}
return nil
}
func (s *SetApprovedRoutesOK) Validate() error {
if s == nil {
return validate.ErrNilPointer
}
var failures []validate.FieldError
if err := func() error {
if value, ok := s.Node.Get(); ok {
if err := func() error {
if err := value.Validate(); err != nil {
return err
}
return nil
}(); err != nil {
return err
}
}
return nil
}(); err != nil {
failures = append(failures, validate.FieldError{
Name: "node",
Error: err,
})
}
if len(failures) > 0 {
return &validate.Error{Fields: failures}
}
return nil
}
func (s *SetTagsOK) Validate() error {
if s == nil {
return validate.ErrNilPointer
}
var failures []validate.FieldError
if err := func() error {
if value, ok := s.Node.Get(); ok {
if err := func() error {
if err := value.Validate(); err != nil {
return err
}
return nil
}(); err != nil {
return err
}
}
return nil
}(); err != nil {
failures = append(failures, validate.FieldError{
Name: "node",
Error: err,
})
}
if len(failures) > 0 {
return &validate.Error{Fields: failures}
}
return nil
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+10 -20
View File
@@ -10,22 +10,21 @@ require (
github.com/coreos/go-oidc/v3 v3.18.0
github.com/creachadair/command v0.2.6
github.com/creachadair/flax v0.0.6
github.com/danielgtaylor/huma/v2 v2.38.0
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc
github.com/docker/docker v28.5.2+incompatible
github.com/fsnotify/fsnotify v1.10.1
github.com/glebarez/sqlite v1.11.0
github.com/go-chi/chi/v5 v5.3.0
github.com/go-chi/metrics v0.1.1
github.com/go-faster/errors v0.7.1
github.com/go-faster/jx v1.2.0
github.com/go-gormigrate/gormigrate/v2 v2.1.6
github.com/go-json-experiment/json v0.0.0-20260601182631-00ed12fed2a6
github.com/gofrs/uuid/v5 v5.4.0
github.com/google/go-cmp v0.7.0
github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/jagottsicher/termcolor v1.0.2
github.com/oapi-codegen/runtime v1.4.1
github.com/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25
github.com/ogen-go/ogen v1.22.0
github.com/ory/dockertest/v3 v3.12.0
github.com/pkg/profile v1.7.0
github.com/prometheus/client_golang v1.23.2
@@ -43,9 +42,6 @@ require (
github.com/tailscale/squibble v0.0.0-20260411062017-141f5d618bc4
github.com/tailscale/tailsql v0.0.0-20260522170732-77aec5aabc76
github.com/tcnksm/go-latest v0.0.0-20170313132115-e3007ae9052e
go.opentelemetry.io/otel v1.44.0
go.opentelemetry.io/otel/metric v1.44.0
go.opentelemetry.io/otel/trace v1.44.0
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba
golang.org/x/crypto v0.53.0
golang.org/x/exp v0.0.0-20260603202125-055de637280b
@@ -57,6 +53,7 @@ require (
gorm.io/gorm v1.31.1
pgregory.net/rapid v1.3.0
tailscale.com v1.101.0-pre
tailscale.com/client/tailscale/v2 v2.9.0
zombiezen.com/go/postgrestest v1.0.1
)
@@ -105,6 +102,7 @@ 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-20250919150558-7d374ff0d59e // indirect
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.1 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.7 // indirect
@@ -135,20 +133,16 @@ require (
github.com/dblohm7/wingoes v0.0.0-20250822163801-6d8e6105c62d // indirect
github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/dlclark/regexp2 v1.12.0 // indirect
github.com/docker/cli v29.5.3+incompatible // indirect
github.com/docker/go-connections v0.7.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fatih/color v1.19.0 // indirect
github.com/felixge/fgprof v0.9.5 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fogleman/gg v1.3.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.1 // indirect
github.com/gaissmai/bart v0.26.1 // indirect
github.com/ghodss/yaml v1.0.0 // indirect
github.com/glebarez/go-sqlite v1.22.0 // indirect
github.com/go-faster/yaml v0.4.6 // indirect
github.com/go-jose/go-jose/v3 v3.0.5 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
@@ -214,8 +208,6 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/safchain/ethtool v0.7.0 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/segmentio/asm v1.2.1 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
github.com/spf13/afero v1.15.0 // indirect
@@ -234,37 +226,35 @@ require (
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.8.2 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
go.opentelemetry.io/otel v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.28.0 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect
golang.org/x/exp/typeparams v0.0.0-20260603202125-055de637280b // indirect
golang.org/x/image v0.41.0 // indirect
golang.org/x/mod v0.37.0 // indirect
golang.org/x/mod v0.36.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/term v0.44.0 // indirect
golang.org/x/text v0.38.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.46.0 // indirect
golang.org/x/tools v0.45.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260610212136-7ab31c22f7ad // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/grpc v1.81.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
k8s.io/client-go v0.34.0 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
software.sslmate.com/src/go-pkcs12 v0.4.0 // indirect
)
tool (
github.com/ogen-go/ogen/cmd/ogen
golang.org/x/tools/cmd/stress
golang.org/x/tools/cmd/stringer
tailscale.com/cmd/tailscale
+20 -30
View File
@@ -28,12 +28,15 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw=
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk=
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A=
github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/arl/statsviz v0.8.0 h1:O6GjjVxEDxcByAucOSl29HaGYLXsuwA3ujJw8H9E7/U=
github.com/arl/statsviz v0.8.0/go.mod h1:XlrbiT7xYT03xaW9JMMfD8KFUhBOESJwfyNJu83PbB0=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
@@ -82,6 +85,7 @@ github.com/axiomhq/hyperloglog v0.2.6 h1:sRhvvF3RIXWQgAXaTphLp4yJiX4S0IN3MWTaAgZ
github.com/axiomhq/hyperloglog v0.2.6/go.mod h1:YjX/dQqCR/7QYX0g8mu8UZAjpIenz1FKM71UEsjFoTo=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
@@ -132,6 +136,8 @@ github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoi
github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/danielgtaylor/huma/v2 v2.38.0 h1:fb0WZCatnaiHLphMQDDWDjygNxfMkX/ENma3QsRl7vY=
github.com/danielgtaylor/huma/v2 v2.38.0/go.mod h1:k9hwjlgWFt1t2jsmQGlsgXAG2FBTZa4kkjV581qAtfo=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
@@ -146,8 +152,6 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/docker/cli v29.5.3+incompatible h1:nbEFfz774vBwQ5KRYv7c/AghjReqnGISvrRhzjV0evs=
github.com/docker/cli v29.5.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
@@ -158,8 +162,6 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw=
github.com/felixge/fgprof v0.9.5 h1:8+vR6yu2vvSKn08urWyEuxx75NWPEvybbkBirEpsbVY=
github.com/felixge/fgprof v0.9.5/go.mod h1:yKl+ERSa++RYOs32d8K6WEXCB4uXdLls4ZaZPpayhMM=
@@ -171,12 +173,10 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo=
github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I=
github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
@@ -187,12 +187,6 @@ github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-chi/metrics v0.1.1 h1:CXhbnkAVVjb0k73EBRQ6Z2YdWFnbXZgNtg1Mboguibk=
github.com/go-chi/metrics v0.1.1/go.mod h1:mcGTM1pPalP7WCtb+akNYFO/lwNwBBLCuedepqjoPn4=
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
github.com/go-faster/jx v1.2.0 h1:T2YHJPrFaYu21fJtUxC9GzmluKu8rVIFDwwGBKTDseI=
github.com/go-faster/jx v1.2.0/go.mod h1:UWLOVDmMG597a5tBFPLIWJdUxz5/2emOpfsj9Neg0PE=
github.com/go-faster/yaml v0.4.6 h1:lOK/EhI04gCpPgPhgt0bChS6bvw7G3WwI8xxVe0sw9I=
github.com/go-faster/yaml v0.4.6/go.mod h1:390dRIvV4zbnO7qC9FGo6YYutc+wyyUSHBgbXL52eXk=
github.com/go-gormigrate/gormigrate/v2 v2.1.6 h1:VtX+l1Stj2v5RGubVQk0LS/8EPGXR+ldcOyCmlmKoyg=
github.com/go-gormigrate/gormigrate/v2 v2.1.6/go.mod h1:PZpedQc4tWaxn6kvXicwhinh3L0seLpMc5ReKRX5id4=
github.com/go-jose/go-jose/v3 v3.0.5 h1:BLLJWbC4nMZOfuPVxoZIxeYsn6Nl2r1fITaJ78UQlVQ=
@@ -298,6 +292,7 @@ github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfC
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/jsimonetti/rtnetlink v1.4.2 h1:Df9w9TZ3npHTyDn0Ev9e1uzmN2odmXd0QX+J5GTEn90=
github.com/jsimonetti/rtnetlink v1.4.2/go.mod h1:92s6LJdE+1iOrw+F2/RO7LYI2Qd8pPpFNNUYW06gcoM=
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
github.com/kamstrup/intmap v0.5.2 h1:qnwBm1mh4XAnW9W9Ue9tZtTff8pS6+s6iKF6JRIV2Dk=
github.com/kamstrup/intmap v0.5.2/go.mod h1:gWUVWHKzWj8xpJVFf5GC0O26bWmv3GqdnIX/LMT6Aq4=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
@@ -363,10 +358,12 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs=
github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY=
github.com/oapi-codegen/runtime v1.4.1 h1:9nwLoI+KrWxzbBcp0jO/R8uXqbik/HUyCvPeU68Y/qo=
github.com/oapi-codegen/runtime v1.4.1/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU=
github.com/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25 h1:9bCMuD3TcnjeqjPT2gSlha4asp8NvgcFRYExCaikCxk=
github.com/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25/go.mod h1:eDjgYHYDJbPLBLsyZ6qRaugP0mX8vePOhZ5id1fdzJw=
github.com/ogen-go/ogen v1.22.0 h1:7wU+jcIKg/JBAhM95909ULLdAkGr43KQOuvNpJ7Mxb4=
github.com/ogen-go/ogen v1.22.0/go.mod h1:7BOh9a51QiPCC92RMrj1LlkLjejhBAyPhR+oMc6lR9g=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
@@ -425,12 +422,8 @@ github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/sasha-s/go-deadlock v0.3.9 h1:fiaT9rB7g5sr5ddNZvlwheclN9IP86eFW9WgqlEQV+w=
github.com/sasha-s/go-deadlock v0.3.9/go.mod h1:KuZj51ZFmx42q/mPaYbRk0P1xcwe697zsJKE03vD4/Y=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
@@ -446,6 +439,7 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
@@ -510,8 +504,6 @@ github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQ
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
@@ -534,10 +526,6 @@ go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpu
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
@@ -559,8 +547,8 @@ golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
@@ -612,8 +600,8 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk=
golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
@@ -686,5 +674,7 @@ software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB
software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
tailscale.com v1.101.0-pre h1:q1eBWxryj7Lz5fMvi7npSbN/fJ3q6/crvbbfMkx89F8=
tailscale.com v1.101.0-pre/go.mod h1:DQ9YBy85DpNlSyeU2XRIWzbAu3RsGp/frv+Khg57meE=
tailscale.com/client/tailscale/v2 v2.9.0 h1:zBZIIeIYXL42qvvile7d29O2DKSr3AfNc2gzd1JCf2o=
tailscale.com/client/tailscale/v2 v2.9.0/go.mod h1:FGjvGT3ThHelqo0gfdK3IN3k1dwNbRzYbQh2XO3C47U=
zombiezen.com/go/postgrestest v1.0.1 h1:aXoADQAJmZDU3+xilYVut0pHhgc0sF8ZspPW9gFNwP4=
zombiezen.com/go/postgrestest v1.0.1/go.mod h1:marlZezr+k2oSJrvXHnZUs1olHqpE9czlz8ZYkVxliQ=
+160
View File
@@ -0,0 +1,160 @@
// Package apiv1 is the code-first Huma implementation of the Headscale v1 API.
// Handlers are a thin adapter over hscontrol/state; Huma emits the OpenAPI 3.1
// spec from the Go definitions (see Spec), and that spec drives the client.
//
// It depends only on the domain layer (hscontrol/state, hscontrol/types) via
// Backend, never on the hscontrol server package, so a future hscontrol/api/v2
// can sit beside it without either importing the other.
package apiv1
import (
"context"
"net/http"
"strings"
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humachi"
"github.com/go-chi/chi/v5"
"github.com/juanfont/headscale/hscontrol/state"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/types/change"
)
// Backend is the dependency surface the v1 API needs from the control plane:
// the state layer, the change-notification sink that distributes updates to
// connected nodes, and the config (only Policy.Mode and Policy.Path are read).
type Backend struct {
State *state.State
Change func(...change.Change)
Cfg *types.Config
}
// NewAPI builds the v1 Huma API on the given chi router and registers every
// operation. Auth is enforced by a Huma middleware driven by each operation's
// declared bearer security (see authMiddleware); locally-trusted requests
// bypass it via WithLocalTrust.
func NewAPI(router chi.Router, backend Backend) huma.API {
config := huma.DefaultConfig("Headscale API", "v1")
config.Info.Description = "Headscale control server API."
// Version the OpenAPI/docs routes under /api/v1 so a future v2 owns its own.
// These register as plain mux routes, not operations, so they never appear
// in the emitted spec or client.
config.OpenAPIPath = "/api/v1/openapi"
config.DocsPath = "/api/v1/docs"
// The v1 API does not emit "$schema".
config.SchemasPath = ""
// Drop the default schema-link create hook: it injects a "$schema" property
// and Link header into every response, which the v1 contract omits.
config.CreateHooks = nil
config.Components.SecuritySchemes = map[string]*huma.SecurityScheme{
"bearer": {
Type: "http",
Scheme: "bearer",
},
}
api := humachi.New(router, config)
// Must run before register: Huma snapshots the middleware chain at operation
// registration, so a middleware added afterwards would silently never run.
api.UseMiddleware(authMiddleware(api, backend))
register(api, backend)
return api
}
// bearerAuth is the security requirement applied to every operation: all
// /api/v1 routes require an API key.
var bearerAuth = []map[string][]string{{"bearer": {}}}
// registrations is populated by each resource file's init(), so adding a
// resource group means adding a file rather than editing a shared point. Huma
// sorts the emitted spec, so init order does not affect output.
var registrations []func(huma.API, Backend)
// register wires up every operation contributed by the resource files.
func register(api huma.API, b Backend) {
for _, fn := range registrations {
fn(api, b)
}
}
// Spec emits the OpenAPI 3.1 document. The zero Backend is safe because
// handlers are registered but never invoked during emission.
func Spec() ([]byte, error) {
api := NewAPI(chi.NewMux(), Backend{})
return api.OpenAPI().YAML()
}
// Spec30 emits the document downgraded to OpenAPI 3.0.3, needed because the
// client generator (oapi-codegen v2) cannot yet read the 3.1 spec.
func Spec30() ([]byte, error) {
api := NewAPI(chi.NewMux(), Backend{})
return api.OpenAPI().DowngradeYAML()
}
// Handler builds the v1 API on a fresh mux and returns both. Callers mount the
// mux and may use mux.Match to detect which paths this API serves.
func Handler(backend Backend) (*chi.Mux, huma.API) {
mux := chi.NewMux()
api := NewAPI(mux, backend)
return mux, api
}
// localTrustKey marks a request as arriving over a locally-trusted transport;
// the auth middleware skips authentication for such requests.
type localTrustKey struct{}
// WithLocalTrust wraps a handler so its requests bypass API-key authentication.
// The unix socket uses this — access to the socket is the trust boundary — as
// do in-process tests that exercise the mux directly.
func WithLocalTrust(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
next.ServeHTTP(w, req.WithContext(
context.WithValue(req.Context(), localTrustKey{}, struct{}{}),
))
})
}
// authMiddleware is a pure gate enforcing the bearer API key for any operation
// that declares security; the v1 handlers do not read caller identity.
// Locally-trusted requests and operations without declared security pass
// through. b.State is nil only during spec emission, where no request is
// served, so it is never dereferenced there.
func authMiddleware(api huma.API, b Backend) func(huma.Context, func(huma.Context)) {
return func(ctx huma.Context, next func(huma.Context)) {
if ctx.Context().Value(localTrustKey{}) != nil {
next(ctx)
return
}
if len(ctx.Operation().Security) == 0 {
next(ctx)
return
}
token, ok := strings.CutPrefix(ctx.Header("Authorization"), "Bearer ")
if !ok {
_ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "Unauthorized")
return
}
valid, err := b.State.ValidateAPIKey(token)
if err != nil || !valid {
_ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "Unauthorized")
return
}
next(ctx)
}
}
+208 -71
View File
@@ -3,106 +3,243 @@ package apiv1
import (
"cmp"
"context"
"net/http"
"slices"
"strconv"
"time"
oas "github.com/juanfont/headscale/gen/api/v1"
"github.com/danielgtaylor/huma/v2"
"github.com/juanfont/headscale/hscontrol/types"
)
// CreateApiKey creates an API key and returns the full secret. This is the only
// time the secret is exposed.
func (s *Server) CreateApiKey(
_ context.Context,
req *oas.CreateApiKeyReq,
) (*oas.CreateApiKeyOK, error) {
var expiration time.Time
if v, ok := req.Expiration.Get(); ok {
expiration = v
}
key, _, err := s.state.CreateAPIKey(&expiration)
if err != nil {
return nil, mapStateError(err)
}
return &oas.CreateApiKeyOK{ApiKey: oas.NewOptString(key)}, nil
func init() {
registrations = append(registrations, registerApiKeys)
}
// ListApiKeys lists all API keys (masked), sorted by id.
func (s *Server) ListApiKeys(_ context.Context) (*oas.ListApiKeysOK, error) {
keys, err := s.state.ListAPIKeys()
if err != nil {
return nil, mapStateError(err)
}
slices.SortFunc(keys, func(a, b types.APIKey) int { return cmp.Compare(a.ID, b.ID) })
out := make([]oas.ApiKey, len(keys))
for i := range keys {
out[i] = oasAPIKey(&keys[i])
}
return &oas.ListApiKeysOK{ApiKeys: out}, nil
// ApiKey is the v1 ApiKey message. Timestamps are pointers so a nil source is
// emitted as JSON null, matching protojson's unset Timestamp (e.g. lastSeen on
// a fresh key).
type ApiKey struct {
ID string `format:"uint64" json:"id"`
Prefix string `json:"prefix"`
Expiration *time.Time `json:"expiration" nullable:"true"`
CreatedAt *time.Time `json:"createdAt" nullable:"true"`
LastSeen *time.Time `json:"lastSeen" nullable:"true"`
}
// ExpireApiKey expires an API key identified by id or prefix.
func (s *Server) ExpireApiKey(_ context.Context, req *oas.ExpireApiKeyReq) error {
key, apiErr := s.apiKeyByIDOrPrefix(req.ID.Or(0), req.Prefix.Or(""))
if apiErr != nil {
return apiErr
}
err := s.state.ExpireAPIKey(key)
if err != nil {
return mapStateError(err)
}
return nil
// CreateApiKeyRequestBody is the v1.CreateApiKeyRequest body.
type CreateApiKeyRequestBody struct {
Expiration *time.Time `json:"expiration,omitempty"`
}
// DeleteApiKey deletes an API key identified by prefix (or id).
func (s *Server) DeleteApiKey(_ context.Context, params oas.DeleteApiKeyParams) error {
key, apiErr := s.apiKeyByIDOrPrefix(params.ID.Or(0), params.Prefix)
if apiErr != nil {
return apiErr
}
err := s.state.DestroyAPIKey(*key)
if err != nil {
return mapStateError(err)
}
return nil
// ExpireApiKeyRequestBody is the v1.ExpireApiKeyRequest body.
type ExpireApiKeyRequestBody struct {
Prefix string `json:"prefix,omitempty"`
ID string `format:"uint64" json:"id,omitempty"`
}
// apiKeyByIDOrPrefix looks up an API key by exactly one of id or prefix.
// Providing neither or both is a 400.
func (s *Server) apiKeyByIDOrPrefix(
id uint64,
prefix string,
) (*types.APIKey, *oas.ErrorStatusCode) {
type (
createApiKeyInput struct {
Body CreateApiKeyRequestBody
}
createApiKeyOutput struct {
Body struct {
APIKey string `json:"apiKey"`
}
}
)
type (
expireApiKeyInput struct {
Body ExpireApiKeyRequestBody
}
expireApiKeyOutput struct {
Body struct{}
}
)
type (
listApiKeysOutput struct {
Body struct {
APIKeys []ApiKey `json:"apiKeys" nullable:"false"`
}
}
)
type (
deleteApiKeyInput struct {
Prefix string `path:"prefix"`
ID string `format:"uint64" query:"id"`
}
deleteApiKeyOutput struct {
Body struct{}
}
)
func registerApiKeys(api huma.API, b Backend) {
huma.Register(api, huma.Operation{
OperationID: "createApiKey",
Method: http.MethodPost,
Path: "/api/v1/apikey",
Summary: "Create API key",
Tags: []string{"ApiKeys"},
Security: bearerAuth,
}, func(ctx context.Context, in *createApiKeyInput) (*createApiKeyOutput, error) {
// CreateAPIKey requires a non-nil pointer; default a missing expiration
// to the zero time as the gRPC handler does.
var expiration time.Time
if in.Body.Expiration != nil {
expiration = *in.Body.Expiration
}
keyStr, _, err := b.State.CreateAPIKey(&expiration)
if err != nil {
return nil, huma.Error500InternalServerError("creating api key", err)
}
out := &createApiKeyOutput{}
out.Body.APIKey = keyStr
return out, nil
})
huma.Register(api, huma.Operation{
OperationID: "expireApiKey",
Method: http.MethodPost,
Path: "/api/v1/apikey/expire",
Summary: "Expire API key",
Tags: []string{"ApiKeys"},
Security: bearerAuth,
}, func(ctx context.Context, in *expireApiKeyInput) (*expireApiKeyOutput, error) {
key, err := lookupApiKey(b, in.Body.ID, in.Body.Prefix)
if err != nil {
return nil, err
}
err = b.State.ExpireAPIKey(key)
if err != nil {
return nil, huma.Error500InternalServerError("expiring api key", err)
}
return &expireApiKeyOutput{}, nil
})
huma.Register(api, huma.Operation{
OperationID: "listApiKeys",
Method: http.MethodGet,
Path: "/api/v1/apikey",
Summary: "List API keys",
Tags: []string{"ApiKeys"},
Security: bearerAuth,
}, func(ctx context.Context, _ *struct{}) (*listApiKeysOutput, error) {
keys, err := b.State.ListAPIKeys()
if err != nil {
return nil, huma.Error500InternalServerError("listing api keys", err)
}
// Match the gRPC handler's ascending-ID ordering.
slices.SortFunc(keys, func(a, b types.APIKey) int {
return cmp.Compare(a.ID, b.ID)
})
out := &listApiKeysOutput{}
out.Body.APIKeys = make([]ApiKey, len(keys))
for i := range keys {
out.Body.APIKeys[i] = apiKeyFromState(&keys[i])
}
return out, nil
})
huma.Register(api, huma.Operation{
OperationID: "deleteApiKey",
Method: http.MethodDelete,
Path: "/api/v1/apikey/{prefix}",
Summary: "Delete API key",
Tags: []string{"ApiKeys"},
Security: bearerAuth,
}, func(ctx context.Context, in *deleteApiKeyInput) (*deleteApiKeyOutput, error) {
key, err := lookupApiKey(b, in.ID, in.Prefix)
if err != nil {
return nil, err
}
err = b.State.DestroyAPIKey(*key)
if err != nil {
return nil, huma.Error500InternalServerError("deleting api key", err)
}
return &deleteApiKeyOutput{}, nil
})
}
// lookupApiKey resolves an API key by id or prefix; exactly one must be
// supplied. An empty or zero id counts as "no id". Unknown id/prefix maps to
// 404 via mapError.
func lookupApiKey(b Backend, idStr, prefix string) (*types.APIKey, error) {
id, err := parseApiKeyID(idStr)
if err != nil {
return nil, err
}
hasID := id != 0
hasPrefix := prefix != ""
switch {
case hasID && hasPrefix:
return nil, badRequest("provide either id or prefix, not both")
return nil, huma.Error400BadRequest("provide either id or prefix, not both")
case hasID:
key, err := s.state.GetAPIKeyByID(id)
key, err := b.State.GetAPIKeyByID(id)
if err != nil {
return nil, mapStateError(err)
return nil, mapError("getting api key", err)
}
return key, nil
case hasPrefix:
key, err := s.state.GetAPIKey(prefix)
key, err := b.State.GetAPIKey(prefix)
if err != nil {
return nil, mapStateError(err)
return nil, mapError("getting api key", err)
}
return key, nil
default:
return nil, badRequest("must provide id or prefix")
return nil, huma.Error400BadRequest("must provide id or prefix")
}
}
// parseApiKeyID decodes the optional uint64 id. Empty maps to zero; non-numeric
// is rejected with 400.
func parseApiKeyID(s string) (uint64, error) {
if s == "" {
return 0, nil
}
id, err := strconv.ParseUint(s, 10, 64)
if err != nil {
return 0, huma.Error400BadRequest("invalid api key id", err)
}
return id, nil
}
// apiKeyFromState converts a domain API key into the v1 response shape, masking
// the prefix so the secret is never returned.
func apiKeyFromState(k *types.APIKey) ApiKey {
return ApiKey{
ID: formatID(k.ID),
Prefix: apiKeyMaskedPrefix(k.Prefix),
Expiration: k.Expiration,
CreatedAt: k.CreatedAt,
LastSeen: k.LastSeen,
}
}
// apiKeyMaskedPrefix reproduces the unexported types.APIKey.maskedPrefix.
func apiKeyMaskedPrefix(prefix string) string {
if len(prefix) == types.NewAPIKeyPrefixLength {
return "hskey-api-" + prefix + "-***"
}
return prefix + "***"
}
+134 -78
View File
@@ -5,102 +5,158 @@ import (
"errors"
"net/http"
oas "github.com/juanfont/headscale/gen/api/v1"
"github.com/danielgtaylor/huma/v2"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/util"
)
func init() {
registrations = append(registrations, registerAuth)
}
// errAuthRejected is the verdict handed to the waiting registration flow when
// an auth session is rejected.
var errAuthRejected = errors.New("auth request rejected")
type socketAuthContextKey struct{}
// WithSocketAuth marks ctx as originating from the trusted local unix socket,
// where filesystem permissions are the trust boundary. Requests carrying this
// marker bypass bearer-token validation. The server mounts this on the
// socket-only listener.
func WithSocketAuth(ctx context.Context) context.Context {
return context.WithValue(ctx, socketAuthContextKey{}, true)
// AuthRegisterRequestBody is the v1.AuthRegisterRequest body.
type AuthRegisterRequestBody struct {
User string `json:"user,omitempty"`
AuthID string `json:"authId,omitempty"`
}
func isSocketAuth(ctx context.Context) bool {
v, _ := ctx.Value(socketAuthContextKey{}).(bool)
return v
// AuthApproveRequestBody is the v1.AuthApproveRequest body.
type AuthApproveRequestBody struct {
AuthID string `json:"authId,omitempty"`
}
// HandleBearerAuth validates the API key bearer token against the state layer.
// A missing or malformed Authorization header is reported by ogen before this
// is reached. Any validation failure — a malformed/unknown key (which
// [state.State.ValidateAPIKey] reports as an error) or an expired/invalid one —
// is a 401, matching the previous middleware which rejected every such case
// with Unauthorized.
func (s *Server) HandleBearerAuth(
ctx context.Context,
_ oas.OperationName,
t oas.BearerAuth,
) (context.Context, error) {
// Requests from the local unix socket are trusted via filesystem permissions.
if isSocketAuth(ctx) {
return ctx, nil
// AuthRejectRequestBody is the v1.AuthRejectRequest body.
type AuthRejectRequestBody struct {
AuthID string `json:"authId,omitempty"`
}
type (
authRegisterInput struct {
Body AuthRegisterRequestBody
}
valid, err := s.state.ValidateAPIKey(t.Token)
if err != nil || !valid {
return ctx, apiError(http.StatusUnauthorized, "invalid API key")
authRegisterOutput struct {
Body struct {
Node Node `json:"node"`
}
}
)
return ctx, nil
}
type (
authApproveInput struct {
Body AuthApproveRequestBody
}
authApproveOutput struct {
Body struct{}
}
)
// AuthRegister registers a node via an auth id; it is an alias of RegisterNode.
func (s *Server) AuthRegister(
ctx context.Context,
req *oas.AuthRegisterReq,
) (*oas.AuthRegisterOK, error) {
resp, err := s.RegisterNode(ctx, oas.RegisterNodeParams{
Key: oas.NewOptString(req.AuthId.Or("")),
User: oas.NewOptString(req.User.Or("")),
type (
authRejectInput struct {
Body AuthRejectRequestBody
}
authRejectOutput struct {
Body struct{}
}
)
func registerAuth(api huma.API, b Backend) {
huma.Register(api, huma.Operation{
OperationID: "authRegister",
Method: http.MethodPost,
Path: "/api/v1/auth/register",
Summary: "Register node via auth flow",
Tags: []string{"Auth"},
Security: bearerAuth,
}, func(ctx context.Context, in *authRegisterInput) (*authRegisterOutput, error) {
// Malformed auth_id is 400; unknown user and missing pending session are
// 404 via mapError, matching the Approve/Reject handlers.
registrationID, err := types.AuthIDFromString(in.Body.AuthID)
if err != nil {
return nil, huma.Error400BadRequest("registering node", err)
}
user, err := b.State.GetUserByName(in.Body.User)
if err != nil {
return nil, mapError("looking up user", err)
}
node, nodeChange, err := b.State.HandleNodeFromAuthPath(
registrationID,
types.UserID(user.ID),
nil,
util.RegisterMethodCLI,
)
if err != nil {
return nil, mapError("registering node", err)
}
routeChange, err := b.State.AutoApproveRoutes(node)
if err != nil {
return nil, huma.Error500InternalServerError("auto approving routes", err)
}
b.Change(nodeChange, routeChange)
out := &authRegisterOutput{}
out.Body.Node = nodeFromView(node)
return out, nil
})
huma.Register(api, huma.Operation{
OperationID: "authApprove",
Method: http.MethodPost,
Path: "/api/v1/auth/approve",
Summary: "Approve a pending auth session",
Tags: []string{"Auth"},
Security: bearerAuth,
}, func(ctx context.Context, in *authApproveInput) (*authApproveOutput, error) {
authReq, err := pendingAuthRequest(b, in.Body.AuthID)
if err != nil {
return nil, err
}
authReq.FinishAuth(types.AuthVerdict{})
return &authApproveOutput{}, nil
})
huma.Register(api, huma.Operation{
OperationID: "authReject",
Method: http.MethodPost,
Path: "/api/v1/auth/reject",
Summary: "Reject a pending auth session",
Tags: []string{"Auth"},
Security: bearerAuth,
}, func(ctx context.Context, in *authRejectInput) (*authRejectOutput, error) {
authReq, err := pendingAuthRequest(b, in.Body.AuthID)
if err != nil {
return nil, err
}
authReq.FinishAuth(types.AuthVerdict{
Err: errAuthRejected,
})
return &authRejectOutput{}, nil
})
}
// pendingAuthRequest looks up the pending session for auth_id. Malformed
// auth_id is 400, unknown is 404.
func pendingAuthRequest(b Backend, rawID string) (*types.AuthRequest, error) {
authID, err := types.AuthIDFromString(rawID)
if err != nil {
return nil, err
return nil, huma.Error400BadRequest("invalid auth_id", err)
}
return &oas.AuthRegisterOK{Node: resp.Node}, nil
}
// AuthApprove approves a pending auth session.
func (s *Server) AuthApprove(_ context.Context, req *oas.AuthApproveReq) error {
authReq, apiErr := s.pendingAuth(req.AuthId.Or(""))
if apiErr != nil {
return apiErr
}
authReq.FinishAuth(types.AuthVerdict{})
return nil
}
// AuthReject rejects a pending auth session.
func (s *Server) AuthReject(_ context.Context, req *oas.AuthRejectReq) error {
authReq, apiErr := s.pendingAuth(req.AuthId.Or(""))
if apiErr != nil {
return apiErr
}
authReq.FinishAuth(types.AuthVerdict{Err: errAuthRejected})
return nil
}
// pendingAuth resolves an auth id to its cached, in-progress auth request.
// An unparseable id is a 400; an unknown one is a 404.
func (s *Server) pendingAuth(authID string) (*types.AuthRequest, *oas.ErrorStatusCode) {
id, err := types.AuthIDFromString(authID)
if err != nil {
return nil, badRequest("invalid auth_id: " + err.Error())
}
authReq, ok := s.state.GetAuthCacheEntry(id)
authReq, ok := b.State.GetAuthCacheEntry(authID)
if !ok {
return nil, notFound("no pending auth session for auth_id " + id.String())
return nil, huma.Error404NotFound("no pending auth session for auth_id " + authID.String())
}
return authReq, nil
-224
View File
@@ -1,224 +0,0 @@
package apiv1
import (
"time"
oas "github.com/juanfont/headscale/gen/api/v1"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/util"
"tailscale.com/types/views"
)
// This file converts the state-layer types into the ogen API types. It reads
// the copy-on-write view types (NodeView, UserView, PreAuthKeyView) directly so
// no node/user is deep-copied on the read path, and reproduces the same fields
// the previous API emitted — username fallback, masked key prefixes, online
// computation, the register-method enum.
//
// These converters omit zero-value and absent fields — empty strings, false booleans, zero
// numbers, empty arrays, nil timestamps/objects, and the unspecified register
// method. See docs/v1-ogen/CHANGES.md.
func optString(s string) oas.OptString {
if s == "" {
return oas.OptString{}
}
return oas.NewOptString(s)
}
func optUint64(v uint64) oas.OptUint64 {
if v == 0 {
return oas.OptUint64{}
}
return oas.NewOptUint64(v)
}
func optBool(b bool) oas.OptBool {
if !b {
return oas.OptBool{}
}
return oas.NewOptBool(b)
}
// optTimeVal sets a timestamp from a value; the previous proto builders always
// emitted these fields (timestamppb.New is never nil), so they are always set.
func optTimeVal(t time.Time) oas.OptDateTime {
return oas.NewOptDateTime(t)
}
// optTimePtr sets a timestamp only when present, matching the proto builders'
// nil checks.
func optTimePtr(t *time.Time) oas.OptDateTime {
if t == nil {
return oas.OptDateTime{}
}
return oas.NewOptDateTime(*t)
}
// optTimeVP sets a timestamp from a view's optional pointer.
func optTimeVP(p views.ValuePointer[time.Time]) oas.OptDateTime {
if !p.Valid() {
return oas.OptDateTime{}
}
return oas.NewOptDateTime(p.Get())
}
// strs normalises an empty slice to nil so it is omitted from the response
// rather than emitted as an empty array.
func strs(s []string) []string {
if len(s) == 0 {
return nil
}
return s
}
func oasRegisterMethod(rm string) oas.OptRegisterMethod {
var v oas.RegisterMethod
switch rm {
case "authkey":
v = "REGISTER_METHOD_AUTH_KEY"
case "oidc":
v = "REGISTER_METHOD_OIDC"
case "cli":
v = "REGISTER_METHOD_CLI"
default:
return oas.OptRegisterMethod{}
}
return oas.NewOptRegisterMethod(v)
}
func oasUser(u types.UserView) oas.User {
// Use Name if set, otherwise the display-friendly Username() fallback.
name := u.Name()
if name == "" {
name = u.Username()
}
return oas.User{
ID: optUint64(uint64(u.Model().ID)),
Name: optString(name),
CreatedAt: optTimeVal(u.Model().CreatedAt),
DisplayName: optString(u.DisplayName()),
Email: optString(u.Email()),
ProviderId: optString(u.ProviderIdentifier().String),
Provider: optString(u.Provider()),
ProfilePicUrl: optString(u.ProfilePicURL()),
}
}
func optUser(u types.UserView) oas.OptUser {
if !u.Valid() {
return oas.OptUser{}
}
return oas.NewOptUser(oasUser(u))
}
func apiKeyMaskedPrefix(prefix string) string {
if len(prefix) == types.NewAPIKeyPrefixLength {
return "hskey-api-" + prefix + "-***"
}
return prefix + "***"
}
func oasAPIKey(k *types.APIKey) oas.ApiKey {
return oas.ApiKey{
ID: optUint64(k.ID),
Prefix: optString(apiKeyMaskedPrefix(k.Prefix)),
Expiration: optTimePtr(k.Expiration),
CreatedAt: optTimePtr(k.CreatedAt),
LastSeen: optTimePtr(k.LastSeen),
}
}
func preAuthKeyMaskedPrefix(prefix string) string {
if prefix != "" {
return "hskey-auth-" + prefix + "-***"
}
return ""
}
func oasPreAuthKey(k types.PreAuthKeyView) oas.PreAuthKey {
out := oas.PreAuthKey{
User: optUser(k.User()),
ID: optUint64(k.ID()),
Reusable: optBool(k.Reusable()),
Ephemeral: optBool(k.Ephemeral()),
Used: optBool(k.Used()),
Expiration: optTimeVP(k.Expiration()),
CreatedAt: optTimeVP(k.CreatedAt()),
AclTags: strs(k.Tags().AsSlice()),
}
// New keys show the masked prefix; legacy keys (with a plaintext key) show
// the full key for backwards compatibility.
if masked := preAuthKeyMaskedPrefix(k.Prefix()); masked != "" {
out.Key = optString(masked)
} else if k.Key() != "" {
out.Key = optString(k.Key())
}
return out
}
// oasPreAuthKeyNew converts a freshly created pre-auth key, which exposes the
// full secret key exactly once and has no view type.
func oasPreAuthKeyNew(k *types.PreAuthKeyNew) oas.PreAuthKey {
out := oas.PreAuthKey{
ID: optUint64(k.ID),
Key: optString(k.Key),
Reusable: optBool(k.Reusable),
Ephemeral: optBool(k.Ephemeral),
Expiration: optTimePtr(k.Expiration),
CreatedAt: optTimePtr(k.CreatedAt),
AclTags: strs(k.Tags),
}
if k.User != nil {
out.User = oas.NewOptUser(oasUser(k.User.View()))
}
return out
}
// oasNode converts a node view. user is the user to present (the node's own
// user for most callers, or TaggedDevices for tagged nodes); subnetRoutes
// carries the routes actively served from the node (primary + exit), which only
// some callers populate.
func oasNode(nv types.NodeView, user types.UserView, subnetRoutes []string) oas.Node {
out := oas.Node{
ID: optUint64(uint64(nv.ID())),
MachineKey: optString(nv.MachineKey().String()),
NodeKey: optString(nv.NodeKey().String()),
DiscoKey: optString(nv.DiscoKey().String()),
IpAddresses: strs(nv.IPsAsString()),
Name: optString(nv.Hostname()),
User: optUser(user),
CreatedAt: optTimeVal(nv.CreatedAt()),
RegisterMethod: oasRegisterMethod(nv.RegisterMethod()),
GivenName: optString(nv.GivenName()),
Online: optBool(nv.IsOnline().GetOr(false)),
ApprovedRoutes: strs(util.PrefixesToString(nv.ApprovedRoutes().AsSlice())),
AvailableRoutes: strs(util.PrefixesToString(nv.AnnouncedRoutes())),
SubnetRoutes: strs(subnetRoutes),
Tags: strs(nv.Tags().AsSlice()),
LastSeen: optTimeVP(nv.LastSeen()),
Expiry: optTimeVP(nv.Expiry()),
}
if nv.AuthKey().Valid() {
out.PreAuthKey = oas.NewOptPreAuthKey(oasPreAuthKey(nv.AuthKey()))
}
return out
}
+30 -136
View File
@@ -1,155 +1,49 @@
package apiv1
import (
"context"
"encoding/json"
"errors"
"net/http"
oas "github.com/juanfont/headscale/gen/api/v1"
"github.com/danielgtaylor/huma/v2"
"github.com/juanfont/headscale/hscontrol/db"
"github.com/juanfont/headscale/hscontrol/state"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/ogen-go/ogen/ogenerrors"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
)
const problemContentType = "application/problem+json"
// apiError builds an RFC 7807 problem response with the given HTTP status and
// detail. Handlers return it so expected errors render as problem documents
// with the correct status code (ogen encodes [oas.ErrorStatusCode] directly,
// without going through [errorHandler]).
func apiError(status int, detail string) *oas.ErrorStatusCode {
return &oas.ErrorStatusCode{
StatusCode: status,
Response: oas.Problem{
Title: oas.NewOptString(http.StatusText(status)),
//nolint:gosec // G115: status is an HTTP status code, always within int32.
Status: oas.NewOptInt32(int32(status)),
Detail: oas.NewOptString(detail),
},
// mapError translates a state/db-layer error into a Huma HTTP error
// (NotFound→404, invalid input→400, conflict→409, everything else→500).
// Handlers use this default mapping and may return a more specific huma.ErrorN
// directly. msg is a human context prefix, e.g. "getting node".
func mapError(msg string, err error) error {
if err == nil {
return nil
}
}
func notFound(
detail string,
) *oas.ErrorStatusCode {
return apiError(http.StatusNotFound, detail)
}
func badRequest(
detail string,
) *oas.ErrorStatusCode {
return apiError(http.StatusBadRequest, detail)
}
func internalError(detail string) *oas.ErrorStatusCode {
return apiError(http.StatusInternalServerError, detail)
}
// mapStateError classifies an error returned by the state layer into an HTTP
// problem response. Not-found sentinels become 404; everything else is a 500.
// Handlers that need a different status (e.g. validation 400) build the problem
// explicitly with [badRequest] rather than routing through here.
func mapStateError(err error) *oas.ErrorStatusCode {
switch {
case errors.Is(err, gorm.ErrRecordNotFound),
errors.Is(err, state.ErrNodeNotFound),
errors.Is(err, state.ErrNodeNotInNodeStore),
errors.Is(err, db.ErrUserNotFound):
return notFound(err.Error())
case errors.Is(err, types.ErrPolicyUpdateIsDisabled):
return badRequest(err.Error())
errors.Is(err, db.ErrUserNotFound),
errors.Is(err, db.ErrNodeNotFoundRegistrationCache),
errors.Is(err, state.ErrRegistrationExpired):
return huma.Error404NotFound(msg, err)
case errors.Is(err, state.ErrGivenNameInvalid),
errors.Is(err, state.ErrGivenNameTaken),
errors.Is(err, state.ErrNodeNameNotUnique),
errors.Is(err, state.ErrNodeMarkedTaggedButHasNoTags),
errors.Is(err, state.ErrNodeHasNeitherUserNorTags),
errors.Is(err, state.ErrRequestedTagsInvalidOrNotPermitted),
errors.Is(err, db.ErrUserStillHasNodes),
errors.Is(err, db.ErrCannotChangeOIDCUser),
errors.Is(err, db.ErrPreAuthKeyNotTaggedOrOwned),
errors.Is(err, db.ErrSingleUseAuthKeyHasBeenUsed):
return huma.Error400BadRequest(msg, err)
case errors.Is(err, state.ErrNodeKeyInUse),
errors.Is(err, state.ErrAmbiguousNodeOwnership):
return huma.Error409Conflict(msg, err)
default:
return internalError(err.Error())
return huma.Error500InternalServerError(msg, err)
}
}
// NewError converts an error that ogen raises outside a handler return value —
// failed authentication, request decoding — into a typed problem response. ogen
// calls it from the generated security/decoding paths; the resulting
// [oas.ErrorStatusCode] is then encoded as application/problem+json.
func (s *Server) NewError(_ context.Context, err error) *oas.ErrorStatusCode {
return classify(err)
}
// errorHandler renders problems for the remaining ogen error path: a plain
// error returned from a handler. Expected errors are returned as
// [oas.ErrorStatusCode] (encoded directly by ogen), so this is the safety net
// for anything else.
func errorHandler(
_ context.Context,
w http.ResponseWriter,
_ *http.Request,
err error,
) {
writeProblem(w, classify(err))
}
// classify maps an arbitrary error to a problem response. An already-typed
// [oas.ErrorStatusCode] passes through; ogen's framework errors carry their own
// HTTP status via Code() (security -> 401, decode -> 400); everything else is
// classified by [mapStateError].
func classify(err error) *oas.ErrorStatusCode {
var esc *oas.ErrorStatusCode
if errors.As(err, &esc) {
return esc
}
// A failed security requirement (missing/malformed bearer) must not echo
// ogen's internal "operation X: security ...: not satisfied" message, which
// leaks the operation name. Return a minimal 401.
var secErr *ogenerrors.SecurityError
if errors.As(err, &secErr) {
return apiError(http.StatusUnauthorized, "valid API key required")
}
var coder interface{ Code() int }
if errors.As(err, &coder) {
return apiError(coder.Code(), err.Error())
}
return mapStateError(err)
}
func writeProblem(w http.ResponseWriter, esc *oas.ErrorStatusCode) {
w.Header().Set("Content-Type", problemContentType)
w.WriteHeader(esc.StatusCode)
p := esc.Response
status := esc.StatusCode
if v, ok := p.Status.Get(); ok {
status = int(v)
}
body := problemJSON{
Title: p.Title.Or(""),
Status: status,
Detail: p.Detail.Or(""),
}
if v, ok := p.Type.Get(); ok {
body.Type = v.String()
}
if v, ok := p.Instance.Get(); ok {
body.Instance = v.String()
}
err := json.NewEncoder(w).Encode(body)
if err != nil {
log.Error().Err(err).Msg("writing problem response failed")
}
}
// problemJSON mirrors [oas.Problem] for hand-written encoding in
// [errorHandler]; ogen's own encoder is package-private.
type problemJSON struct {
Type string `json:"type,omitempty"`
Title string `json:"title,omitempty"`
Status int `json:"status,omitempty"`
Detail string `json:"detail,omitempty"`
Instance string `json:"instance,omitempty"`
}
-32
View File
@@ -1,32 +0,0 @@
package apiv1
import (
"errors"
"net/http"
"strings"
"testing"
"github.com/ogen-go/ogen/ogenerrors"
)
var errSecurityNotSatisfied = errors.New(
`operation ListUsers: security "": security requirement is not satisfied`,
)
// TestClassifySecurityErrorIsMinimal ensures a failed security requirement
// becomes a clean 401 that does not leak ogen's internal operation/security
// message.
func TestClassifySecurityErrorIsMinimal(t *testing.T) {
secErr := &ogenerrors.SecurityError{Err: errSecurityNotSatisfied}
esc := classify(secErr)
if esc.StatusCode != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", esc.StatusCode)
}
detail := esc.Response.Detail.Or("")
if strings.Contains(detail, "operation") || strings.Contains(detail, "ListUsers") {
t.Errorf("401 detail leaks internals: %q", detail)
}
}
+33 -11
View File
@@ -2,18 +2,40 @@ package apiv1
import (
"context"
"net/http"
oas "github.com/juanfont/headscale/gen/api/v1"
"github.com/danielgtaylor/huma/v2"
)
// Health reports server health, including database connectivity. A failed
// database ping is a 500; the previous implementation likewise returned the ping
// error (the body's databaseConnectivity flag was never observable on failure).
func (s *Server) Health(ctx context.Context) (*oas.HealthOK, error) {
err := s.state.PingDB(ctx)
if err != nil {
return nil, internalError("pinging database: " + err.Error())
}
return &oas.HealthOK{DatabaseConnectivity: oas.NewOptBool(true)}, nil
// HealthResponseBody mirrors the v1 HealthResponse message. database_connectivity
// is reported true only when the database responds to a ping.
type HealthResponseBody struct {
DatabaseConnectivity bool `json:"databaseConnectivity"`
}
type healthOutput struct {
Body HealthResponseBody
}
func init() {
registrations = append(registrations, registerHealth)
}
func registerHealth(api huma.API, b Backend) {
huma.Register(api, huma.Operation{
OperationID: "health",
Method: http.MethodGet,
Path: "/api/v1/health",
Summary: "Health check",
Description: "Reports server health, including database connectivity.",
Tags: []string{"Health"},
Security: bearerAuth,
}, func(ctx context.Context, _ *struct{}) (*healthOutput, error) {
err := b.State.PingDB(ctx)
if err != nil {
return nil, mapError("pinging database", err)
}
return &healthOutput{Body: HealthResponseBody{DatabaseConnectivity: true}}, nil
})
}
+678 -289
View File
@@ -1,313 +1,702 @@
package apiv1
import (
"cmp"
"context"
"fmt"
"errors"
"net/http"
"net/netip"
"slices"
"strconv"
"time"
oas "github.com/juanfont/headscale/gen/api/v1"
"github.com/danielgtaylor/huma/v2"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/util"
"tailscale.com/net/tsaddr"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
"tailscale.com/types/views"
)
// RegisterNode registers a node to a user using a registration id, then
// auto-approves its routes.
func (s *Server) RegisterNode(
_ context.Context,
params oas.RegisterNodeParams,
) (*oas.RegisterNodeOK, error) {
registrationID, err := types.AuthIDFromString(params.Key.Or(""))
if err != nil {
return nil, badRequest(err.Error())
}
user, err := s.state.GetUserByName(params.User.Or(""))
if err != nil {
return nil, mapStateError(fmt.Errorf("looking up user: %w", err))
}
node, nodeChange, err := s.state.HandleNodeFromAuthPath(
registrationID,
types.UserID(user.ID),
nil,
util.RegisterMethodCLI,
)
if err != nil {
return nil, mapStateError(err)
}
routeChange, err := s.state.AutoApproveRoutes(node)
if err != nil {
return nil, internalError("auto approving routes: " + err.Error())
}
s.change(nodeChange, routeChange)
return &oas.RegisterNodeOK{Node: oas.NewOptNode(oasNode(node, node.User(), nil))}, nil
func init() {
registrations = append(registrations, registerNodes)
}
// GetNode returns a node by id.
func (s *Server) GetNode(_ context.Context, params oas.GetNodeParams) (*oas.GetNodeOK, error) {
node, ok := s.state.GetNodeByID(types.NodeID(params.NodeID))
if !ok {
return nil, notFound("node not found")
}
// errBackfillNotConfirmed guards BackfillNodeIPs behind explicit confirmed=true.
var errBackfillNotConfirmed = errors.New("not confirmed, aborting")
return &oas.GetNodeOK{Node: oas.NewOptNode(oasNode(node, node.User(), nil))}, nil
// registerMethodToV1Enum maps the stored register method onto the
// SCREAMING_SNAKE enum string the v1 contract emits.
var registerMethodToV1Enum = map[string]string{
util.RegisterMethodAuthKey: "REGISTER_METHOD_AUTH_KEY",
util.RegisterMethodOIDC: "REGISTER_METHOD_OIDC",
util.RegisterMethodCLI: "REGISTER_METHOD_CLI",
}
// SetTags sets the ACL tags of a node, converting it to a tagged node.
func (s *Server) SetTags(
_ context.Context,
req *oas.SetTagsReq,
params oas.SetTagsParams,
) (*oas.SetTagsOK, error) {
if len(req.Tags) == 0 {
return nil, badRequest(
"cannot remove all tags from a node - tagged nodes must have at least one tag",
// Node mirrors the v1 Node message. The protojson contract emits unpopulated
// fields: scalars and slices always (no omitempty), nested messages and optional
// timestamps as JSON null when unset.
type Node struct {
ID string `format:"uint64" json:"id"`
MachineKey string `json:"machineKey"`
NodeKey string `json:"nodeKey"`
DiscoKey string `json:"discoKey"`
IPAddresses []string `json:"ipAddresses" nullable:"false"`
Name string `json:"name"`
User *User `json:"user"`
LastSeen *time.Time `json:"lastSeen" nullable:"true"`
Expiry *time.Time `json:"expiry" nullable:"true"`
PreAuthKey *NodePreAuthKey `json:"preAuthKey"`
CreatedAt time.Time `json:"createdAt"`
RegisterMethod string `enum:"REGISTER_METHOD_UNSPECIFIED,REGISTER_METHOD_AUTH_KEY,REGISTER_METHOD_CLI,REGISTER_METHOD_OIDC" json:"registerMethod"`
GivenName string `json:"givenName"`
Online bool `json:"online"`
ApprovedRoutes []string `json:"approvedRoutes" nullable:"false"`
AvailableRoutes []string `json:"availableRoutes" nullable:"false"`
SubnetRoutes []string `json:"subnetRoutes" nullable:"false"`
Tags []string `json:"tags" nullable:"false"`
}
// NodePreAuthKey is the PreAuthKey shape embedded in a Node response. The
// /preauthkey endpoints own the standalone request/response surface.
type NodePreAuthKey struct {
User *User `json:"user"`
ID string `format:"uint64" json:"id"`
Key string `json:"key"`
Reusable bool `json:"reusable"`
Ephemeral bool `json:"ephemeral"`
Used bool `json:"used"`
Expiration *time.Time `json:"expiration" nullable:"true"`
CreatedAt *time.Time `json:"createdAt" nullable:"true"`
AclTags []string `json:"aclTags" nullable:"false"`
}
// SetTagsRequestBody mirrors v1.SetTagsRequest.
type SetTagsRequestBody struct {
Tags []string `json:"tags,omitempty"`
}
// SetApprovedRoutesRequestBody mirrors v1.SetApprovedRoutesRequest.
type SetApprovedRoutesRequestBody struct {
Routes []string `json:"routes,omitempty"`
}
// DebugCreateNodeRequestBody mirrors v1.DebugCreateNodeRequest.
type DebugCreateNodeRequestBody struct {
User string `json:"user,omitempty"`
Key string `json:"key,omitempty"`
Name string `json:"name,omitempty"`
Routes []string `json:"routes,omitempty"`
}
type (
getNodeInput struct {
NodeID string `format:"uint64" path:"nodeId"`
}
nodeOutput struct {
Body struct {
Node Node `json:"node"`
}
}
)
type (
listNodesInput struct {
User string `query:"user"`
}
listNodesOutput struct {
Body struct {
Nodes []Node `json:"nodes" nullable:"false"`
}
}
)
type (
deleteNodeInput struct {
NodeID string `format:"uint64" path:"nodeId"`
}
deleteNodeOutput struct {
Body struct{}
}
)
// ExpireNodeRequestBody mirrors v1.ExpireNodeRequest. Both fields are optional;
// an absent or all-zero body expires the node immediately, as gRPC does.
type ExpireNodeRequestBody struct {
Expiry *time.Time `json:"expiry,omitempty"`
DisableExpiry bool `json:"disableExpiry,omitempty"`
}
type expireNodeInput struct {
NodeID string `format:"uint64" path:"nodeId"`
Body *ExpireNodeRequestBody `required:"false"`
}
type renameNodeInput struct {
NodeID string `format:"uint64" path:"nodeId"`
NewName string `path:"newName"`
}
type setTagsInput struct {
NodeID string `format:"uint64" path:"nodeId"`
Body SetTagsRequestBody
}
type setApprovedRoutesInput struct {
NodeID string `format:"uint64" path:"nodeId"`
Body SetApprovedRoutesRequestBody
}
type registerNodeInput struct {
User string `query:"user"`
Key string `query:"key"`
}
type backfillNodeIPsInput struct {
Confirmed bool `query:"confirmed"`
}
type backfillNodeIPsOutput struct {
Body struct {
Changes []string `json:"changes" nullable:"false"`
}
}
type debugCreateNodeInput struct {
Body DebugCreateNodeRequestBody
}
func registerNodes(api huma.API, b Backend) {
registerNodeReadOps(api, b)
registerNodeWriteOps(api, b)
registerNodeAdminOps(api, b)
}
func registerNodeReadOps(api huma.API, b Backend) {
huma.Register(api, huma.Operation{
OperationID: "getNode",
Method: http.MethodGet,
Path: "/api/v1/node/{nodeId}",
Summary: "Get node",
Tags: []string{"Nodes"},
Security: bearerAuth,
}, func(ctx context.Context, in *getNodeInput) (*nodeOutput, error) {
nodeID, err := parseNodeID(in.NodeID)
if err != nil {
return nil, err
}
node, ok := b.State.GetNodeByID(nodeID)
if !ok {
return nil, huma.Error404NotFound("node not found")
}
out := &nodeOutput{}
out.Body.Node = nodeFromView(node)
return out, nil
})
huma.Register(api, huma.Operation{
OperationID: "listNodes",
Method: http.MethodGet,
Path: "/api/v1/node",
Summary: "List nodes",
Tags: []string{"Nodes"},
Security: bearerAuth,
}, func(ctx context.Context, in *listNodesInput) (*listNodesOutput, error) {
nodes := b.State.ListNodes()
if in.User != "" {
user, err := b.State.GetUserByName(in.User)
if err != nil {
return nil, mapError("listing nodes", err)
}
nodes = b.State.ListNodesByUser(types.UserID(user.ID))
}
out := &listNodesOutput{}
out.Body.Nodes = make([]Node, nodes.Len())
for i, node := range nodes.All() {
n := nodeFromView(node)
// Tags-as-identity: tagged nodes are presented as the special
// TaggedDevices user.
if node.IsTagged() {
user := userFromView(types.TaggedDevices.View())
n.User = &user
}
// SubnetRoutes is the routes actively served, exit routes included.
n.SubnetRoutes = util.PrefixesToString(
append(b.State.GetNodePrimaryRoutes(node.ID()), node.ExitRoutes()...),
)
out.Body.Nodes[i] = n
}
// Match the gRPC handler's ascending-ID ordering.
slices.SortFunc(out.Body.Nodes, func(a, b Node) int {
return cmpNodeID(a.ID, b.ID)
})
return out, nil
})
}
func registerNodeWriteOps(api huma.API, b Backend) {
huma.Register(api, huma.Operation{
OperationID: "deleteNode",
Method: http.MethodDelete,
Path: "/api/v1/node/{nodeId}",
Summary: "Delete node",
Tags: []string{"Nodes"},
Security: bearerAuth,
}, func(ctx context.Context, in *deleteNodeInput) (*deleteNodeOutput, error) {
nodeID, err := parseNodeID(in.NodeID)
if err != nil {
return nil, err
}
node, ok := b.State.GetNodeByID(nodeID)
if !ok {
return nil, huma.Error404NotFound("node not found")
}
nodeChange, err := b.State.DeleteNode(node)
if err != nil {
return nil, huma.Error500InternalServerError("deleting node", err)
}
b.Change(nodeChange)
return &deleteNodeOutput{}, nil
})
huma.Register(api, huma.Operation{
OperationID: "expireNode",
Method: http.MethodPost,
Path: "/api/v1/node/{nodeId}/expire",
Summary: "Expire node",
Tags: []string{"Nodes"},
Security: bearerAuth,
}, func(ctx context.Context, in *expireNodeInput) (*nodeOutput, error) {
nodeID, err := parseNodeID(in.NodeID)
if err != nil {
return nil, err
}
// gRPC parity: disableExpiry => nil expiry (never expires); explicit
// expiry honoured; absent/zero body expires now. Both set is a 400.
var (
disableExpiry bool
customExpiry *time.Time
)
if in.Body != nil {
disableExpiry = in.Body.DisableExpiry
customExpiry = in.Body.Expiry
}
if disableExpiry && customExpiry != nil {
return nil, huma.Error400BadRequest("cannot set both disable_expiry and expiry")
}
expiry := time.Now()
switch {
case disableExpiry:
node, nodeChange, expErr := b.State.SetNodeExpiry(nodeID, nil)
if expErr != nil {
return nil, mapError("expiring node", expErr)
}
b.Change(nodeChange)
out := &nodeOutput{}
out.Body.Node = nodeFromView(node)
return out, nil
case customExpiry != nil:
expiry = *customExpiry
}
node, nodeChange, err := b.State.SetNodeExpiry(nodeID, &expiry)
if err != nil {
return nil, mapError("expiring node", err)
}
b.Change(nodeChange)
out := &nodeOutput{}
out.Body.Node = nodeFromView(node)
return out, nil
})
huma.Register(api, huma.Operation{
OperationID: "renameNode",
Method: http.MethodPost,
Path: "/api/v1/node/{nodeId}/rename/{newName}",
Summary: "Rename node",
Tags: []string{"Nodes"},
Security: bearerAuth,
}, func(ctx context.Context, in *renameNodeInput) (*nodeOutput, error) {
nodeID, err := parseNodeID(in.NodeID)
if err != nil {
return nil, err
}
node, nodeChange, err := b.State.RenameNode(nodeID, in.NewName)
if err != nil {
return nil, mapError("renaming node", err)
}
b.Change(nodeChange)
out := &nodeOutput{}
out.Body.Node = nodeFromView(node)
return out, nil
})
huma.Register(api, huma.Operation{
OperationID: "setTags",
Method: http.MethodPost,
Path: "/api/v1/node/{nodeId}/tags",
Summary: "Set tags",
Tags: []string{"Nodes"},
Security: bearerAuth,
}, func(ctx context.Context, in *setTagsInput) (*nodeOutput, error) {
nodeID, err := parseNodeID(in.NodeID)
if err != nil {
return nil, err
}
// Tagged nodes must keep at least one tag, so reject an empty set
// before touching state, as gRPC does.
if len(in.Body.Tags) == 0 {
return nil, huma.Error400BadRequest(
"cannot remove all tags from a node - tagged nodes must have at least one tag",
)
}
for _, tag := range in.Body.Tags {
tagErr := validateTag(tag)
if tagErr != nil {
return nil, huma.Error400BadRequest("setting tags", tagErr)
}
}
_, found := b.State.GetNodeByID(nodeID)
if !found {
return nil, huma.Error404NotFound("node not found")
}
node, nodeChange, err := b.State.SetNodeTags(nodeID, in.Body.Tags)
if err != nil {
return nil, huma.Error400BadRequest("setting tags", err)
}
b.Change(nodeChange)
out := &nodeOutput{}
out.Body.Node = nodeFromView(node)
return out, nil
})
}
func registerNodeAdminOps(api huma.API, b Backend) {
huma.Register(api, huma.Operation{
OperationID: "setApprovedRoutes",
Method: http.MethodPost,
Path: "/api/v1/node/{nodeId}/approve_routes",
Summary: "Set approved routes",
Tags: []string{"Nodes"},
Security: bearerAuth,
}, func(ctx context.Context, in *setApprovedRoutesInput) (*nodeOutput, error) {
nodeID, err := parseNodeID(in.NodeID)
if err != nil {
return nil, err
}
var newApproved []netip.Prefix
for _, route := range in.Body.Routes {
prefix, parseErr := netip.ParsePrefix(route)
if parseErr != nil {
return nil, huma.Error400BadRequest("parsing route", parseErr)
}
// One exit route implies both families, else the client won't
// annotate the node as an exit node.
if prefix == tsaddr.AllIPv4() || prefix == tsaddr.AllIPv6() {
newApproved = append(newApproved, tsaddr.AllIPv4(), tsaddr.AllIPv6())
} else {
newApproved = append(newApproved, prefix)
}
}
slices.SortFunc(newApproved, netip.Prefix.Compare)
newApproved = slices.Compact(newApproved)
node, nodeChange, err := b.State.SetApprovedRoutes(nodeID, newApproved)
if err != nil {
return nil, mapError("setting approved routes", err)
}
b.Change(nodeChange)
out := &nodeOutput{}
out.Body.Node = nodeFromView(node)
// SubnetRoutes here excludes exit routes, unlike the list handler.
out.Body.Node.SubnetRoutes = util.PrefixesToString(
b.State.GetNodePrimaryRoutes(node.ID()),
)
return out, nil
})
huma.Register(api, huma.Operation{
OperationID: "registerNode",
Method: http.MethodPost,
Path: "/api/v1/node/register",
Summary: "Register node",
Tags: []string{"Nodes"},
Security: bearerAuth,
}, func(ctx context.Context, in *registerNodeInput) (*nodeOutput, error) {
registrationID, err := types.AuthIDFromString(in.Key)
if err != nil {
return nil, huma.Error400BadRequest("registering node", err)
}
user, err := b.State.GetUserByName(in.User)
if err != nil {
return nil, mapError("looking up user", err)
}
node, nodeChange, err := b.State.HandleNodeFromAuthPath(
registrationID,
types.UserID(user.ID),
nil,
util.RegisterMethodCLI,
)
if err != nil {
return nil, mapError("registering node", err)
}
routeChange, err := b.State.AutoApproveRoutes(node)
if err != nil {
return nil, huma.Error500InternalServerError("auto approving routes", err)
}
// Empty changes are ignored by the change sink.
b.Change(nodeChange, routeChange)
out := &nodeOutput{}
out.Body.Node = nodeFromView(node)
return out, nil
})
huma.Register(api, huma.Operation{
OperationID: "backfillNodeIPs",
Method: http.MethodPost,
Path: "/api/v1/node/backfillips",
Summary: "Backfill node IPs",
Tags: []string{"Nodes"},
Security: bearerAuth,
}, func(ctx context.Context, in *backfillNodeIPsInput) (*backfillNodeIPsOutput, error) {
if !in.Confirmed {
return nil, huma.Error400BadRequest("backfilling node IPs", errBackfillNotConfirmed)
}
changes, err := b.State.BackfillNodeIPs()
if err != nil {
return nil, huma.Error500InternalServerError("backfilling node IPs", err)
}
out := &backfillNodeIPsOutput{}
out.Body.Changes = changes
if out.Body.Changes == nil {
out.Body.Changes = []string{}
}
return out, nil
})
huma.Register(api, huma.Operation{
OperationID: "debugCreateNode",
Method: http.MethodPost,
Path: "/api/v1/debug/node",
Summary: "Debug create node",
Tags: []string{"Nodes"},
Security: bearerAuth,
}, func(ctx context.Context, in *debugCreateNodeInput) (*nodeOutput, error) {
user, err := b.State.GetUserByName(in.Body.User)
if err != nil {
return nil, mapError("looking up user", err)
}
routes, err := util.StringToIPPrefix(in.Body.Routes)
if err != nil {
return nil, huma.Error400BadRequest("parsing routes", err)
}
registrationID, err := types.AuthIDFromString(in.Body.Key)
if err != nil {
return nil, huma.Error400BadRequest("debug creating node", err)
}
regData := &types.RegistrationData{
NodeKey: key.NewNode().Public(),
MachineKey: key.NewMachine().Public(),
Hostname: in.Body.Name,
Expiry: &time.Time{}, // zero time, not nil, to keep proto JSON round-trip semantics
}
authRegReq := types.NewRegisterAuthRequest(regData)
b.State.SetAuthCacheEntry(registrationID, authRegReq)
// Synthetic echo; the real node is created later via the auth path
// from the cached registration data.
echoNode := types.Node{
NodeKey: regData.NodeKey,
MachineKey: regData.MachineKey,
Hostname: regData.Hostname,
User: user,
Expiry: &time.Time{},
LastSeen: &time.Time{},
Hostinfo: &tailcfg.Hostinfo{
Hostname: in.Body.Name,
OS: "TestOS",
RoutableIPs: routes,
},
}
out := &nodeOutput{}
out.Body.Node = nodeFromView(echoNode.View())
return out, nil
})
}
// nodeFromView builds the Node response from a NodeView, reading through the
// view accessors. SubnetRoutes is left empty; callers that serve routes set it
// explicitly.
func nodeFromView(view types.NodeView) Node {
n := Node{
ID: view.StringID(),
MachineKey: view.MachineKey().String(),
NodeKey: view.NodeKey().String(),
DiscoKey: view.DiscoKey().String(),
IPAddresses: nonNilStrings(view.IPsAsString()),
Name: view.Hostname(),
CreatedAt: view.CreatedAt(),
RegisterMethod: registerMethodEnum(view.RegisterMethod()),
GivenName: view.GivenName(),
Online: view.IsOnline().Valid() && view.IsOnline().Get(),
ApprovedRoutes: nonNilStrings(util.PrefixesToString(view.ApprovedRoutes().AsSlice())),
AvailableRoutes: nonNilStrings(util.PrefixesToString(view.AnnouncedRoutes())),
SubnetRoutes: []string{},
Tags: nonNilStrings(view.Tags().AsSlice()),
}
if view.User().Valid() {
user := userFromView(view.User())
n.User = &user
}
if view.AuthKey().Valid() {
n.PreAuthKey = nodePreAuthKeyFromView(view.AuthKey())
}
if view.LastSeen().Valid() {
ls := view.LastSeen().Get()
n.LastSeen = &ls
}
if view.Expiry().Valid() {
exp := view.Expiry().Get()
n.Expiry = &exp
}
return n
}
// nodePreAuthKeyFromView builds the embedded NodePreAuthKey, masking the key to
// its prefix (legacy plaintext keys are shown in full).
func nodePreAuthKeyFromView(key types.PreAuthKeyView) *NodePreAuthKey {
pak := &NodePreAuthKey{
ID: formatID(key.ID()),
Key: maskedPreAuthKey(key),
Reusable: key.Reusable(),
Ephemeral: key.Ephemeral(),
Used: key.Used(),
AclTags: nonNilStrings(key.Tags().AsSlice()),
}
if key.User().Valid() {
user := userFromView(key.User())
pak.User = &user
}
if key.Expiration().Valid() {
exp := key.Expiration().Get()
pak.Expiration = &exp
}
if key.CreatedAt().Valid() {
created := key.CreatedAt().Get()
pak.CreatedAt = &created
}
return pak
}
// registerMethodEnum maps the stored register method onto the v1 enum string,
// defaulting to REGISTER_METHOD_UNSPECIFIED for unknown values.
func registerMethodEnum(method string) string {
if enum, ok := registerMethodToV1Enum[method]; ok {
return enum
}
return "REGISTER_METHOD_UNSPECIFIED"
}
func nonNilStrings(s []string) []string {
if s == nil {
return []string{}
}
return s
}
// cmpNodeID orders two decimal node-ID strings numerically, matching the gRPC
// handler's ascending-ID ordering.
func cmpNodeID(a, b string) int {
ai, _ := strconv.ParseUint(a, 10, 64)
bi, _ := strconv.ParseUint(b, 10, 64)
switch {
case ai < bi:
return -1
case ai > bi:
return 1
default:
return 0
}
}
func parseNodeID(s string) (types.NodeID, error) {
id, err := strconv.ParseUint(s, 10, 64)
if err != nil {
return 0, huma.Error400BadRequest(
"type mismatch, parameter: node_id, error: " + err.Error(),
)
}
for _, tag := range req.Tags {
err := validateTag(tag)
if err != nil {
return nil, badRequest(err.Error())
}
}
_, found := s.state.GetNodeByID(types.NodeID(params.NodeID))
if !found {
return nil, notFound("node not found")
}
node, nodeChange, err := s.state.SetNodeTags(types.NodeID(params.NodeID), req.Tags)
if err != nil {
return nil, badRequest(err.Error())
}
s.change(nodeChange)
return &oas.SetTagsOK{Node: oas.NewOptNode(oasNode(node, node.User(), nil))}, nil
}
// SetApprovedRoutes sets the approved subnet routes of a node, expanding exit
// routes to cover both address families.
func (s *Server) SetApprovedRoutes(
_ context.Context,
req *oas.SetApprovedRoutesReq,
params oas.SetApprovedRoutesParams,
) (*oas.SetApprovedRoutesOK, error) {
var newApproved []netip.Prefix
for _, route := range req.Routes {
prefix, err := netip.ParsePrefix(route)
if err != nil {
return nil, badRequest("parsing route: " + err.Error())
}
// An exit route is annotated by both v4 and v6 default routes.
if prefix == tsaddr.AllIPv4() || prefix == tsaddr.AllIPv6() {
newApproved = append(newApproved, tsaddr.AllIPv4(), tsaddr.AllIPv6())
} else {
newApproved = append(newApproved, prefix)
}
}
slices.SortFunc(newApproved, netip.Prefix.Compare)
newApproved = slices.Compact(newApproved)
node, nodeChange, err := s.state.SetApprovedRoutes(types.NodeID(params.NodeID), newApproved)
if err != nil {
return nil, badRequest(err.Error())
}
s.change(nodeChange)
// SubnetRoutes carries only the routes actively served from the node.
subnetRoutes := util.PrefixesToString(s.state.GetNodePrimaryRoutes(node.ID()))
return &oas.SetApprovedRoutesOK{
Node: oas.NewOptNode(oasNode(node, node.User(), subnetRoutes)),
}, nil
}
// DeleteNode deletes a node.
func (s *Server) DeleteNode(_ context.Context, params oas.DeleteNodeParams) error {
node, ok := s.state.GetNodeByID(types.NodeID(params.NodeID))
if !ok {
return notFound("node not found")
}
nodeChange, err := s.state.DeleteNode(node)
if err != nil {
return mapStateError(err)
}
s.change(nodeChange)
return nil
}
// ExpireNode expires a node, or disables its expiry.
func (s *Server) ExpireNode(
_ context.Context,
params oas.ExpireNodeParams,
) (*oas.ExpireNodeOK, error) {
_, hasExpiry := params.Expiry.Get()
if params.DisableExpiry.Or(false) && hasExpiry {
return nil, badRequest("cannot set both disable_expiry and expiry")
}
var expiry *time.Time
if !params.DisableExpiry.Or(false) {
t := time.Now()
if v, ok := params.Expiry.Get(); ok {
t = v
}
expiry = &t
}
node, nodeChange, err := s.state.SetNodeExpiry(types.NodeID(params.NodeID), expiry)
if err != nil {
return nil, mapStateError(err)
}
s.change(nodeChange)
return &oas.ExpireNodeOK{Node: oas.NewOptNode(oasNode(node, node.User(), nil))}, nil
}
// RenameNode renames a node.
func (s *Server) RenameNode(
_ context.Context,
params oas.RenameNodeParams,
) (*oas.RenameNodeOK, error) {
node, nodeChange, err := s.state.RenameNode(types.NodeID(params.NodeID), params.NewName)
if err != nil {
return nil, mapStateError(err)
}
s.change(nodeChange)
return &oas.RenameNodeOK{Node: oas.NewOptNode(oasNode(node, node.User(), nil))}, nil
}
// ListNodes lists nodes, optionally filtered by user, sorted by id.
func (s *Server) ListNodes(
_ context.Context,
params oas.ListNodesParams,
) (*oas.ListNodesOK, error) {
var nodes views.Slice[types.NodeView]
if params.User.Or("") != "" {
user, err := s.state.GetUserByName(params.User.Or(""))
if err != nil {
return nil, mapStateError(err)
}
nodes = s.state.ListNodesByUser(types.UserID(user.ID))
} else {
nodes = s.state.ListNodes()
}
return &oas.ListNodesOK{Nodes: s.nodesToOAS(nodes)}, nil
}
// nodesToOAS converts a slice of node views to API nodes, presenting tagged
// nodes as the TaggedDevices user and populating SubnetRoutes with the routes
// actively served from each node.
func (s *Server) nodesToOAS(nodes views.Slice[types.NodeView]) []oas.Node {
out := make([]oas.Node, nodes.Len())
for index, node := range nodes.All() {
// Tagged nodes are presented as the TaggedDevices user.
user := node.User()
if node.IsTagged() {
user = types.TaggedDevices.View()
}
subnetRoutes := util.PrefixesToString(
append(s.state.GetNodePrimaryRoutes(node.ID()), node.ExitRoutes()...),
)
out[index] = oasNode(node, user, subnetRoutes)
}
slices.SortFunc(out, func(a, b oas.Node) int { return cmp.Compare(a.ID.Or(0), b.ID.Or(0)) })
return out
}
// BackfillNodeIPs backfills missing IP addresses for all nodes. It must be
// explicitly confirmed.
func (s *Server) BackfillNodeIPs(
_ context.Context,
params oas.BackfillNodeIPsParams,
) (*oas.BackfillNodeIPsOK, error) {
if !params.Confirmed.Or(false) {
return nil, badRequest("not confirmed, aborting")
}
changes, err := s.state.BackfillNodeIPs()
if err != nil {
return nil, mapStateError(err)
}
return &oas.BackfillNodeIPsOK{Changes: changes}, nil
}
// DebugCreateNode caches a synthetic node registration for testing and echoes
// back a node describing it. The real node is created later via AuthApprove.
func (s *Server) DebugCreateNode(
_ context.Context,
req *oas.DebugCreateNodeReq,
) (*oas.DebugCreateNodeOK, error) {
user, err := s.state.GetUserByName(req.User.Or(""))
if err != nil {
return nil, mapStateError(err)
}
routes, err := util.StringToIPPrefix(req.Routes)
if err != nil {
return nil, badRequest(err.Error())
}
registrationID, err := types.AuthIDFromString(req.Key.Or(""))
if err != nil {
return nil, badRequest(err.Error())
}
regData := &types.RegistrationData{
NodeKey: key.NewNode().Public(),
MachineKey: key.NewMachine().Public(),
Hostname: req.Name.Or(""),
Expiry: &time.Time{},
}
s.state.SetAuthCacheEntry(registrationID, types.NewRegisterAuthRequest(regData))
echoNode := types.Node{
NodeKey: regData.NodeKey,
MachineKey: regData.MachineKey,
Hostname: regData.Hostname,
User: user,
Expiry: &time.Time{},
LastSeen: &time.Time{},
Hostinfo: &tailcfg.Hostinfo{
Hostname: req.Name.Or(""),
OS: "TestOS",
RoutableIPs: routes,
},
}
return &oas.DebugCreateNodeOK{Node: oas.NewOptNode(oasNode(echoNode.View(), echoNode.View().User(), nil))}, nil
return types.NodeID(id), nil
}
+168 -89
View File
@@ -2,111 +2,190 @@ package apiv1
import (
"context"
"fmt"
"io"
"net/http"
"os"
"time"
oas "github.com/juanfont/headscale/gen/api/v1"
"github.com/danielgtaylor/huma/v2"
policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/util"
)
// GetPolicy returns the current ACL policy, from the database or the policy
// file depending on the configured policy mode.
func (s *Server) GetPolicy(_ context.Context) (*oas.GetPolicyOK, error) {
switch s.cfg.Policy.Mode {
case types.PolicyModeDB:
p, err := s.state.GetPolicy()
if err != nil {
return nil, internalError("loading ACL from database: " + err.Error())
}
return &oas.GetPolicyOK{
Policy: oas.NewOptString(p.Data),
UpdatedAt: oas.NewOptDateTime(p.UpdatedAt),
}, nil
case types.PolicyModeFile:
absPath := util.AbsolutePathFromConfigPath(s.cfg.Policy.Path)
b, err := os.ReadFile(absPath)
if err != nil {
return nil, internalError("reading policy from path " + absPath + ": " + err.Error())
}
return &oas.GetPolicyOK{Policy: oas.NewOptString(string(b))}, nil
}
return nil, internalError(
"no supported policy mode found in configuration, policy.mode: " +
string(s.cfg.Policy.Mode),
)
func init() {
registrations = append(registrations, registerPolicy)
}
// SetPolicy stores a new ACL policy (database policy mode only), validating it
// against the live nodes and distributing the resulting changes.
func (s *Server) SetPolicy(_ context.Context, req *oas.SetPolicyReq) (*oas.SetPolicyOK, error) {
if s.cfg.Policy.Mode != types.PolicyModeDB {
return nil, badRequest(types.ErrPolicyUpdateIsDisabled.Error())
// PolicyRequestBody carries the HuJSON policy document as a string, for both
// v1.SetPolicyRequest and v1.CheckPolicyRequest.
type PolicyRequestBody struct {
Policy string `json:"policy,omitempty"`
}
// PolicyResponseBody is the v1.GetPolicyResponse/SetPolicyResponse body. Fields
// carry no omitempty so zero values are emitted (EmitUnpopulated parity).
type PolicyResponseBody struct {
Policy string `json:"policy"`
UpdatedAt time.Time `json:"updatedAt"`
}
type (
getPolicyInput struct{}
getPolicyOutput struct {
Body PolicyResponseBody
}
p := req.Policy.Or("")
// Validate against live nodes, where they exist, before storing.
nodes := s.state.ListNodes()
_, err := s.state.SetPolicy([]byte(p))
if err != nil {
return nil, badRequest("setting policy: " + err.Error())
setPolicyInput struct {
Body PolicyRequestBody
}
setPolicyOutput struct {
Body PolicyResponseBody
}
if nodes.Len() > 0 {
_, err = s.state.SSHPolicy(nodes.At(0))
if err != nil {
return nil, badRequest("verifying SSH rules: " + err.Error())
checkPolicyInput struct {
Body PolicyRequestBody
}
checkPolicyOutput struct {
Body struct{}
}
)
func registerPolicy(api huma.API, b Backend) {
huma.Register(api, huma.Operation{
OperationID: "getPolicy",
Method: http.MethodGet,
Path: "/api/v1/policy",
Summary: "Get policy",
Tags: []string{"Policy"},
Security: bearerAuth,
}, func(ctx context.Context, _ *getPolicyInput) (*getPolicyOutput, error) {
switch b.Cfg.Policy.Mode {
case types.PolicyModeDB:
p, err := b.State.GetPolicy()
if err != nil {
return nil, huma.Error500InternalServerError("loading ACL from database", err)
}
out := &getPolicyOutput{}
out.Body.Policy = p.Data
out.Body.UpdatedAt = p.UpdatedAt
return out, nil
case types.PolicyModeFile:
absPath := util.AbsolutePathFromConfigPath(b.Cfg.Policy.Path)
f, err := os.Open(absPath)
if err != nil {
return nil, huma.Error500InternalServerError(
fmt.Sprintf("reading policy from path %q", absPath), err,
)
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
return nil, huma.Error500InternalServerError("reading policy from file", err)
}
out := &getPolicyOutput{}
out.Body.Policy = string(data)
return out, nil
}
}
updated, err := s.state.SetPolicyInDB(p)
if err != nil {
return nil, mapStateError(err)
}
return nil, huma.Error500InternalServerError(fmt.Sprintf(
"no supported policy mode found in configuration, policy.mode: %q",
b.Cfg.Policy.Mode,
), nil)
})
// Always reload so routes are re-evaluated even when the content is unchanged.
cs, err := s.state.ReloadPolicy()
if err != nil {
return nil, internalError("reloading policy: " + err.Error())
}
huma.Register(api, huma.Operation{
OperationID: "setPolicy",
Method: http.MethodPut,
Path: "/api/v1/policy",
Summary: "Set policy",
Tags: []string{"Policy"},
Security: bearerAuth,
}, func(ctx context.Context, in *setPolicyInput) (*setPolicyOutput, error) {
if b.Cfg.Policy.Mode != types.PolicyModeDB {
// Policy updates are only valid in DB mode; otherwise 400.
return nil, huma.Error400BadRequest(
types.ErrPolicyUpdateIsDisabled.Error(), types.ErrPolicyUpdateIsDisabled,
)
}
if len(cs) > 0 {
s.change(cs...)
}
p := in.Body.Policy
return &oas.SetPolicyOK{
Policy: oas.NewOptString(updated.Data),
UpdatedAt: oas.NewOptDateTime(updated.UpdatedAt),
}, nil
}
// CheckPolicy validates a policy against the live users and nodes without
// storing it. Works regardless of policy mode.
func (s *Server) CheckPolicy(_ context.Context, req *oas.CheckPolicyReq) error {
polB := []byte(req.Policy.Or(""))
users, err := s.state.ListAllUsers()
if err != nil {
return internalError("loading users: " + err.Error())
}
nodes := s.state.ListNodes()
pm, err := policyv2.NewPolicyManager(polB, users, nodes)
if err != nil {
return badRequest(err.Error())
}
_, err = pm.SetPolicy(polB)
if err != nil {
return badRequest(err.Error())
}
return nil
// Reject policy that would fail when building a map response. SSH rule
// validation needs a node, so a server with no nodes can't catch every
// case here.
nodes := b.State.ListNodes()
_, err := b.State.SetPolicy([]byte(p))
if err != nil {
return nil, huma.Error400BadRequest("setting policy", err)
}
if nodes.Len() > 0 {
_, err = b.State.SSHPolicy(nodes.At(0))
if err != nil {
return nil, huma.Error400BadRequest("verifying SSH rules", err)
}
}
updated, err := b.State.SetPolicyInDB(p)
if err != nil {
return nil, huma.Error500InternalServerError("setting policy", err)
}
// Reload even when content is unchanged: routes manually disabled before
// may now qualify for auto-approval, so they must be re-evaluated.
cs, err := b.State.ReloadPolicy()
if err != nil {
return nil, huma.Error500InternalServerError("reloading policy", err)
}
if len(cs) > 0 {
b.Change(cs...)
}
out := &setPolicyOutput{}
out.Body.Policy = updated.Data
out.Body.UpdatedAt = updated.UpdatedAt
return out, nil
})
huma.Register(api, huma.Operation{
OperationID: "checkPolicy",
Method: http.MethodPost,
Path: "/api/v1/policy/check",
Summary: "Check policy",
Description: "Validates the given policy against the server's live users and nodes without persisting it.",
Tags: []string{"Policy"},
Security: bearerAuth,
}, func(ctx context.Context, in *checkPolicyInput) (*checkPolicyOutput, error) {
polB := []byte(in.Body.Policy)
users, err := b.State.ListAllUsers()
if err != nil {
return nil, huma.Error500InternalServerError("loading users", err)
}
nodes := b.State.ListNodes()
pm, err := policyv2.NewPolicyManager(polB, users, nodes)
if err != nil {
return nil, huma.Error400BadRequest(err.Error(), err)
}
_, err = pm.SetPolicy(polB)
if err != nil {
return nil, huma.Error400BadRequest(err.Error(), err)
}
return &checkPolicyOutput{}, nil
})
}
+296 -95
View File
@@ -3,115 +3,316 @@ package apiv1
import (
"cmp"
"context"
"errors"
"net/http"
"slices"
"strings"
"strconv"
"time"
oas "github.com/juanfont/headscale/gen/api/v1"
"github.com/danielgtaylor/huma/v2"
"github.com/juanfont/headscale/hscontrol/types"
)
// CreatePreAuthKey creates a pre-auth key for a user.
func (s *Server) CreatePreAuthKey(
_ context.Context,
req *oas.CreatePreAuthKeyReq,
) (*oas.CreatePreAuthKeyOK, error) {
var expiration time.Time
if v, ok := req.Expiration.Get(); ok {
expiration = v
}
func init() {
registrations = append(registrations, registerPreAuthKeys)
}
for _, tag := range req.AclTags {
err := validateTag(tag)
if err != nil {
return nil, badRequest(err.Error())
// PreAuthKey is the v1 PreAuthKey message. User is a pointer with no omitempty
// so tagged (system-created) keys emit "user":null. Expiration and CreatedAt
// are always emitted, zero-stamped when unset.
type PreAuthKey struct {
User *User `json:"user"`
ID string `format:"uint64" json:"id"`
Key string `json:"key"`
Reusable bool `json:"reusable"`
Ephemeral bool `json:"ephemeral"`
Used bool `json:"used"`
Expiration time.Time `json:"expiration"`
CreatedAt time.Time `json:"createdAt"`
ACLTags []string `json:"aclTags" nullable:"false"`
}
// CreatePreAuthKeyRequestBody is the v1.CreatePreAuthKeyRequest body. Every
// field is optional, hence omitempty throughout.
type CreatePreAuthKeyRequestBody struct {
User string `format:"uint64" json:"user,omitempty"`
Reusable bool `json:"reusable,omitempty"`
Ephemeral bool `json:"ephemeral,omitempty"`
Expiration *time.Time `json:"expiration,omitempty"`
ACLTags []string `json:"aclTags,omitempty"`
}
// ExpirePreAuthKeyRequestBody is the v1.ExpirePreAuthKeyRequest body.
type ExpirePreAuthKeyRequestBody struct {
ID string `format:"uint64" json:"id,omitempty"`
}
type (
createPreAuthKeyInput struct {
Body CreatePreAuthKeyRequestBody
}
preAuthKeyOutput struct {
Body struct {
PreAuthKey PreAuthKey `json:"preAuthKey"`
}
}
var userID *types.UserID
if req.User.Or(0) != 0 {
user, err := s.state.GetUserByID(types.UserID(req.User.Or(0)))
if err != nil {
return nil, mapStateError(err)
}
userID = user.TypedID()
}
preAuthKey, err := s.state.CreatePreAuthKey(
userID,
req.Reusable.Or(false),
req.Ephemeral.Or(false),
&expiration,
req.AclTags,
)
if err != nil {
return nil, mapStateError(err)
}
return &oas.CreatePreAuthKeyOK{
PreAuthKey: oas.NewOptPreAuthKey(oasPreAuthKeyNew(preAuthKey)),
}, nil
}
// ListPreAuthKeys lists all pre-auth keys, sorted by id.
func (s *Server) ListPreAuthKeys(_ context.Context) (*oas.ListPreAuthKeysOK, error) {
keys, err := s.state.ListPreAuthKeys()
if err != nil {
return nil, mapStateError(err)
}
slices.SortFunc(keys, func(a, b types.PreAuthKey) int { return cmp.Compare(a.ID, b.ID) })
out := make([]oas.PreAuthKey, len(keys))
for i := range keys {
out[i] = oasPreAuthKey(keys[i].View())
}
return &oas.ListPreAuthKeysOK{PreAuthKeys: out}, nil
}
// ExpirePreAuthKey expires a pre-auth key.
func (s *Server) ExpirePreAuthKey(_ context.Context, req *oas.ExpirePreAuthKeyReq) error {
err := s.state.ExpirePreAuthKey(req.ID.Or(0))
if err != nil {
return mapStateError(err)
}
return nil
}
// DeletePreAuthKey deletes a pre-auth key.
func (s *Server) DeletePreAuthKey(_ context.Context, params oas.DeletePreAuthKeyParams) error {
err := s.state.DeletePreAuthKey(params.ID.Or(0))
if err != nil {
return mapStateError(err)
}
return nil
}
var (
errTagPrefix = errors.New("tag must start with the string 'tag:'")
errTagLowercase = errors.New("tag should be lowercase")
errTagSpaces = errors.New("tags must not contain spaces")
)
// validateTag enforces the ACL tag format ("tag:" prefix, lowercase, no spaces).
func validateTag(tag string) error {
if !strings.HasPrefix(tag, "tag:") {
return errTagPrefix
type (
expirePreAuthKeyInput struct {
Body ExpirePreAuthKeyRequestBody
}
if strings.ToLower(tag) != tag {
return errTagLowercase
expirePreAuthKeyOutput struct {
Body struct{}
}
)
if len(strings.Fields(tag)) > 1 {
return errTagSpaces
type (
deletePreAuthKeyInput struct {
ID string `format:"uint64" query:"id"`
}
deletePreAuthKeyOutput struct {
Body struct{}
}
)
return nil
type listPreAuthKeysOutput struct {
Body struct {
PreAuthKeys []PreAuthKey `json:"preAuthKeys" nullable:"false"`
}
}
func registerPreAuthKeys(api huma.API, b Backend) {
huma.Register(api, huma.Operation{
OperationID: "createPreAuthKey",
Method: http.MethodPost,
Path: "/api/v1/preauthkey",
Summary: "Create pre-auth key",
Tags: []string{"PreAuthKeys"},
Security: bearerAuth,
}, func(ctx context.Context, in *createPreAuthKeyInput) (*preAuthKeyOutput, error) {
user, err := parsePreAuthKeyUser(in.Body.User)
if err != nil {
return nil, err
}
for _, tag := range in.Body.ACLTags {
tagErr := validateTag(tag)
if tagErr != nil {
return nil, huma.Error400BadRequest("invalid tag", tagErr)
}
}
// CreatePreAuthKey requires a non-nil pointer; zero-stamp when unset.
var expiration time.Time
if in.Body.Expiration != nil {
expiration = *in.Body.Expiration
}
var userID *types.UserID
if user != 0 {
u, getErr := b.State.GetUserByID(user)
if getErr != nil {
return nil, mapError("creating pre-auth key", getErr)
}
userID = u.TypedID()
}
preAuthKey, err := b.State.CreatePreAuthKey(
userID,
in.Body.Reusable,
in.Body.Ephemeral,
&expiration,
in.Body.ACLTags,
)
if err != nil {
// A key that is neither tagged nor user-owned is invalid input (400).
return nil, mapError("creating pre-auth key", err)
}
out := &preAuthKeyOutput{}
out.Body.PreAuthKey = preAuthKeyNewToResponse(preAuthKey)
return out, nil
})
huma.Register(api, huma.Operation{
OperationID: "expirePreAuthKey",
Method: http.MethodPost,
Path: "/api/v1/preauthkey/expire",
Summary: "Expire pre-auth key",
Tags: []string{"PreAuthKeys"},
Security: bearerAuth,
}, func(ctx context.Context, in *expirePreAuthKeyInput) (*expirePreAuthKeyOutput, error) {
id, err := parsePreAuthKeyID(in.Body.ID)
if err != nil {
return nil, err
}
err = b.State.ExpirePreAuthKey(id)
if err != nil {
// An unknown key id maps to 404.
return nil, mapError("expiring pre-auth key", err)
}
return &expirePreAuthKeyOutput{}, nil
})
huma.Register(api, huma.Operation{
OperationID: "deletePreAuthKey",
Method: http.MethodDelete,
Path: "/api/v1/preauthkey",
Summary: "Delete pre-auth key",
Tags: []string{"PreAuthKeys"},
Security: bearerAuth,
}, func(ctx context.Context, in *deletePreAuthKeyInput) (*deletePreAuthKeyOutput, error) {
// DELETE has no body: id is bound from the query string.
id, err := parsePreAuthKeyID(in.ID)
if err != nil {
return nil, err
}
err = b.State.DeletePreAuthKey(id)
if err != nil {
// An unknown key id maps to 404.
return nil, mapError("deleting pre-auth key", err)
}
return &deletePreAuthKeyOutput{}, nil
})
huma.Register(api, huma.Operation{
OperationID: "listPreAuthKeys",
Method: http.MethodGet,
Path: "/api/v1/preauthkey",
Summary: "List pre-auth keys",
Tags: []string{"PreAuthKeys"},
Security: bearerAuth,
}, func(ctx context.Context, _ *struct{}) (*listPreAuthKeysOutput, error) {
preAuthKeys, err := b.State.ListPreAuthKeys()
if err != nil {
return nil, huma.Error500InternalServerError("listing pre-auth keys", err)
}
// Match the gRPC handler's ascending-ID ordering.
slices.SortFunc(preAuthKeys, func(a, b types.PreAuthKey) int {
return cmp.Compare(a.ID, b.ID)
})
out := &listPreAuthKeysOutput{}
out.Body.PreAuthKeys = make([]PreAuthKey, len(preAuthKeys))
for i := range preAuthKeys {
out.Body.PreAuthKeys[i] = preAuthKeyToResponse(&preAuthKeys[i])
}
return out, nil
})
}
// preAuthKeyNewToResponse builds the v1 response for a freshly created key. The
// plaintext key is returned only here; Used is always false.
func preAuthKeyNewToResponse(key *types.PreAuthKeyNew) PreAuthKey {
out := PreAuthKey{
ID: formatID(key.ID),
Key: key.Key,
Reusable: key.Reusable,
Ephemeral: key.Ephemeral,
ACLTags: nonNilTags(key.Tags),
}
if key.User != nil {
u := userFromView(key.User.View())
out.User = &u
}
if key.Expiration != nil {
out.Expiration = *key.Expiration
}
if key.CreatedAt != nil {
out.CreatedAt = *key.CreatedAt
}
return out
}
// preAuthKeyToResponse builds the v1 response for a stored key, with its key
// field masked (see maskedPreAuthKey).
func preAuthKeyToResponse(key *types.PreAuthKey) PreAuthKey {
out := PreAuthKey{
ID: formatID(key.ID),
Key: maskedPreAuthKey(key.View()),
Reusable: key.Reusable,
Ephemeral: key.Ephemeral,
Used: key.Used,
ACLTags: nonNilTags(key.Tags),
}
if key.User != nil {
u := userFromView(key.User.View())
out.User = &u
}
if key.Expiration != nil {
out.Expiration = *key.Expiration
}
if key.CreatedAt != nil {
out.CreatedAt = *key.CreatedAt
}
return out
}
// maskedPreAuthKey masks new keys (those with a stored prefix) so the secret is
// never returned; legacy plaintext keys are returned in full for backwards
// compatibility.
func maskedPreAuthKey(key types.PreAuthKeyView) string {
if key.Prefix() != "" {
return "hskey-auth-" + key.Prefix() + "-***"
}
return key.Key()
}
// nonNilTags ensures aclTags serializes as [] rather than null, matching
// EmitUnpopulated output.
func nonNilTags(tags []string) []string {
if tags == nil {
return []string{}
}
return tags
}
// parsePreAuthKeyUser parses the optional uint64 user field. Empty means "no
// user" (user 0); non-numeric is rejected with 400.
func parsePreAuthKeyUser(s string) (types.UserID, error) {
if s == "" {
return 0, nil
}
id, err := strconv.ParseUint(s, 10, 64)
if err != nil {
return 0, huma.Error400BadRequest("invalid user id", err)
}
return types.UserID(id), nil
}
// parsePreAuthKeyID parses the uint64 key id. Empty means id 0; non-numeric is
// rejected with 400.
func parsePreAuthKeyID(s string) (uint64, error) {
if s == "" {
return 0, nil
}
id, err := strconv.ParseUint(s, 10, 64)
if err != nil {
return 0, huma.Error400BadRequest("invalid pre-auth key id", err)
}
return id, nil
}
-52
View File
@@ -1,52 +0,0 @@
// Package apiv1 implements the Headscale v1 HTTP API: thin handlers that
// adapt the ogen-generated server interface ([oas.Handler]) onto the shared
// state layer ([state.State]). Business logic lives in the state layer; these
// handlers only translate between HTTP request/response types and state calls.
//
// The package deliberately does not import the parent hscontrol package: it
// depends only on state, types, and the generated API package, so that
// hscontrol can mount it without an import cycle.
package apiv1
import (
"net/http"
oas "github.com/juanfont/headscale/gen/api/v1"
"github.com/juanfont/headscale/hscontrol/state"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/types/change"
)
// Server implements the generated [oas.Handler] and [oas.SecurityHandler].
//
// Any operation not implemented here is inherited from
// [oas.UnimplementedHandler] and returns 501; every operation is implemented.
type Server struct {
oas.UnimplementedHandler
state *state.State
cfg *types.Config
change changeFunc
}
// changeFunc distributes state changes to connected nodes. In production this
// is [github.com/juanfont/headscale/hscontrol.Headscale.Change]; tests may pass
// a no-op or a recorder.
type changeFunc func(...change.Change)
// NewHandler builds the v1 API as an [http.Handler] ready to mount at
// /api/v1. changeFn distributes [change.Change]s produced by mutating
// operations; it must not be nil (pass a no-op if changes are irrelevant).
func NewHandler(
st *state.State,
cfg *types.Config,
changeFn func(...change.Change),
) (http.Handler, error) {
s := &Server{
state: st,
cfg: cfg,
change: changeFn,
}
return oas.NewServer(s, s, oas.WithErrorHandler(errorHandler))
}
+29
View File
@@ -0,0 +1,29 @@
package apiv1
import (
"errors"
"strings"
)
// ACL tag validation, shared by the node and pre-auth-key resources. These
// reproduce the gRPC validateTag checks and messages.
var (
errTagMissingPrefix = errors.New("tag must start with the string 'tag:'")
errTagNotLowercase = errors.New("tag should be lowercase")
errTagHasSpaces = errors.New("tags must not contain spaces")
)
// validateTag reports whether an ACL tag is well formed: it must start with
// "tag:", be lowercase, and contain no spaces.
func validateTag(tag string) error {
switch {
case !strings.HasPrefix(tag, "tag:"):
return errTagMissingPrefix
case strings.ToLower(tag) != tag:
return errTagNotLowercase
case len(strings.Fields(tag)) > 1:
return errTagHasSpaces
default:
return nil
}
}
+52
View File
@@ -0,0 +1,52 @@
package apiv1
import (
"strconv"
"time"
"github.com/juanfont/headscale/hscontrol/types"
)
// The v1 contract follows protojson: 64-bit integers are JSON strings (avoiding
// precision loss above 2^53), timestamps are RFC 3339, and zero values are
// emitted. Hence response fields carry NO omitempty; request types keep
// omitempty so their fields stay optional in the spec.
// formatID renders a uint64 identifier as the contract's decimal string.
func formatID[T ~uint64 | ~uint](id T) string {
return strconv.FormatUint(uint64(id), 10)
}
// User mirrors the v1 User message.
type User struct {
ID string `format:"uint64" json:"id"`
Name string `json:"name"`
CreatedAt time.Time `json:"createdAt"`
DisplayName string `json:"displayName"`
Email string `json:"email"`
ProviderID string `json:"providerId"`
Provider string `json:"provider"`
ProfilePicURL string `json:"profilePicUrl"`
}
// userFromView converts a domain user into the v1 response shape, reading
// through the [types.UserView] accessors: Name falls back to Username()
// (email/provider/id) when the stored Name is empty, so OIDC users display
// their email.
func userFromView(u types.UserView) User {
name := u.Name()
if name == "" {
name = u.Username()
}
return User{
ID: formatID(u.ID()),
Name: name,
CreatedAt: u.CreatedAt(),
DisplayName: u.DisplayName(),
Email: u.Email(),
ProviderID: u.ProviderIdentifier().String,
Provider: u.Provider(),
ProfilePicURL: u.ProfilePicURL(),
}
}
+210 -89
View File
@@ -3,110 +3,231 @@ package apiv1
import (
"cmp"
"context"
"net/http"
"slices"
"strconv"
oas "github.com/juanfont/headscale/gen/api/v1"
"github.com/danielgtaylor/huma/v2"
"github.com/juanfont/headscale/hscontrol/types"
"gorm.io/gorm"
)
// CreateUser creates a user and distributes the resulting policy change.
func (s *Server) CreateUser(
_ context.Context,
req *oas.CreateUserReq,
) (*oas.CreateUserOK, error) {
newUser := types.User{
Name: req.Name.Or(""),
DisplayName: req.DisplayName.Or(""),
Email: req.Email.Or(""),
ProfilePicURL: req.PictureUrl.Or(""),
}
user, policyChanged, err := s.state.CreateUser(newUser)
if err != nil {
return nil, internalError("creating user: " + err.Error())
}
s.change(policyChanged)
return &oas.CreateUserOK{User: oas.NewOptUser(oasUser(user.View()))}, nil
func init() {
registrations = append(registrations, registerUsers)
}
// ListUsers lists users, optionally filtered by id, name, or email, sorted by id.
func (s *Server) ListUsers(
_ context.Context,
params oas.ListUsersParams,
) (*oas.ListUsersOK, error) {
var (
users []types.User
err error
)
// CreateUserRequestBody mirrors v1.CreateUserRequest.
type CreateUserRequestBody struct {
Name string `json:"name,omitempty"`
DisplayName string `json:"displayName,omitempty"`
Email string `json:"email,omitempty"`
PictureURL string `json:"pictureUrl,omitempty"`
}
type (
createUserInput struct {
Body CreateUserRequestBody
}
userOutput struct {
Body struct {
User User `json:"user"`
}
}
)
type (
renameUserInput struct {
OldID string `format:"uint64" path:"oldId"`
NewName string `path:"newName"`
}
deleteUserInput struct {
ID string `format:"uint64" path:"id"`
}
deleteUserOutput struct {
Body struct{}
}
)
type (
listUsersInput struct {
ID string `format:"uint64" query:"id"`
Name string `query:"name"`
Email string `query:"email"`
}
listUsersOutput struct {
Body struct {
Users []User `json:"users" nullable:"false"`
}
}
)
func registerUsers(api huma.API, b Backend) {
huma.Register(api, huma.Operation{
OperationID: "createUser",
Method: http.MethodPost,
Path: "/api/v1/user",
Summary: "Create user",
Tags: []string{"Users"},
Security: bearerAuth,
}, func(ctx context.Context, in *createUserInput) (*userOutput, error) {
// Pre-check yields a 409 for the common case; the DB unique constraint
// is the real guard.
if in.Body.Name != "" {
_, err := b.State.GetUserByName(in.Body.Name)
if err == nil {
return nil, huma.Error409Conflict("user already exists")
}
}
user, policyChanged, err := b.State.CreateUser(types.User{
Name: in.Body.Name,
DisplayName: in.Body.DisplayName,
Email: in.Body.Email,
ProfilePicURL: in.Body.PictureURL,
})
if err != nil {
return nil, mapError("creating user", err)
}
b.Change(policyChanged)
out := &userOutput{}
out.Body.User = userFromView(user.View())
return out, nil
})
huma.Register(api, huma.Operation{
OperationID: "renameUser",
Method: http.MethodPost,
Path: "/api/v1/user/{oldId}/rename/{newName}",
Summary: "Rename user",
Tags: []string{"Users"},
Security: bearerAuth,
}, func(ctx context.Context, in *renameUserInput) (*userOutput, error) {
oldID, err := parseUserID(in.OldID)
if err != nil {
return nil, err
}
oldUser, err := b.State.GetUserByID(oldID)
if err != nil {
return nil, mapError("renaming user", err)
}
_, c, err := b.State.RenameUser(types.UserID(oldUser.ID), in.NewName)
if err != nil {
return nil, mapError("renaming user", err)
}
b.Change(c)
newUser, err := b.State.GetUserByName(in.NewName)
if err != nil {
return nil, huma.Error500InternalServerError("renaming user", err)
}
out := &userOutput{}
out.Body.User = userFromView(newUser.View())
return out, nil
})
huma.Register(api, huma.Operation{
OperationID: "deleteUser",
Method: http.MethodDelete,
Path: "/api/v1/user/{id}",
Summary: "Delete user",
Tags: []string{"Users"},
Security: bearerAuth,
}, func(ctx context.Context, in *deleteUserInput) (*deleteUserOutput, error) {
id, err := parseUserID(in.ID)
if err != nil {
return nil, err
}
user, err := b.State.GetUserByID(id)
if err != nil {
return nil, mapError("deleting user", err)
}
policyChanged, err := b.State.DeleteUser(types.UserID(user.ID))
if err != nil {
return nil, mapError("deleting user", err)
}
b.Change(policyChanged)
return &deleteUserOutput{}, nil
})
huma.Register(api, huma.Operation{
OperationID: "listUsers",
Method: http.MethodGet,
Path: "/api/v1/user",
Summary: "List users",
Tags: []string{"Users"},
Security: bearerAuth,
}, func(ctx context.Context, in *listUsersInput) (*listUsersOutput, error) {
// Gateway parity: a non-numeric id is a 400 even when other filters win.
if in.ID != "" {
_, err := strconv.ParseUint(in.ID, 10, 64)
if err != nil {
return nil, huma.Error400BadRequest("invalid id", err)
}
}
users, err := listUsersFiltered(b, in)
if err != nil {
return nil, huma.Error500InternalServerError("listing users", err)
}
// Match the gRPC handler's ascending-ID ordering.
slices.SortFunc(users, func(a, b types.User) int {
return cmp.Compare(a.ID, b.ID)
})
out := &listUsersOutput{}
out.Body.Users = make([]User, len(users))
for i := range users {
out.Body.Users[i] = userFromView(users[i].View())
}
return out, nil
})
}
// listUsersFiltered reproduces the gRPC ListUsers precedence: name, then email,
// then id, otherwise all users.
func listUsersFiltered(b Backend, in *listUsersInput) ([]types.User, error) {
switch {
case params.Name.Or("") != "":
users, err = s.state.ListUsersWithFilter(&types.User{Name: params.Name.Or("")})
case params.Email.Or("") != "":
users, err = s.state.ListUsersWithFilter(&types.User{Email: params.Email.Or("")})
case params.ID.Or(0) != 0:
users, err = s.state.ListUsersWithFilter(
&types.User{Model: gorm.Model{ID: uint(params.ID.Or(0))}},
)
case in.Name != "":
return b.State.ListUsersWithFilter(&types.User{Name: in.Name})
case in.Email != "":
return b.State.ListUsersWithFilter(&types.User{Email: in.Email})
case in.ID != "":
id, err := strconv.ParseUint(in.ID, 10, 64)
if err != nil {
return nil, err
}
if id == 0 {
return b.State.ListAllUsers()
}
return b.State.ListUsersWithFilter(&types.User{Model: gorm.Model{ID: uint(id)}})
default:
users, err = s.state.ListAllUsers()
return b.State.ListAllUsers()
}
if err != nil {
return nil, mapStateError(err)
}
slices.SortFunc(users, func(a, b types.User) int { return cmp.Compare(a.ID, b.ID) })
out := make([]oas.User, len(users))
for i := range users {
out[i] = oasUser(users[i].View())
}
return &oas.ListUsersOK{Users: out}, nil
}
// RenameUser renames a user and distributes the resulting policy change.
func (s *Server) RenameUser(
_ context.Context,
params oas.RenameUserParams,
) (*oas.RenameUserOK, error) {
oldUser, err := s.state.GetUserByID(types.UserID(params.OldID))
func parseUserID(s string) (types.UserID, error) {
id, err := strconv.ParseUint(s, 10, 64)
if err != nil {
return nil, mapStateError(err)
return 0, huma.Error400BadRequest("invalid user id", err)
}
_, c, err := s.state.RenameUser(types.UserID(oldUser.ID), params.NewName)
if err != nil {
return nil, mapStateError(err)
}
s.change(c)
newUser, err := s.state.GetUserByName(params.NewName)
if err != nil {
return nil, mapStateError(err)
}
return &oas.RenameUserOK{User: oas.NewOptUser(oasUser(newUser.View()))}, nil
}
// DeleteUser deletes a user and distributes the resulting policy change.
func (s *Server) DeleteUser(_ context.Context, params oas.DeleteUserParams) error {
user, err := s.state.GetUserByID(types.UserID(params.ID))
if err != nil {
return mapStateError(err)
}
policyChanged, err := s.state.DeleteUser(types.UserID(user.ID))
if err != nil {
return mapStateError(err)
}
s.change(policyChanged)
return nil
return types.UserID(id), nil
}
+124
View File
@@ -0,0 +1,124 @@
# API v2: Headscale's v2 API
This is Headscale's v2 HTTP API, served at `/api/v2`. Some of its endpoints are
**ported from Tailscale's API**, reusing Tailscale's wire shapes, so the
Tailscale ecosystem that cannot talk to Headscale today works: the
[Terraform/OpenTofu provider], [tscli], and the official [Go client]
(`tailscale.com/client/tailscale/v2`).
It is **not** a port of the whole Tailscale API. Ported endpoints are added one
at a time, only as we need them; a headscale-native v2 endpoint may use
headscale's own conventions. The headscale-native admin API stays at `/api/v1`
(`hscontrol/api/v1`). This guide is for the endpoints **ported from Tailscale**.
[Terraform/OpenTofu provider]: https://registry.terraform.io/providers/tailscale/tailscale/latest
[tscli]: https://github.com/jaxxstorm/tscli
[Go client]: https://pkg.go.dev/tailscale.com/client/tailscale/v2
## Conventions
- Operations derived from Tailscale carry the `Tailscale compat` tag.
- The `{tailnet}` path segment must be `-` (the single Headscale tailnet);
anything else is `404`. See `requireDefaultTailnet`.
- Errors use **Tailscale's** body (`{"message","data","status"}`), installed as
a per-API transform (`tailscaleErrorTransformer` in `errors.go`). A future
headscale-native v2 operation would keep Huma's RFC 9457 problem+json.
- Auth accepts a credential as **HTTP Basic** (key as username, what the SDK
sends) or **Bearer**: an admin API key (`hskey-api-…`), or an OAuth access
token (`hskey-oauthtok-…`). See `authMiddleware`.
- Each operation declares the Tailscale scope it requires (`auth_keys`,
`oauth_keys`, `devices:core`, `devices:routes`, `policy_file`,
`feature_settings`, each with a `:read` subset, plus `all`/`all:read`).
`requireScope` records it both for the middleware and in the generated
OpenAPI, as an `x-required-scope` extension and a sentence in the operation
description, so the scope shows up in the docs and spec. Enforcement: an
**admin API key is all-access** (scope checks skipped); an **OAuth access
token is scope-limited**, the middleware checks the operation's declared scope
against the token's grant (`scope.Grants`, where a write scope subsumes its
`:read` and `all`/`all:read` are super-scopes). The two are told apart by
credential prefix.
- Resolve one entity by id with a typed getter (`GetNodeByID`, `GetUserByID`,
`GetAPIKeyByID`, `GetPreAuthKeyByID`); add one to state/db if it is missing
rather than scanning a `List`. Build responses from the view accessors
(`NodeView`/`UserView`/`PreAuthKeyView`), never `AsStruct()`.
- Reuse upstream wire shapes, but declare the request/response structs here:
Huma reflects these to build the OpenAPI schema, and the upstream `Key`'s
`ExpirySeconds *time.Duration` marshals as nanoseconds, which the spec and
every client read as seconds.
## OAuth clients & scopes
Most of the Tailscale ecosystem (the Terraform provider, `tscli`, the Go client)
accepts **either** an API key **or OAuth 2.0 client-credentials**; the Kubernetes
operator is OAuth-only. Supporting OAuth lets all of them drive Headscale.
- **OAuth clients** are not a separate resource; they are `keyType:"client"` on
the keys endpoint, exactly as Tailscale does it. Create
(`POST /api/v2/tailnet/-/keys` with `{"keyType":"client","scopes":[…],"tags":[…]}`)
returns a `Key` whose `id` is the client id and whose `key` is the secret,
**shown once**; get/list never re-expose it. The secret is
`hskey-client-<clientID>-<secret>`, embedding the client id so the token
endpoint derives it from the secret (Tailscale's `get-authkey` trick). See
`keys.go` (`createOAuthClient`) and `db/oauth.go`.
- **Token endpoint** `POST /api/v2/oauth/token` (`oauth.go`) is a plain handler,
not a Huma operation: it takes `application/x-www-form-urlencoded` and emits
RFC 6749 OAuth2 error bodies (`{"error","error_description"}`). Credentials
arrive in the body or HTTP Basic; optional space-delimited `scope`/`tags`
narrow the token to a subset of the client's grant. It returns a 1-hour
`Bearer` access token (`hskey-oauthtok-…`).
- **Scope enforcement** is the one seam in `authMiddleware`. **Tag enforcement**:
an auth key minted by a token may only carry tags the token holds, or tags
owned-by them via the policy `tagOwners` (`State.TagOwnedByTags`
`policy/v2`), so e.g. an operator token tagged `tag:k8s-operator` may mint
`tag:k8s` keys.
- Credentials/tokens are stored like API keys: a public id/prefix plus an
**Argon2id** hash of the secret (no JWT, no signing keys). `OAuthClient` and
`OAuthAccessToken` live in `types/oauth.go` and `db/oauth.go`.
## Adding an endpoint
Worked example: the keys resource (`keys.go`) = Tailscale auth keys = Headscale
pre-auth keys.
1. **Read the Tailscale spec.** Find the operation in the [Tailscale API
reference](https://tailscale.com/api) (OpenAPI 3.1). Note method, path,
request/response schema, and which variant(s) Headscale supports (auth keys
only, for keys).
2. **Capture golden samples.** Pull the request + response JSON examples from the
spec, prune to the variant, and use them as the assertion in the contract
test. _Acceptance: the captured request and response are recorded in the
test._
3. **Map to Headscale.** Write the field ↔ field ↔ `state` call mapping. Record
gaps and the decision for each (e.g. Tailscale `preauthorized` has no
Headscale equivalent: accepted, ignored, echoed back). _Acceptance: every
request field is consumed or deliberately ignored; every response field has a
source._
4. **Implement the Huma operation.** Declare named request/response structs with
validation/`default`/`example`/`doc` tags; tag the operation `Tailscale
compat`; declare its `Errors`; enforce the tailnet and scope. Map state
errors with `mapError`. _Acceptance: `go build ./hscontrol/api/v2/` and the
operation appears in `Spec()`._
5. **Contract test (in-process, `humatest`).** Assert the server accepts the
golden request and returns the golden response shape, with secrets and
timestamps neutralised. Pin the wire facts (e.g. `expirySeconds` in seconds,
the list `{"keys":[...]}` envelope, the error `message`). See
`hscontrol/apiv2_keys_test.go`. _Acceptance: the test is green._
6. **Roundtrip the real clients.** Add a `t.Run` subtest to `TestAPIv2`
(`hscontrol/servertest/apiv2_test.go`) for each of the Go client, tscli, and
OpenTofu, full create→read→list→delete against one shared server on a real
loopback port (`servertest.WithRealListener`). tscli and tofu come from the
nix dev shell; a missing binary fails the test. _Acceptance: `nix develop -c
go test ./hscontrol/servertest/ -run TestAPIv2` is green._
7. **Update the CLI** only if the v2 operation fully replaces a v1 one. Tailscale
has no separate key-expire verb (its `DELETE` _is_ the revoke), so v2 maps
`DELETE` to a soft revoke: the key stays retrievable with `invalid: true`
until the collector reaps it (`preauth_keys.revoked_retention`), the
equivalent of v1 `preauthkeys expire`. `headscale preauthkeys` still stays on
v1 for now (it is the cross-user admin surface), but the verb gap that
previously blocked migration is closed.
+229
View File
@@ -0,0 +1,229 @@
package apiv2
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"net/http"
"os"
"strings"
"github.com/danielgtaylor/huma/v2"
"github.com/juanfont/headscale/hscontrol/scope"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/util"
)
func init() {
registrations = append(registrations, registerACL)
}
const (
contentTypeJSON = "application/json"
contentTypeHuJSON = "application/hujson"
)
// defaultPolicy is served by GET when no policy is set, and is what an If-Match
// of "ts-default" matches against. Allow-all, matching the cluster's behaviour
// with no configured policy.
const defaultPolicy = `{
// Headscale default policy. Allows all communication.
"acls": [
{"action": "accept", "src": ["*"], "dst": ["*:*"]},
],
}
`
type getACLInput struct {
Tailnet string `path:"tailnet"`
Accept string `header:"Accept"`
Details bool `doc:"Accepted for compatibility; ignored." query:"details"`
}
type setACLInput struct {
Tailnet string `path:"tailnet"`
IfMatch string `header:"If-Match"`
Accept string `header:"Accept"`
// RawBody captures the raw HuJSON or JSON policy bytes; huma feeds them here
// regardless of Content-Type. The declared type only shapes the OpenAPI schema.
RawBody []byte `contentType:"application/json"`
}
func registerACL(api huma.API, b Backend) {
aclTags := []string{"Policy", "Tailscale compat"}
huma.Register(api, requireScope(huma.Operation{
OperationID: "getACL",
Method: http.MethodGet,
Path: "/api/v2/tailnet/{tailnet}/acl",
Summary: "Get the policy file",
Tags: aclTags,
Security: security,
Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusInternalServerError},
}, scope.PolicyFileRead), func(ctx context.Context, in *getACLInput) (*huma.StreamResponse, error) {
err := requireDefaultTailnet(in.Tailnet)
if err != nil {
return nil, err
}
data, err := currentPolicy(b)
if err != nil {
return nil, err
}
return streamPolicy(data, aclContentType(in.Accept)), nil
})
huma.Register(api, requireScope(huma.Operation{
OperationID: "setACL",
Method: http.MethodPost,
Path: "/api/v2/tailnet/{tailnet}/acl",
Summary: "Set the policy file",
Tags: aclTags,
Security: security,
DefaultStatus: http.StatusOK,
// The body is an opaque HuJSON document captured raw; skip huma's
// schema validation (which would expect a base64 string).
SkipValidateBody: true,
Errors: []int{
http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden,
http.StatusNotFound, http.StatusPreconditionFailed, http.StatusInternalServerError,
},
}, scope.PolicyFile), func(ctx context.Context, in *setACLInput) (*huma.StreamResponse, error) {
err := requireDefaultTailnet(in.Tailnet)
if err != nil {
return nil, err
}
if b.Cfg.Policy.Mode != types.PolicyModeDB {
return nil, huma.Error400BadRequest(
types.ErrPolicyUpdateIsDisabled.Error(), types.ErrPolicyUpdateIsDisabled,
)
}
if in.IfMatch != "" {
current, err := currentPolicy(b)
if err != nil {
return nil, err
}
if !etagMatches(in.IfMatch, current) {
return nil, huma.Error412PreconditionFailed("precondition failed, invalid old hash")
}
}
// Mirror the v1 setPolicy flow: validate, SSH-check, persist, reload.
nodes := b.State.ListNodes()
_, err = b.State.SetPolicy(in.RawBody)
if err != nil {
return nil, huma.Error400BadRequest("setting policy", err)
}
if nodes.Len() > 0 {
_, err = b.State.SSHPolicy(nodes.At(0))
if err != nil {
return nil, huma.Error400BadRequest("verifying SSH rules", err)
}
}
updated, err := b.State.SetPolicyInDB(string(in.RawBody))
if err != nil {
return nil, huma.Error500InternalServerError("setting policy", err)
}
cs, err := b.State.ReloadPolicy()
if err != nil {
return nil, huma.Error500InternalServerError("reloading policy", err)
}
if len(cs) > 0 {
b.Change(cs...)
}
return streamPolicy([]byte(updated.Data), aclContentType(in.Accept)), nil
})
}
// currentPolicy returns the stored policy bytes, or the allow-all default when
// none is set. File mode reads the configured path; DB mode reads the database.
func currentPolicy(b Backend) ([]byte, error) {
switch b.Cfg.Policy.Mode {
case types.PolicyModeDB:
p, err := b.State.GetPolicy()
if err != nil {
if errors.Is(err, types.ErrPolicyNotFound) {
return []byte(defaultPolicy), nil
}
return nil, huma.Error500InternalServerError("loading policy", err)
}
if p.Data == "" {
return []byte(defaultPolicy), nil
}
return []byte(p.Data), nil
case types.PolicyModeFile:
path := util.AbsolutePathFromConfigPath(b.Cfg.Policy.Path)
if path == "" {
return []byte(defaultPolicy), nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil, huma.Error500InternalServerError("reading policy file", err)
}
return data, nil
}
return nil, huma.Error500InternalServerError("unsupported policy mode", nil)
}
// streamPolicy writes the policy bytes as-is with the chosen content type and
// a content-addressed ETag, bypassing huma's JSON marshaler so HuJSON survives.
func streamPolicy(data []byte, contentType string) *huma.StreamResponse {
return &huma.StreamResponse{Body: func(ctx huma.Context) {
ctx.SetHeader("Content-Type", contentType)
ctx.SetHeader("ETag", policyETag(data))
ctx.SetStatus(http.StatusOK)
_, _ = ctx.BodyWriter().Write(data)
}}
}
// aclContentType serves HuJSON only when explicitly asked; everything else
// (including an empty Accept) gets application/json. The bytes are identical.
func aclContentType(accept string) string {
if strings.Contains(accept, contentTypeHuJSON) {
return contentTypeHuJSON
}
return contentTypeJSON
}
// policyETag is the quoted hex SHA-256 of the policy bytes: stable across reads,
// changes iff the policy changes.
func policyETag(data []byte) string {
sum := sha256.Sum256(data)
return `"` + hex.EncodeToString(sum[:]) + `"`
}
// etagMatches reports whether an If-Match header satisfies the current policy.
// "*" always matches; "ts-default" matches only when no policy is set (an
// approximation of Tailscale's untouched-default semantics).
func etagMatches(ifMatch string, current []byte) bool {
ifMatch = strings.TrimSpace(ifMatch)
switch ifMatch {
case "*":
return true
case `"ts-default"`, "ts-default":
return string(current) == defaultPolicy
default:
return ifMatch == policyETag(current)
}
}
+325
View File
@@ -0,0 +1,325 @@
// Package apiv2 is Headscale's v2 HTTP API, served at /api/v2.
//
// Where the v1 API (hscontrol/api/v1) is the headscale-native admin surface, v2
// additionally ports selected endpoints from Tailscale's API, reusing
// Tailscale's wire shapes (paths, request/response JSON, error body), so the
// existing Tailscale ecosystem (the Terraform/OpenTofu provider, tscli, and
// tailscale.com/client/tailscale/v2) can drive Headscale unchanged. Ported
// operations carry the "Tailscale compat" tag; a headscale-native v2 operation
// may use headscale's own conventions instead. See README.md for the porting
// guide.
//
// It depends only on the domain layer (hscontrol/state, hscontrol/types, and
// the db error sentinels), never on the hscontrol server package, so it sits
// beside the v1 API without either importing the other.
package apiv2
import (
"context"
"encoding/base64"
"maps"
"net/http"
"strings"
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humachi"
"github.com/go-chi/chi/v5"
"github.com/juanfont/headscale/hscontrol/scope"
"github.com/juanfont/headscale/hscontrol/state"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/types/change"
)
// Backend is the dependency surface the v2 API needs from the control plane:
// the state layer, the change-notification sink that distributes node/policy
// updates to connected clients, and the config (Policy.Mode/Path, Node.Expiry,
// and TLS are read by the ACL and settings handlers).
type Backend struct {
State *state.State
Change func(...change.Change)
Cfg *types.Config
}
// security is the requirement applied to authenticated operations: an API key
// presented as HTTP Basic (the key as username, what the Tailscale SDK sends)
// or as a Bearer token.
var security = []map[string][]string{{"basicAuth": {}}, {"bearerAuth": {}}}
// registrations is populated by each resource file's init(), so adding a
// resource means adding a file rather than editing a shared list.
var registrations []func(huma.API, Backend)
// Register wires up every operation contributed by the resource files. Exported
// so tests can register the v2 operations onto a humatest API.
func Register(api huma.API, b Backend) {
for _, fn := range registrations {
fn(api, b)
}
}
// Config returns the Huma configuration shared by the production API and tests:
// the v2 OpenAPI/docs paths, the basic+bearer security schemes, and the
// Tailscale error transform. Suppressing SchemasPath/CreateHooks keeps "$schema"
// out of the emitted bodies, matching the Tailscale wire contract.
func Config() huma.Config {
config := huma.DefaultConfig("Headscale API", "v2")
config.Info.Description = "Headscale v2 API. Some endpoints are ported from / compatible with the Tailscale API (tagged \"Tailscale compat\")."
config.OpenAPIPath = "/api/v2/openapi"
config.DocsPath = "/api/v2/docs"
config.SchemasPath = ""
config.CreateHooks = nil
config.Components.SecuritySchemes = map[string]*huma.SecurityScheme{
"basicAuth": {Type: "http", Scheme: "basic"},
"bearerAuth": {Type: "http", Scheme: "bearer"},
}
config.Transformers = append(config.Transformers, tailscaleErrorTransformer)
// Accept application/hujson request bodies (the Tailscale SDK sends the
// policy file that way). The bytes are captured raw by the ACL handler, so
// reusing the JSON format is only to satisfy huma's content-type check.
// Clone first: config.Formats aliases huma's shared DefaultFormats map.
formats := maps.Clone(config.Formats)
formats["application/hujson"] = formats["application/json"]
config.Formats = formats
return config
}
// NewAPI builds the v2 Huma API on router, installs the auth+scope middleware,
// and registers every operation.
func NewAPI(router chi.Router, backend Backend) huma.API {
api := humachi.New(router, Config())
// Must run before Register: Huma snapshots the middleware chain at operation
// registration, so a middleware added afterwards would silently never run.
api.UseMiddleware(authMiddleware(api, backend))
Register(api, backend)
// The OAuth token endpoint is a plain route, not a Huma operation (see
// oauth.go); register it on the same router.
registerOAuthToken(router, backend)
return api
}
// Handler builds the v2 API on a fresh mux and returns both, for mounting.
func Handler(backend Backend) (*chi.Mux, huma.API) {
mux := chi.NewMux()
api := NewAPI(mux, backend)
return mux, api
}
// Spec emits the OpenAPI 3.1 document. The zero Backend is safe because
// handlers are registered but never invoked during emission.
func Spec() ([]byte, error) {
api := NewAPI(chi.NewMux(), Backend{})
return api.OpenAPI().YAML()
}
// Spec30 emits the document downgraded to OpenAPI 3.0.3, needed because
// oapi-codegen cannot yet read 3.1; the typed client is generated from this.
func Spec30() ([]byte, error) {
api := NewAPI(chi.NewMux(), Backend{})
return api.OpenAPI().DowngradeYAML()
}
type contextKey int
const (
localTrustKey contextKey = iota
ownerUserKey
principalScopesKey
principalTagsKey
)
// WithLocalTrust marks a request as arriving over a locally-trusted transport
// (the unix socket), bypassing API-key authentication. Reserved for a future
// v2 socket mount; the network listener always authenticates.
func WithLocalTrust(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
next.ServeHTTP(w, req.WithContext(
context.WithValue(req.Context(), localTrustKey, struct{}{}),
))
})
}
// authMiddleware authenticates the API key (HTTP Basic with the key as the
// username, what the Tailscale SDK sends, or Bearer), records the key's owning
// user for handlers, and enforces the operation's required scope.
func authMiddleware(api huma.API, b Backend) func(huma.Context, func(huma.Context)) {
return func(ctx huma.Context, next func(huma.Context)) {
if ctx.Context().Value(localTrustKey) != nil {
next(ctx)
return
}
if len(ctx.Operation().Security) == 0 {
next(ctx)
return
}
token, ok := bearerOrBasicToken(ctx.Header("Authorization"))
if !ok {
_ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "unauthorized")
return
}
// An OAuth access token is scope-limited; an admin API key is all-access.
// They are told apart by prefix so a scoped token can never be mistaken
// for an all-access key.
if strings.HasPrefix(token, types.AccessTokenPrefix) {
at, err := b.State.AuthenticateAccessToken(token)
if err != nil {
_ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "unauthorized")
return
}
if want, ok := requiredScope(ctx.Operation()); ok && !scope.Grants(scope.Parse(at.Scopes), want) {
_ = huma.WriteErr(api, ctx, http.StatusForbidden,
"token is missing the required scope "+string(want))
return
}
// The keys handler multiplexes on keyType, so its required scope and
// permitted tags depend on the body; carry the token's scopes and tags
// for it to finish the check the static middleware cannot.
ctx = huma.WithValue(ctx, principalScopesKey, at.Scopes)
ctx = huma.WithValue(ctx, principalTagsKey, at.Tags)
next(ctx)
return
}
key, err := b.State.AuthenticateAPIKey(token)
if err != nil {
_ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "unauthorized")
return
}
// An admin API key is all-access: its operations are not scope-checked.
// Record its owning user (may be unset) so handlers can create user-owned
// keys on its behalf.
if key.UserID != nil {
ctx = huma.WithValue(ctx, ownerUserKey, types.UserID(*key.UserID))
}
next(ctx)
}
}
// bearerOrBasicToken extracts the API key from an Authorization header. The
// Tailscale SDK sends the key as the Basic-auth username with an empty
// password; curl and humans may use Bearer.
func bearerOrBasicToken(header string) (string, bool) {
if token, ok := strings.CutPrefix(header, "Bearer "); ok {
return token, token != ""
}
if encoded, ok := strings.CutPrefix(header, "Basic "); ok {
raw, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", false
}
username, _, _ := strings.Cut(string(raw), ":")
return username, username != ""
}
return "", false
}
// ownerUser returns the user the request's API key belongs to, if any.
func ownerUser(ctx context.Context) (types.UserID, bool) {
uid, ok := ctx.Value(ownerUserKey).(types.UserID)
return uid, ok
}
// principalScopes returns the scopes granted to the request's OAuth access
// token, and whether the request authenticated with one. ok is false for an
// admin API key, which is all-access and not scope-checked.
func principalScopes(ctx context.Context) ([]string, bool) {
scopes, ok := ctx.Value(principalScopesKey).([]string)
return scopes, ok
}
// principalTags returns the tags granted to the request's OAuth access token,
// and whether the request authenticated with one. An admin API key is not an
// OAuth token, so ok is false and its key creation is unrestricted by tags.
func principalTags(ctx context.Context) ([]string, bool) {
tags, ok := ctx.Value(principalTagsKey).([]string)
return tags, ok
}
// requireDefaultTailnet rejects any tailnet other than "-". Headscale is
// single-tailnet; the Tailscale SDK sends "-" (its default tailnet). A non-"-"
// value is "no such tailnet", a 404, which lets the SDK's IsNotFound behave.
func requireDefaultTailnet(tailnet string) error {
if tailnet != "-" {
return huma.Error404NotFound("tailnet not found")
}
return nil
}
// The scope vocabulary and the grant predicate live in the hscontrol/scope
// package; this file only wires a required scope onto each huma operation and
// reads it back in the middleware.
// scopeMetaKey keys the per-operation required scope in huma.Operation.Metadata.
const scopeMetaKey = "headscale.scope"
// requireScope records op's required scope, both in its Metadata (where the auth
// middleware reads it back) and in the generated OpenAPI document: an
// x-required-scope extension for machine consumers and a Description line so the
// rendered docs state what each operation needs.
func requireScope(op huma.Operation, s scope.Scope) huma.Operation {
if op.Metadata == nil {
op.Metadata = map[string]any{}
}
op.Metadata[scopeMetaKey] = s
if op.Extensions == nil {
op.Extensions = map[string]any{}
}
op.Extensions["x-required-scope"] = string(s)
note := "Requires the `" + string(s) + "` OAuth scope (an admin API key is all-access)."
if op.Description == "" {
op.Description = note
} else {
op.Description += "\n\n" + note
}
return op
}
// requiredScope returns the scope an operation declared via requireScope, if any.
func requiredScope(op *huma.Operation) (scope.Scope, bool) {
if op == nil || op.Metadata == nil {
return "", false
}
s, ok := op.Metadata[scopeMetaKey].(scope.Scope)
return s, ok
}
+486
View File
@@ -0,0 +1,486 @@
package apiv2
import (
"context"
"net/http"
"net/netip"
"slices"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/juanfont/headscale/hscontrol/scope"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/util"
"tailscale.com/net/tsaddr"
)
func init() {
registrations = append(registrations, registerDevices)
}
// Device is the Tailscale device response. Headscale nodes map onto it; fields
// Headscale does not track are emitted as their zero value. The route slices
// are only populated for the fields=all variant, matching Tailscale.
type Device struct {
Addresses []string `json:"addresses" nullable:"false"`
Name string `json:"name"`
ID string `json:"id"`
NodeID string `json:"nodeId"`
Authorized bool `json:"authorized"`
User string `json:"user"`
Tags []string `json:"tags" nullable:"false"`
KeyExpiryDisabled bool `json:"keyExpiryDisabled"`
Created time.Time `json:"created"`
Expires *time.Time `json:"expires,omitempty"`
Hostname string `json:"hostname"`
IsEphemeral bool `json:"isEphemeral"`
LastSeen *time.Time `json:"lastSeen,omitempty"`
MachineKey string `json:"machineKey"`
NodeKey string `json:"nodeKey"`
OS string `json:"os"`
ClientVersion string `json:"clientVersion"`
UpdateAvailable bool `json:"updateAvailable"`
// Populated only when fields=all is requested.
AdvertisedRoutes []string `json:"advertisedRoutes,omitempty"`
EnabledRoutes []string `json:"enabledRoutes,omitempty"`
}
// DeviceRoutes is the GET /device/{id}/routes response: what the node announces
// vs which of those are approved (Tailscale calls approved routes "enabled").
type DeviceRoutes struct {
Advertised []string `json:"advertisedRoutes" nullable:"false"`
Enabled []string `json:"enabledRoutes" nullable:"false"`
}
// Request bodies match the Tailscale SDK wire shapes.
type (
setAuthorizedRequest struct {
Authorized bool `json:"authorized"`
}
setNameRequest struct {
Name string `json:"name"`
}
// setTagsRequest.Tags is intentionally not nullable:"false": the SDK sends
// "tags":null for "make untagged", which the handler accepts as a no-op
// rather than failing to decode (Headscale cannot untag a node).
setTagsRequest struct {
Tags []string `json:"tags"`
}
setKeyRequest struct {
KeyExpiryDisabled bool `json:"keyExpiryDisabled"`
}
setSubnetRoutesRequest struct {
Routes []string `json:"routes" nullable:"false"`
}
)
type (
deviceByIDInput struct {
DeviceID string `doc:"Device id (the decimal node id)." path:"id"`
Fields string `doc:"Set to \"all\" for route fields." query:"fields"`
}
listDevicesInput struct {
Tailnet string `doc:"Tailnet; must be \"-\"." path:"tailnet"`
Fields string `query:"fields"`
}
setAuthorizedInput struct {
DeviceID string `path:"id"`
Body setAuthorizedRequest
}
setNameInput struct {
DeviceID string `path:"id"`
Body setNameRequest
}
setTagsInput struct {
DeviceID string `path:"id"`
Body setTagsRequest
}
setKeyInput struct {
DeviceID string `path:"id"`
Body setKeyRequest
}
setSubnetRoutesInput struct {
DeviceID string `path:"id"`
Body setSubnetRoutesRequest
}
deviceOutput struct{ Body Device }
deviceRoutesOutput struct{ Body DeviceRoutes }
listDevicesOutput struct {
Body struct {
Devices []Device `json:"devices" nullable:"false"`
}
}
emptyOutput struct{ Body struct{} }
)
func registerDevices(api huma.API, b Backend) {
deviceTags := []string{"Devices", "Tailscale compat"}
huma.Register(api, requireScope(huma.Operation{
OperationID: "getDevice",
Method: http.MethodGet,
Path: "/api/v2/device/{id}",
Summary: "Get a device",
Tags: deviceTags,
Security: security,
Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
}, scope.DevicesCoreRead), func(ctx context.Context, in *deviceByIDInput) (*deviceOutput, error) {
node, err := lookupNode(b, in.DeviceID)
if err != nil {
return nil, err
}
return &deviceOutput{Body: deviceFromView(node, in.Fields == "all")}, nil
})
huma.Register(api, requireScope(huma.Operation{
OperationID: "listDevices",
Method: http.MethodGet,
Path: "/api/v2/tailnet/{tailnet}/devices",
Summary: "List devices",
Tags: deviceTags,
Security: security,
Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
}, scope.DevicesCoreRead), func(ctx context.Context, in *listDevicesInput) (*listDevicesOutput, error) {
err := requireDefaultTailnet(in.Tailnet)
if err != nil {
return nil, err
}
nodes := b.State.ListNodes()
allFields := in.Fields == "all"
out := &listDevicesOutput{}
out.Body.Devices = make([]Device, 0, nodes.Len())
for _, node := range nodes.All() {
out.Body.Devices = append(out.Body.Devices, deviceFromView(node, allFields))
}
return out, nil
})
huma.Register(api, requireScope(huma.Operation{
OperationID: "deleteDevice",
Method: http.MethodDelete,
Path: "/api/v2/device/{id}",
Summary: "Delete a device",
Tags: deviceTags,
Security: security,
DefaultStatus: http.StatusOK,
Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
}, scope.DevicesCore), func(ctx context.Context, in *deviceByIDInput) (*emptyOutput, error) {
node, err := lookupNode(b, in.DeviceID)
if err != nil {
return nil, err
}
nodeChange, err := b.State.DeleteNode(node)
if err != nil {
return nil, mapError("deleting device", err)
}
b.Change(nodeChange)
return &emptyOutput{}, nil
})
huma.Register(api, requireScope(huma.Operation{
OperationID: "authorizeDevice",
Method: http.MethodPost,
Path: "/api/v2/device/{id}/authorized",
Summary: "Authorize a device",
Tags: deviceTags,
Security: security,
DefaultStatus: http.StatusOK,
Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
}, scope.DevicesCore), func(ctx context.Context, in *setAuthorizedInput) (*emptyOutput, error) {
_, err := lookupNode(b, in.DeviceID)
if err != nil {
return nil, err
}
// Headscale nodes are authorized the moment they register; there is no
// de-authorize state. Accept authorized=true as a no-op; reject false so
// callers are not misled into thinking the device is fenced off.
if !in.Body.Authorized {
return nil, huma.Error400BadRequest(
"Headscale does not support de-authorizing a device; delete or expire it instead",
)
}
return &emptyOutput{}, nil
})
huma.Register(api, requireScope(huma.Operation{
OperationID: "setDeviceName",
Method: http.MethodPost,
Path: "/api/v2/device/{id}/name",
Summary: "Set a device's name",
Tags: deviceTags,
Security: security,
DefaultStatus: http.StatusOK,
Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
}, scope.DevicesCore), func(ctx context.Context, in *setNameInput) (*emptyOutput, error) {
node, err := lookupNode(b, in.DeviceID)
if err != nil {
return nil, err
}
_, nodeChange, err := b.State.RenameNode(node.ID(), in.Body.Name)
if err != nil {
return nil, mapError("renaming device", err)
}
b.Change(nodeChange)
return &emptyOutput{}, nil
})
huma.Register(api, requireScope(huma.Operation{
OperationID: "setDeviceTags",
Method: http.MethodPost,
Path: "/api/v2/device/{id}/tags",
Summary: "Set a device's tags",
Tags: deviceTags,
Security: security,
DefaultStatus: http.StatusOK,
Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
}, scope.DevicesCore), func(ctx context.Context, in *setTagsInput) (*emptyOutput, error) {
node, err := lookupNode(b, in.DeviceID)
if err != nil {
return nil, err
}
// Headscale cannot make a node untagged (tags-as-identity is one-way), so
// an empty/null tag set is accepted as a no-op rather than rejected. This
// keeps tooling lifecycles such as Terraform destroy working; the SDK
// sends "tags":null for "make untagged".
if len(in.Body.Tags) == 0 {
return &emptyOutput{}, nil
}
// An OAuth token may only assign tags within its grant (held directly or
// owned by a held tag per policy); an admin API key is unrestricted. The
// devices:core scope alone must not let a token stamp an arbitrary policy
// tag (e.g. tag:prod) onto any node. SetNodeTags still enforces that each
// tag exists in policy.
if tokenTags, isOAuth := principalTags(ctx); isOAuth {
for _, tag := range in.Body.Tags {
if !b.State.TagOwnedByTags(tag, tokenTags) {
return nil, huma.Error403Forbidden("token may not assign tag " + tag)
}
}
}
_, nodeChange, err := b.State.SetNodeTags(node.ID(), in.Body.Tags)
if err != nil {
return nil, mapError("setting device tags", err)
}
b.Change(nodeChange)
return &emptyOutput{}, nil
})
huma.Register(api, requireScope(huma.Operation{
OperationID: "setDeviceKey",
Method: http.MethodPost,
Path: "/api/v2/device/{id}/key",
Summary: "Set a device's key settings",
Tags: deviceTags,
Security: security,
DefaultStatus: http.StatusOK,
Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
}, scope.DevicesCore), func(ctx context.Context, in *setKeyInput) (*emptyOutput, error) {
node, err := lookupNode(b, in.DeviceID)
if err != nil {
return nil, err
}
// Only disabling expiry maps cleanly (expiry=nil never expires).
// Re-enabling has no target expiry in the Tailscale request and Headscale
// stores no original, so it is accepted as a no-op (keeps Terraform
// destroy working) rather than guessing a lifetime.
if !in.Body.KeyExpiryDisabled {
return &emptyOutput{}, nil
}
_, nodeChange, err := b.State.SetNodeExpiry(node.ID(), nil)
if err != nil {
return nil, mapError("setting device key expiry", err)
}
b.Change(nodeChange)
return &emptyOutput{}, nil
})
huma.Register(api, requireScope(huma.Operation{
OperationID: "setDeviceRoutes",
Method: http.MethodPost,
Path: "/api/v2/device/{id}/routes",
Summary: "Set a device's enabled subnet routes",
Tags: deviceTags,
Security: security,
DefaultStatus: http.StatusOK,
Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
}, scope.DevicesRoutes), func(ctx context.Context, in *setSubnetRoutesInput) (*deviceRoutesOutput, error) {
node, err := lookupNode(b, in.DeviceID)
if err != nil {
return nil, err
}
approved, err := parseRoutes(in.Body.Routes)
if err != nil {
return nil, err
}
updated, nodeChange, err := b.State.SetApprovedRoutes(node.ID(), approved)
if err != nil {
return nil, mapError("setting device routes", err)
}
b.Change(nodeChange)
return &deviceRoutesOutput{Body: routesFromView(updated)}, nil
})
huma.Register(api, requireScope(huma.Operation{
OperationID: "getDeviceRoutes",
Method: http.MethodGet,
Path: "/api/v2/device/{id}/routes",
Summary: "Get a device's subnet routes",
Tags: deviceTags,
Security: security,
Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
}, scope.DevicesRoutesRead), func(ctx context.Context, in *deviceByIDInput) (*deviceRoutesOutput, error) {
node, err := lookupNode(b, in.DeviceID)
if err != nil {
return nil, err
}
return &deviceRoutesOutput{Body: routesFromView(node)}, nil
})
}
// lookupNode resolves a device id to its NodeView, mapping a malformed or
// unknown id to 404 (the Tailscale SDK keys IsNotFound off the status code).
func lookupNode(b Backend, rawID string) (types.NodeView, error) {
nodeID, err := parseNodeID(rawID)
if err != nil {
return types.NodeView{}, err
}
node, ok := b.State.GetNodeByID(nodeID)
if !ok {
return types.NodeView{}, huma.Error404NotFound("device not found")
}
return node, nil
}
// parseRoutes parses the enabled-route strings, expanding an exit route to both
// families (else the node is not annotated as an exit node), then sorts/dedups.
func parseRoutes(routes []string) ([]netip.Prefix, error) {
var approved []netip.Prefix
for _, route := range routes {
prefix, err := netip.ParsePrefix(route)
if err != nil {
return nil, huma.Error400BadRequest("parsing route", err)
}
if prefix == tsaddr.AllIPv4() || prefix == tsaddr.AllIPv6() {
approved = append(approved, tsaddr.AllIPv4(), tsaddr.AllIPv6())
} else {
approved = append(approved, prefix)
}
}
slices.SortFunc(approved, netip.Prefix.Compare)
return slices.Compact(approved), nil
}
// deviceFromView maps a Headscale node onto the Tailscale Device, reading
// through the [types.NodeView] accessors. allFields gates the route slices,
// which Tailscale only returns for fields=all.
func deviceFromView(view types.NodeView, allFields bool) Device {
id := view.StringID()
d := Device{
Addresses: emptyIfNil(view.IPsAsString()),
Name: view.GivenName(),
ID: id,
NodeID: id,
Authorized: true, // Headscale has no post-registration de-auth state.
User: deviceUser(view),
Tags: emptyIfNil(view.Tags().AsSlice()),
KeyExpiryDisabled: !view.Expiry().Valid(),
Created: view.CreatedAt(),
Hostname: view.Hostname(),
IsEphemeral: view.IsEphemeral(),
MachineKey: view.MachineKey().String(),
NodeKey: view.NodeKey().String(),
OS: hostinfoOS(view),
ClientVersion: hostinfoVersion(view),
}
if view.Expiry().Valid() {
exp := view.Expiry().Get()
d.Expires = &exp
}
// LastSeen is reported only when the device is offline, matching tailcfg.
if view.LastSeen().Valid() && view.IsOnline().Valid() && !view.IsOnline().Get() {
ls := view.LastSeen().Get()
d.LastSeen = &ls
}
if allFields {
d.AdvertisedRoutes = emptyIfNil(util.PrefixesToString(view.AnnouncedRoutes()))
d.EnabledRoutes = emptyIfNil(util.PrefixesToString(view.ApprovedRoutes().AsSlice()))
}
return d
}
func routesFromView(view types.NodeView) DeviceRoutes {
return DeviceRoutes{
Advertised: emptyIfNil(util.PrefixesToString(view.AnnouncedRoutes())),
Enabled: emptyIfNil(util.PrefixesToString(view.ApprovedRoutes().AsSlice())),
}
}
// deviceUser is the owning login. [types.NodeView.Owner] already resolves the
// tags-as-identity rule: tagged nodes present the special TaggedDevices user,
// user-owned nodes present their login, orphaned nodes present nothing.
func deviceUser(view types.NodeView) string {
owner := view.Owner()
if owner.Valid() {
return owner.Username()
}
return ""
}
// hostinfoOS / hostinfoVersion read Hostinfo fields, returning "" when the node
// has not reported Hostinfo yet.
func hostinfoOS(view types.NodeView) string {
if hi := view.Hostinfo(); hi.Valid() {
return hi.OS()
}
return ""
}
func hostinfoVersion(view types.NodeView) string {
if hi := view.Hostinfo(); hi.Valid() {
return hi.IPNVersion()
}
return ""
}
+84
View File
@@ -0,0 +1,84 @@
package apiv2
import (
"errors"
"github.com/danielgtaylor/huma/v2"
"github.com/juanfont/headscale/hscontrol/db"
"github.com/juanfont/headscale/hscontrol/state"
"gorm.io/gorm"
)
// apiError is the Tailscale API error body. The official Tailscale Go client
// (and therefore the Terraform provider and tscli built on it) decodes 4xx/5xx
// responses into this shape. Huma's default RFC 9457 problem+json would reach
// them with an empty message, so tailscaleErrorTransformer rewrites every v2
// error into this shape. The HTTP status is read from the response code, not
// from the body's status field.
type apiError struct {
Message string `json:"message"`
Data []apiErrorData `json:"data,omitempty"`
Status int `json:"status"`
}
type apiErrorData struct {
User string `json:"user,omitempty"`
Errors []string `json:"errors,omitempty"`
}
// tailscaleErrorTransformer rewrites Huma's RFC 9457 error model into the
// Tailscale error shape. It is registered on the v2 API config only, so the
// headscale-native v1 API keeps emitting problem+json. Non-error bodies pass
// through untouched.
func tailscaleErrorTransformer(_ huma.Context, _ string, v any) (any, error) {
em, ok := v.(*huma.ErrorModel)
if !ok {
return v, nil
}
message := em.Detail
if message == "" {
message = em.Title
}
out := apiError{Message: message, Status: em.Status}
if len(em.Errors) > 0 {
details := make([]string, 0, len(em.Errors))
for _, d := range em.Errors {
details = append(details, d.Error())
}
out.Data = []apiErrorData{{Errors: details}}
}
return out, nil
}
// mapError translates a state/db-layer error into a Huma HTTP error
// (not-found→404, invalid input→400, everything else→500). The transformer
// then reshapes it into the Tailscale body.
func mapError(msg string, err error) error {
if err == nil {
return nil
}
switch {
case errors.Is(err, gorm.ErrRecordNotFound),
errors.Is(err, db.ErrPreAuthKeyNotFound),
errors.Is(err, db.ErrUserNotFound),
errors.Is(err, state.ErrNodeNotFound):
return huma.Error404NotFound(msg, err)
case errors.Is(err, db.ErrPreAuthKeyNotTaggedOrOwned),
errors.Is(err, db.ErrPreAuthKeyACLTagInvalid),
errors.Is(err, state.ErrGivenNameInvalid),
errors.Is(err, state.ErrGivenNameTaken),
errors.Is(err, state.ErrNodeNameNotUnique),
errors.Is(err, state.ErrRequestedTagsInvalidOrNotPermitted):
return huma.Error400BadRequest(msg, err)
default:
return huma.Error500InternalServerError(msg, err)
}
}

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