Compare commits

...

114 commits

Author SHA1 Message Date
alaningtrump
048308511c chore: fix function comment to match actual function name
Some checks failed
Build / build-nix (push) Has been cancelled
Build / build-cross (GOARCH=amd64 GOOS=darwin) (push) Has been cancelled
Build / build-cross (GOARCH=amd64 GOOS=linux) (push) Has been cancelled
Build / build-cross (GOARCH=arm64 GOOS=darwin) (push) Has been cancelled
Build / build-cross (GOARCH=arm64 GOOS=linux) (push) Has been cancelled
Check Generated Files / check-generated (push) Has been cancelled
Build (main) / container (push) Has been cancelled
Build (main) / binaries (amd64, darwin) (push) Has been cancelled
Build (main) / binaries (amd64, linux) (push) Has been cancelled
Build (main) / binaries (arm64, darwin) (push) Has been cancelled
Build (main) / binaries (arm64, linux) (push) Has been cancelled
Nix Flake Checks / build (push) Has been cancelled
Nix Flake Checks / gotest (push) Has been cancelled
Nix Flake Checks / golangci-lint (push) Has been cancelled
Nix Flake Checks / formatting (push) Has been cancelled
NixOS Module Tests / nix-module-check (push) Has been cancelled
Server Tests / servertest (push) Has been cancelled
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
Some checks failed
Build / build-nix (push) Has been cancelled
Build / build-cross (GOARCH=amd64 GOOS=darwin) (push) Has been cancelled
Build / build-cross (GOARCH=amd64 GOOS=linux) (push) Has been cancelled
Build / build-cross (GOARCH=arm64 GOOS=darwin) (push) Has been cancelled
Build / build-cross (GOARCH=arm64 GOOS=linux) (push) Has been cancelled
Check Generated Files / check-generated (push) Has been cancelled
Build (main) / container (push) Has been cancelled
Build (main) / binaries (amd64, darwin) (push) Has been cancelled
Build (main) / binaries (amd64, linux) (push) Has been cancelled
Build (main) / binaries (arm64, darwin) (push) Has been cancelled
Build (main) / binaries (arm64, linux) (push) Has been cancelled
Nix Flake Checks / build (push) Has been cancelled
Nix Flake Checks / gotest (push) Has been cancelled
Nix Flake Checks / golangci-lint (push) Has been cancelled
Nix Flake Checks / formatting (push) Has been cancelled
NixOS Module Tests / nix-module-check (push) Has been cancelled
Server Tests / servertest (push) Has been cancelled
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
Some checks failed
Build / build-nix (push) Has been cancelled
Build / build-cross (GOARCH=amd64 GOOS=darwin) (push) Has been cancelled
Build / build-cross (GOARCH=amd64 GOOS=linux) (push) Has been cancelled
Build / build-cross (GOARCH=arm64 GOOS=darwin) (push) Has been cancelled
Build / build-cross (GOARCH=arm64 GOOS=linux) (push) Has been cancelled
Check Generated Files / check-generated (push) Has been cancelled
Build (main) / container (push) Has been cancelled
Build (main) / binaries (amd64, darwin) (push) Has been cancelled
Build (main) / binaries (amd64, linux) (push) Has been cancelled
Build (main) / binaries (arm64, darwin) (push) Has been cancelled
Build (main) / binaries (arm64, linux) (push) Has been cancelled
Nix Flake Checks / build (push) Has been cancelled
Nix Flake Checks / gotest (push) Has been cancelled
Nix Flake Checks / golangci-lint (push) Has been cancelled
Nix Flake Checks / formatting (push) Has been cancelled
NixOS Module Tests / nix-module-check (push) Has been cancelled
Server Tests / servertest (push) Has been cancelled
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
Some checks are pending
Build / build-nix (push) Waiting to run
Build / build-cross (GOARCH=amd64 GOOS=darwin) (push) Waiting to run
Build / build-cross (GOARCH=amd64 GOOS=linux) (push) Waiting to run
Build / build-cross (GOARCH=arm64 GOOS=darwin) (push) Waiting to run
Build / build-cross (GOARCH=arm64 GOOS=linux) (push) Waiting to run
Check Generated Files / check-generated (push) Waiting to run
Build (main) / container (push) Waiting to run
Build (main) / binaries (amd64, darwin) (push) Waiting to run
Build (main) / binaries (amd64, linux) (push) Waiting to run
Build (main) / binaries (arm64, darwin) (push) Waiting to run
Build (main) / binaries (arm64, linux) (push) Waiting to run
Nix Flake Checks / build (push) Waiting to run
Nix Flake Checks / gotest (push) Waiting to run
Nix Flake Checks / golangci-lint (push) Waiting to run
Nix Flake Checks / formatting (push) Waiting to run
NixOS Module Tests / nix-module-check (push) Waiting to run
Server Tests / servertest (push) Waiting to run
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
Some checks are pending
Build / build-nix (push) Waiting to run
Build / build-cross (GOARCH=amd64 GOOS=darwin) (push) Waiting to run
Build / build-cross (GOARCH=amd64 GOOS=linux) (push) Waiting to run
Build / build-cross (GOARCH=arm64 GOOS=darwin) (push) Waiting to run
Build / build-cross (GOARCH=arm64 GOOS=linux) (push) Waiting to run
Check Generated Files / check-generated (push) Waiting to run
Nix Flake Checks / build (push) Waiting to run
Nix Flake Checks / gotest (push) Waiting to run
Nix Flake Checks / golangci-lint (push) Waiting to run
Nix Flake Checks / formatting (push) Waiting to run
NixOS Module Tests / nix-module-check (push) Waiting to run
Server Tests / servertest (push) Waiting to run
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
Some checks failed
Build / build-nix (push) Waiting to run
Build / build-cross (GOARCH=amd64 GOOS=darwin) (push) Waiting to run
Build / build-cross (GOARCH=amd64 GOOS=linux) (push) Waiting to run
Build / build-cross (GOARCH=arm64 GOOS=darwin) (push) Waiting to run
Build / build-cross (GOARCH=arm64 GOOS=linux) (push) Waiting to run
Check Generated Files / check-generated (push) Waiting to run
Nix Flake Checks / build (push) Waiting to run
Nix Flake Checks / gotest (push) Waiting to run
Nix Flake Checks / golangci-lint (push) Waiting to run
Nix Flake Checks / formatting (push) Waiting to run
NixOS Module Tests / nix-module-check (push) Waiting to run
Server Tests / servertest (push) Waiting to run
Build (main) / container (push) Has been cancelled
Build (main) / binaries (amd64, darwin) (push) Has been cancelled
Build (main) / binaries (amd64, linux) (push) Has been cancelled
Build (main) / binaries (arm64, darwin) (push) Has been cancelled
Build (main) / binaries (arm64, linux) (push) Has been cancelled
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
Some checks failed
Build / build-nix (push) Waiting to run
Build / build-cross (GOARCH=amd64 GOOS=darwin) (push) Waiting to run
Build / build-cross (GOARCH=amd64 GOOS=linux) (push) Waiting to run
Build / build-cross (GOARCH=arm64 GOOS=darwin) (push) Waiting to run
Build / build-cross (GOARCH=arm64 GOOS=linux) (push) Waiting to run
Check Generated Files / check-generated (push) Waiting to run
Nix Flake Checks / build (push) Waiting to run
Nix Flake Checks / gotest (push) Waiting to run
Nix Flake Checks / golangci-lint (push) Waiting to run
Nix Flake Checks / formatting (push) Waiting to run
NixOS Module Tests / nix-module-check (push) Waiting to run
Server Tests / servertest (push) Waiting to run
Deploy docs / deploy (push) Has been cancelled
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
Some checks failed
Build / build-nix (push) Has been cancelled
Build / build-cross (GOARCH=amd64 GOOS=darwin) (push) Has been cancelled
Build / build-cross (GOARCH=amd64 GOOS=linux) (push) Has been cancelled
Build / build-cross (GOARCH=arm64 GOOS=darwin) (push) Has been cancelled
Build / build-cross (GOARCH=arm64 GOOS=linux) (push) Has been cancelled
Check Generated Files / check-generated (push) Has been cancelled
Build (main) / container (push) Has been cancelled
Build (main) / binaries (amd64, darwin) (push) Has been cancelled
Build (main) / binaries (amd64, linux) (push) Has been cancelled
Build (main) / binaries (arm64, darwin) (push) Has been cancelled
Build (main) / binaries (arm64, linux) (push) Has been cancelled
Nix Flake Checks / build (push) Has been cancelled
Nix Flake Checks / gotest (push) Has been cancelled
Nix Flake Checks / golangci-lint (push) Has been cancelled
Nix Flake Checks / formatting (push) Has been cancelled
NixOS Module Tests / nix-module-check (push) Has been cancelled
Server Tests / servertest (push) Has been cancelled
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
Some checks are pending
Build / build-nix (push) Waiting to run
Build / build-cross (GOARCH=amd64 GOOS=darwin) (push) Waiting to run
Build / build-cross (GOARCH=amd64 GOOS=linux) (push) Waiting to run
Build / build-cross (GOARCH=arm64 GOOS=darwin) (push) Waiting to run
Build / build-cross (GOARCH=arm64 GOOS=linux) (push) Waiting to run
Check Generated Files / check-generated (push) Waiting to run
Build (main) / container (push) Waiting to run
Build (main) / binaries (amd64, darwin) (push) Waiting to run
Build (main) / binaries (amd64, linux) (push) Waiting to run
Build (main) / binaries (arm64, darwin) (push) Waiting to run
Build (main) / binaries (arm64, linux) (push) Waiting to run
Nix Flake Checks / build (push) Waiting to run
Nix Flake Checks / gotest (push) Waiting to run
Nix Flake Checks / golangci-lint (push) Waiting to run
Nix Flake Checks / formatting (push) Waiting to run
NixOS Module Tests / nix-module-check (push) Waiting to run
Server Tests / servertest (push) Waiting to run
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
Some checks failed
Build / build-nix (push) Waiting to run
Build / build-cross (GOARCH=amd64 GOOS=darwin) (push) Waiting to run
Build / build-cross (GOARCH=amd64 GOOS=linux) (push) Waiting to run
Build / build-cross (GOARCH=arm64 GOOS=darwin) (push) Waiting to run
Build / build-cross (GOARCH=arm64 GOOS=linux) (push) Waiting to run
Check Generated Files / check-generated (push) Waiting to run
Build (main) / container (push) Waiting to run
Build (main) / binaries (amd64, darwin) (push) Waiting to run
Build (main) / binaries (amd64, linux) (push) Waiting to run
Build (main) / binaries (arm64, darwin) (push) Waiting to run
Build (main) / binaries (arm64, linux) (push) Waiting to run
NixOS Module Tests / nix-module-check (push) Waiting to run
Deploy docs / deploy (push) Has been cancelled
Tests / test (push) Has been cancelled
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
Some checks are pending
Build / build-nix (push) Waiting to run
Build / build-cross (GOARCH=amd64 GOOS=darwin) (push) Waiting to run
Build / build-cross (GOARCH=amd64 GOOS=linux) (push) Waiting to run
Build / build-cross (GOARCH=arm64 GOOS=darwin) (push) Waiting to run
Build / build-cross (GOARCH=arm64 GOOS=linux) (push) Waiting to run
Check Generated Files / check-generated (push) Waiting to run
Build (main) / container (push) Waiting to run
Build (main) / binaries (amd64, darwin) (push) Waiting to run
Build (main) / binaries (amd64, linux) (push) Waiting to run
Build (main) / binaries (arm64, darwin) (push) Waiting to run
Build (main) / binaries (arm64, linux) (push) Waiting to run
Deploy docs / deploy (push) Waiting to run
NixOS Module Tests / nix-module-check (push) Waiting to run
Tests / test (push) Waiting to run
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
Kristoffer Dalby
a00de89c85 ci: run nix jobs with --fallback
Some checks are pending
Build / build-nix (push) Waiting to run
Build / build-cross (GOARCH=amd64 GOOS=darwin) (push) Waiting to run
Build / build-cross (GOARCH=amd64 GOOS=linux) (push) Waiting to run
Build / build-cross (GOARCH=arm64 GOOS=darwin) (push) Waiting to run
Build / build-cross (GOARCH=arm64 GOOS=linux) (push) Waiting to run
Check Generated Files / check-generated (push) Waiting to run
Build (main) / container (push) Waiting to run
Build (main) / binaries (amd64, darwin) (push) Waiting to run
Build (main) / binaries (amd64, linux) (push) Waiting to run
Build (main) / binaries (arm64, darwin) (push) Waiting to run
Build (main) / binaries (arm64, linux) (push) Waiting to run
NixOS Module Tests / nix-module-check (push) Waiting to run
Tests / test (push) Waiting to run
cache.nixos.org intermittently serves corrupt NARs; build from source on
substitute failure instead of failing the job.
2026-06-18 21:11:14 +02:00
Kristoffer Dalby
f5198841bd ci: drop Docker v28 pin from release workflow
Engine 29 build compat is fixed in the client; the downgrade is no longer
needed.
2026-06-18 21:11:14 +02:00
Kristoffer Dalby
dfc25f06e1 integration: pin docker client to daemon API version
dockertest's bundled client builds against API v1.25; Docker Engine 29
rejects it (min 1.40), surfacing as a "broken pipe" that fails every local
image build. Pin to the daemon's reported version so builds use a live path.

Closes #2937
2026-06-18 21:11:14 +02:00
Kristoffer Dalby
74928c0241 integration: regenerate test matrix for new CLI tests
Some checks are pending
Build / build-nix (push) Waiting to run
Build / build-cross (GOARCH=amd64 GOOS=darwin) (push) Waiting to run
Build / build-cross (GOARCH=amd64 GOOS=linux) (push) Waiting to run
Build / build-cross (GOARCH=arm64 GOOS=darwin) (push) Waiting to run
Build / build-cross (GOARCH=arm64 GOOS=linux) (push) Waiting to run
Check Generated Files / check-generated (push) Waiting to run
Build (main) / container (push) Waiting to run
Build (main) / binaries (amd64, darwin) (push) Waiting to run
Build (main) / binaries (amd64, linux) (push) Waiting to run
Build (main) / binaries (arm64, darwin) (push) Waiting to run
Build (main) / binaries (arm64, linux) (push) Waiting to run
Deploy docs / deploy (push) Waiting to run
NixOS Module Tests / nix-module-check (push) Waiting to run
Tests / test (push) Waiting to run
2026-06-18 15:21:11 +02:00
Kristoffer Dalby
9aba06ec7d integration: backfill CLI command coverage
Regroup the CLI tests into per-command files and cover every command
except debug/config with CRUD, flag and error permutations, asserted as
JSON.
2026-06-18 15:21:11 +02:00
Kristoffer Dalby
00afce77b1 ci: run nixos module check with --fallback
cache.nixos.org intermittently serves corrupt NARs; build from source
on substitute failure instead of failing the job.
2026-06-18 14:54:44 +02:00
Kristoffer Dalby
9f1886f587 db: preserve user_id on untagged nodes with tags='null'
A nil tags slice marshals to JSON `null`; the clear-tagged migration
read that as tagged and cleared user_id. Exclude it, and recover
already-detached nodes from their pre-auth key.

Fixes #3323
2026-06-18 14:54:44 +02:00
Kristoffer Dalby
ea5165e325 util, db: generate key material as hex via tailscale rands
Some checks are pending
Build / build-nix (push) Waiting to run
Build / build-cross (GOARCH=amd64 GOOS=darwin) (push) Waiting to run
Build / build-cross (GOARCH=amd64 GOOS=linux) (push) Waiting to run
Build / build-cross (GOARCH=arm64 GOOS=darwin) (push) Waiting to run
Build / build-cross (GOARCH=arm64 GOOS=linux) (push) Waiting to run
Check Generated Files / check-generated (push) Waiting to run
Build (main) / container (push) Waiting to run
Build (main) / binaries (amd64, darwin) (push) Waiting to run
Build (main) / binaries (amd64, linux) (push) Waiting to run
Build (main) / binaries (arm64, darwin) (push) Waiting to run
Build (main) / binaries (arm64, linux) (push) Waiting to run
NixOS Module Tests / nix-module-check (push) Waiting to run
Tests / test (push) Waiting to run
2026-06-17 16:12:19 +02:00
Kristoffer Dalby
368b9e7edd state: add regression test for NodeKey-rotation binding 2026-06-17 16:12:19 +02:00
Kristoffer Dalby
abfd5c9236 types: reject port-bearing server_url under base_domain 2026-06-17 16:12:19 +02:00
Kristoffer Dalby
e4cd846075 handlers: set verify Content-Type before writing the body 2026-06-17 16:12:19 +02:00
Kristoffer Dalby
4b693a1320 oidc, types: validate pkce.method when OIDC is active 2026-06-17 16:12:19 +02:00
Kristoffer Dalby
4f4e95fc80 all: adopt strings, errors, and os helpers 2026-06-17 16:12:19 +02:00
Kristoffer Dalby
27468f944b all: adopt maps and cmp helpers 2026-06-17 16:12:19 +02:00
Kristoffer Dalby
ac725f27e4 all: adopt slices helpers 2026-06-17 16:12:19 +02:00
Kristoffer Dalby
60f0544b78 dns, change, noise, auth, capver: misc consolidation 2026-06-17 16:12:19 +02:00
Kristoffer Dalby
ea5e52f662 hi: consolidate doctor and stats helpers 2026-06-17 16:12:19 +02:00
Kristoffer Dalby
722a2f1e18 integration, hsic, tsic, dockertestutil: consolidate container helpers 2026-06-17 16:12:19 +02:00
Kristoffer Dalby
75b09cb991 servertest: consolidate harness helpers 2026-06-17 16:12:19 +02:00
Kristoffer Dalby
0320b56911 oidc: consolidate cookie helpers and logging 2026-06-17 16:12:19 +02:00
Kristoffer Dalby
62869f01d2 app: simplify TLS and auth setup 2026-06-17 16:12:19 +02:00
Kristoffer Dalby
19d5d9deeb derp: simplify DERP map handling 2026-06-17 16:12:19 +02:00
Kristoffer Dalby
c3cfbf1cc0 grpcv1: share list-RPC response helpers 2026-06-17 16:12:19 +02:00
Kristoffer Dalby
fdc7e26c8a util: consolidate parsing and encoding helpers 2026-06-17 16:12:19 +02:00
Kristoffer Dalby
45e6e90d11 types: consolidate DNS, identifier, and proto helpers 2026-06-17 16:12:19 +02:00
Kristoffer Dalby
468f70a9e5 templates: consolidate shared page and component helpers 2026-06-17 16:12:19 +02:00
Kristoffer Dalby
8c10a96921 hscontrol: consolidate debug-endpoint and template helpers 2026-06-17 16:12:19 +02:00
Kristoffer Dalby
3979887d8d mapper: consolidate builder and connection helpers 2026-06-17 16:12:19 +02:00
Kristoffer Dalby
03e2d24c79 policy/v2, policy/matcher: consolidate alias and IP-set helpers 2026-06-17 16:12:19 +02:00
Kristoffer Dalby
e6ef1dda4d policy: consolidate cache-invalidation and evaluation helpers 2026-06-17 16:12:19 +02:00
Kristoffer Dalby
145672f0b6 state: consolidate route-election and node helpers 2026-06-17 16:12:19 +02:00
Kristoffer Dalby
77f289a627 db: consolidate IP allocation helpers 2026-06-17 16:12:19 +02:00
Kristoffer Dalby
55b9e3cfda db, sqliteconfig: consolidate query and config helpers 2026-06-17 16:12:19 +02:00
Kristoffer Dalby
b56326427c cli: consolidate output and table rendering 2026-06-17 16:12:19 +02:00
Kristoffer Dalby
0b7c8aa270 cli: consolidate gRPC command helpers 2026-06-17 16:12:19 +02:00
Kristoffer Dalby
b88a3cb7b2 servertest, hi, integration: remove dead test helpers 2026-06-17 16:12:19 +02:00
Kristoffer Dalby
ed6596f9d7 policy, mapper, capver, hscontrol: remove dead code 2026-06-17 16:12:19 +02:00
Kristoffer Dalby
f49f27adf8 db, sqliteconfig: remove dead and unreachable code 2026-06-17 16:12:19 +02:00
Kristoffer Dalby
43ba20bebb util: remove dead helpers and stale comments 2026-06-17 16:12:19 +02:00
Kristoffer Dalby
c8bd4a0947 types: remove dead types, constants, and methods 2026-06-17 16:12:19 +02:00
Kristoffer Dalby
84168fb90a templates: remove dead style helpers and deprecated shims 2026-06-17 16:12:19 +02:00
Kristoffer Dalby
b0c221f3a1 changelog: set 0.29 date
Some checks failed
Build / build-nix (push) Waiting to run
Build / build-cross (GOARCH=amd64 GOOS=darwin) (push) Waiting to run
Build / build-cross (GOARCH=amd64 GOOS=linux) (push) Waiting to run
Build / build-cross (GOARCH=arm64 GOOS=darwin) (push) Waiting to run
Build / build-cross (GOARCH=arm64 GOOS=linux) (push) Waiting to run
Check Generated Files / check-generated (push) Waiting to run
NixOS Module Tests / nix-module-check (push) Waiting to run
Tests / test (push) Waiting to run
Deploy docs / deploy (push) Has been cancelled
Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2026-06-17 11:09:20 +02:00
371 changed files with 32143 additions and 20923 deletions

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 --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
@ -63,7 +63,7 @@ jobs:
- name: Run nix build
if: steps.changed-files.outputs.files == 'true'
run: nix build
run: nix build --fallback
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
if: steps.changed-files.outputs.files == '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 --command -- go build -o "headscale"
run: env ${{ matrix.env }} go build -o "headscale"
./cmd/headscale
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:

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,20 +32,15 @@ jobs:
- '*.nix'
- 'go.*'
- '**/*.go'
- '**/*.proto'
- 'buf.gen.yaml'
- '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 --command -- make generate
run: make generate
- name: Check for uncommitted changes
if: steps.changed-files.outputs.files == 'true'

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 --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

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 --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 --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 --command -- go build -o headscale ./cmd/headscale
run: go build -o headscale ./cmd/headscale
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:

39
.github/workflows/gc.yml vendored Normal file
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 }}

View file

@ -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 }}

View file

@ -1,93 +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 --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 --command -- prettier --no-error-on-unmatched-pattern
--ignore-unknown --check **/*.{ts,js,md,yaml,yml,sass,css,scss,html}
proto-lint:
runs-on: ubuntu-latest
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 }}
- name: Buf lint
run: nix develop --command -- buf lint proto

56
.github/workflows/nix-checks.yml vendored Normal file
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

View file

@ -38,18 +38,14 @@ 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'
run: |
echo "Running NixOS module integration test..."
nix build .#checks.x86_64-linux.headscale -L
nix build .#checks.x86_64-linux.headscale -L --fallback

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'
@ -17,25 +21,6 @@ jobs:
with:
fetch-depth: 0
- name: Pin Docker to v28 (avoid v29 breaking changes)
run: |
# Docker 29 breaks docker build via Go client libraries and
# docker load/save with certain tarball formats.
# Pin to Docker 28.x until our tooling is updated.
# https://github.com/actions/runner-images/issues/13474
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
| sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
| sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update -qq
VERSION=$(apt-cache madison docker-ce | grep '28\.5' | head -1 | awk '{print $3}')
sudo apt-get install -y --allow-downgrades \
"docker-ce=${VERSION}" "docker-ce-cli=${VERSION}"
sudo systemctl restart docker
docker version
- name: Login to DockerHub
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
@ -49,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 --command -- goreleaser release --clean
run: goreleaser release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

36
.github/workflows/servertest.yml vendored Normal file
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/...

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 --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 --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,7 +241,7 @@ jobs:
- TestACLDynamicUnknownUserRemoval
- TestAPIAuthenticationBypass
- TestAPIAuthenticationBypassCurl
- TestGRPCAuthenticationBypass
- TestRemoteCLIAuthenticationBypass
- TestCLIWithConfigAuthenticationBypass
- TestAuthKeyLogoutAndReloginSameUser
- TestAuthKeyLogoutAndReloginNewUser
@ -265,20 +262,34 @@ jobs:
- TestAuthWebFlowAuthenticationPingAll
- TestAuthWebFlowLogoutAndReloginSameUser
- TestAuthWebFlowLogoutAndReloginNewUser
- TestPolicyCheckCommand
- TestSSHTestsRejectFailingPolicy
- TestUserCommand
- TestPreAuthKeyCommand
- TestPreAuthKeyCommandWithoutExpiry
- TestPreAuthKeyCommandReusableEphemeral
- TestPreAuthKeyCorrectUserLoggedInCommand
- TestTaggedNodesCLIOutput
- TestApiKeyCommand
- TestApiKeyCommandValidation
- TestAuthCommandValidation
- TestNodeCommand
- TestNodeExpireCommand
- TestNodeRenameCommand
- TestPreAuthKeyCorrectUserLoggedInCommand
- TestTaggedNodesCLIOutput
- TestNodeExpireFlagsCommand
- TestNodeCommandValidation
- TestNodeTagCommand
- TestNodeRouteCommands
- TestNodeBackfillIPsCommand
- TestOAuthClientCommand
- TestOAuthClientCommandValidation
- TestPolicyCheckCommand
- TestSSHTestsRejectFailingPolicy
- TestPolicyCommand
- TestPolicyBrokenConfigCommand
- TestPreAuthKeyCommand
- TestPreAuthKeyCommandWithoutExpiry
- TestPreAuthKeyCommandReusableEphemeral
- TestPreAuthKeyDeleteCommand
- TestPreAuthKeyCommandValidation
- TestServerInfoCommands
- TestUserCommand
- TestUserCreateCommand
- TestUserCommandValidation
- TestDERPVerifyEndpoint
- TestResolveMagicDNS
- TestResolveMagicDNSExtraRecordsPath
@ -300,6 +311,7 @@ jobs:
- Test2118DeletingOnlineNodePanics
- TestGrantCapRelay
- TestGrantCapDrive
- TestK8sOperator
- TestEnablingRoutes
- TestHASubnetRouterFailover
- TestSubnetRouteACL
@ -367,6 +379,8 @@ jobs:
- TestTagsAuthKeyWithoutUserInheritsTags
- TestTagsAuthKeyWithoutUserRejectsAdvertisedTags
- TestTagsAuthKeyConvertToUserViaCLIRegister
- TestTS2021WebSocketGET
- TestTS2021WASMClientUnderNode
- TestTailscaleRustAxum
uses: ./.github/workflows/integration-test-template.yml
secrets: inherit

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 --command -- gotestsum

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"

3
.gitignore vendored
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

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

View file

@ -275,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

View file

@ -1,6 +1,69 @@
# CHANGELOG
## 0.29.0 (202x-xx-xx)
## 0.30.0 (202x-xx-xx)
**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**
### Changes
- Fix nodes with `tags='null'` losing their assigned user on upgrade [#3325](https://github.com/juanfont/headscale/pull/3325)
## 0.29.0 (2026-06-17)
**Minimum supported Tailscale client version: v1.80.0**

30
Dockerfile.wasmclient Normal file
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"]

View file

@ -20,7 +20,6 @@ endef
# Source file collections using shell find for better performance
GO_SOURCES := $(shell find . -name '*.go' -not -path './gen/*' -not -path './vendor/*')
PROTO_SOURCES := $(shell find . -name '*.proto' -not -path './gen/*' -not -path './vendor/*')
PRETTIER_SOURCES := $(shell find . \( -name '*.md' -o -name '*.yaml' -o -name '*.yml' -o -name '*.ts' -o -name '*.js' -o -name '*.html' -o -name '*.css' -o -name '*.scss' -o -name '*.sass' \) -not -path './gen/*' -not -path './vendor/*' -not -path './node_modules/*')
# Default target
@ -35,8 +34,6 @@ check-deps:
$(call check_tool,gofumpt)
$(call check_tool,mdformat)
$(call check_tool,prettier)
$(call check_tool,clang-format)
$(call check_tool,buf)
# Build targets
.PHONY: build
@ -53,7 +50,7 @@ test: check-deps $(GO_SOURCES) go.mod go.sum
# Formatting targets
.PHONY: fmt
fmt: fmt-go fmt-mdformat fmt-prettier fmt-proto
fmt: fmt-go fmt-mdformat fmt-prettier
.PHONY: fmt-go
fmt-go: check-deps $(GO_SOURCES)
@ -71,35 +68,49 @@ fmt-prettier: check-deps $(PRETTIER_SOURCES)
@echo "Formatting markup and config files..."
prettier --write '**/*.{ts,js,md,yaml,yml,sass,css,scss,html}'
.PHONY: fmt-proto
fmt-proto: check-deps $(PROTO_SOURCES)
@echo "Formatting Protocol Buffer files..."
clang-format -i $(PROTO_SOURCES)
# Linting targets
.PHONY: lint
lint: lint-go lint-proto
lint: lint-go
.PHONY: lint-go
lint-go: check-deps $(GO_SOURCES) go.mod go.sum
@echo "Linting Go code..."
golangci-lint run --timeout 10m
.PHONY: lint-proto
lint-proto: check-deps $(PROTO_SOURCES)
@echo "Linting Protocol Buffer files..."
cd proto/ && buf lint
# Code generation
.PHONY: generate
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
@ -119,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, proto)"
@echo " lint - Lint all code (Go, proto)"
@echo " generate - Generate code from Protocol Buffers"
@echo " fmt - Format all code (Go, docs, markup)"
@echo " lint - Lint all code (Go)"
@echo " generate - Generate code (go generate + client)"
@echo " dev - Full development workflow (fmt + lint + test + build)"
@echo " clean - Clean build artifacts"
@echo ""
@ -129,9 +140,7 @@ help:
@echo " fmt-go - Format Go code only"
@echo " fmt-mdformat - Format documentation only"
@echo " fmt-prettier - Format markup and config files only"
@echo " fmt-proto - Format Protocol Buffer files only"
@echo " lint-go - Lint Go code only"
@echo " lint-proto - Lint Protocol Buffer files only"
@echo ""
@echo "Dependencies:"
@echo " check-deps - Verify required tools are available"

View file

@ -1,21 +0,0 @@
version: v1
plugins:
- name: go
out: gen/go
opt:
- paths=source_relative
- name: go-grpc
out: gen/go
opt:
- paths=source_relative
- name: grpc-gateway
out: gen/go
opt:
- paths=source_relative
- generate_unbound_methods=true
# - name: gorm
# out: gen/go
# opt:
# - paths=source_relative,enums=string,gateway=true
- name: openapiv2
out: gen/openapiv2

View file

@ -42,8 +42,7 @@ go tool mts node1 status
| `--port` | 8080 | Headscale listen port |
| `--keep` | false | Keep state directory on exit |
The metrics/debug port is `port + 1010` (default 9090) and the gRPC
port is `port + 42363` (default 50443).
The metrics/debug port is `port + 1010` (default 9090).
## What it does

View file

@ -28,17 +28,14 @@ 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 both the derived
// metrics port (port+1010) and gRPC port (port+42363) inside the valid
// 1..65535 TCP range.
const maxDevPort = 23172
// 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 = `---
server_url: http://127.0.0.1:%d
listen_addr: 127.0.0.1:%d
metrics_listen_addr: 127.0.0.1:%d
grpc_listen_addr: 127.0.0.1:%d
grpc_allow_insecure: true
noise:
private_key_path: %s/noise_private.key
@ -83,7 +80,7 @@ func main() {
if *port < 1 || *port > maxDevPort {
log.Fatalf(
"--port must be in 1..%d (higher values overflow the derived gRPC port); got %d",
"--port must be in 1..%d (higher values overflow the derived metrics port); got %d",
maxDevPort, *port,
)
}
@ -101,7 +98,6 @@ func main() {
func run() error {
metricsPort := *port + 1010 // default 9090
grpcPort := *port + 42363 // default 50443
tmpDir, err := os.MkdirTemp("", "headscale-dev-")
if err != nil {
@ -114,8 +110,9 @@ func run() error {
// Write config.
configPath := filepath.Join(tmpDir, "config.yaml")
configContent := fmt.Sprintf(devConfig,
*port, *port, metricsPort, grpcPort,
configContent := fmt.Sprintf(
devConfig,
*port, *port, metricsPort,
tmpDir, tmpDir, tmpDir,
)
@ -193,7 +190,8 @@ func run() error {
}
// Print banner.
fmt.Printf(`
fmt.Printf(
`
=== Headscale Dev Environment ===
Server: http://127.0.0.1:%d
Metrics: http://127.0.0.1:%d

75
cmd/gen-openapi/main.go Normal file
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)
}

View file

@ -3,11 +3,11 @@ package cli
import (
"context"
"fmt"
"net/http"
"strconv"
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
clientv1 "github.com/juanfont/headscale/gen/client/v1"
"github.com/juanfont/headscale/hscontrol/util"
"github.com/pterm/pterm"
"github.com/spf13/cobra"
)
@ -44,32 +44,40 @@ var listAPIKeys = &cobra.Command{
Use: cmdList,
Short: "List the Api keys for headscale",
Aliases: []string{"ls", cmdShow},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
response, err := client.ListApiKeys(ctx, &v1.ListApiKeysRequest{})
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, response.GetApiKeys(), func() error {
tableData := make(pterm.TableData, 1, 1+len(response.GetApiKeys()))
tableData[0] = []string{"ID", "Prefix", colExpiration, colCreated}
if resp.StatusCode() != http.StatusOK {
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
}
for _, key := range response.GetApiKeys() {
apiKeys := resp.JSON200.ApiKeys
return printListOutput(cmd, apiKeys, func() error {
rows := make([][]string, 0, len(apiKeys))
for _, key := range apiKeys {
expiration := "-"
if key.GetExpiration() != nil {
expiration = ColourTime(key.GetExpiration().AsTime())
if key.Expiration != nil {
expiration = ColourTime(*key.Expiration)
}
tableData = append(tableData, []string{
strconv.FormatUint(key.GetId(), util.Base10),
key.GetPrefix(),
var created string
if key.CreatedAt != nil {
created = key.CreatedAt.Format(HeadscaleDateTimeFormat)
}
rows = append(rows, []string{
key.Id,
key.Prefix,
expiration,
key.GetCreatedAt().AsTime().Format(HeadscaleDateTimeFormat),
created,
})
}
return pterm.DefaultTable.WithHasHeader().WithData(tableData).Render()
return renderTable([]string{"ID", "Prefix", colExpiration, colCreated}, rows)
})
}),
}
@ -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: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, 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
}
response, err := client.CreateApiKey(ctx, &v1.CreateApiKeyRequest{
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, response.GetApiKey(), response.GetApiKey())
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: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, 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
}
response, err := client.ExpireApiKey(ctx, &v1.ExpireApiKeyRequest{
Id: id,
Prefix: 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, response, "Key expired")
if resp.StatusCode() != http.StatusOK {
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
}
return printOutput(cmd, resp.JSON200, "Key expired")
}),
}
@ -141,20 +165,59 @@ var deleteAPIKeyCmd = &cobra.Command{
Use: cmdDelete,
Short: "Delete an ApiKey",
Aliases: []string{"remove", aliasDel},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, 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
}
response, err := client.DeleteApiKey(ctx, &v1.DeleteApiKeyRequest{
Id: id,
Prefix: prefix,
})
// 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 = apiKeyPrefixForID(ctx, client, id)
if err != nil {
return err
}
}
resp, err := client.DeleteApiKeyWithResponse(ctx, prefix, &clientv1.DeleteApiKeyParams{})
if err != nil {
return fmt.Errorf("deleting api key: %w", err)
}
return printOutput(cmd, response, "Key deleted")
if resp.StatusCode() != http.StatusOK {
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
}
return printOutput(cmd, resp.JSON200, "Key deleted")
}),
}
// 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)
}
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: api key %d not found", errMissingParameter, id)
}

View file

@ -3,8 +3,9 @@ package cli
import (
"context"
"fmt"
"net/http"
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
clientv1 "github.com/juanfont/headscale/gen/client/v1"
"github.com/spf13/cobra"
)
@ -33,61 +34,66 @@ var authCmd = &cobra.Command{
var authRegisterCmd = &cobra.Command{
Use: "register",
Short: "Register a node to your network",
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, 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")
request := &v1.AuthRegisterRequest{
AuthId: authID,
User: user,
}
response, err := client.AuthRegister(ctx, request)
resp, err := client.AuthRegisterWithResponse(ctx, clientv1.AuthRegisterJSONRequestBody{
AuthId: &authID,
User: &user,
})
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,
response.GetNode(),
fmt.Sprintf("Node %s registered", response.GetNode().GetGivenName()))
node,
fmt.Sprintf("Node %s registered", node.GivenName),
)
}),
}
var authApproveCmd = &cobra.Command{
Use: "approve",
Short: "Approve a pending authentication request",
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error {
authID, _ := cmd.Flags().GetString("auth-id")
request := &v1.AuthApproveRequest{
AuthId: authID,
}
response, err := client.AuthApprove(ctx, request)
resp, err := client.AuthApproveWithResponse(ctx, clientv1.AuthApproveJSONRequestBody{AuthId: &authID})
if err != nil {
return fmt.Errorf("approving auth request: %w", err)
}
return printOutput(cmd, response, "Auth request approved")
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: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error {
authID, _ := cmd.Flags().GetString("auth-id")
request := &v1.AuthRejectRequest{
AuthId: authID,
}
response, err := client.AuthReject(ctx, request)
resp, err := client.AuthRejectWithResponse(ctx, clientv1.AuthRejectJSONRequestBody{AuthId: &authID})
if err != nil {
return fmt.Errorf("rejecting auth request: %w", err)
}
return printOutput(cmd, response, "Auth request rejected")
if resp.StatusCode() != http.StatusOK {
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
}
return printOutput(cmd, resp.JSON200, "Auth request rejected")
}),
}

View file

@ -3,8 +3,9 @@ package cli
import (
"context"
"fmt"
"net/http"
v1 "github.com/juanfont/headscale/gen/go/headscale/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: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, 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,18 +45,20 @@ var createNodeCmd = &cobra.Command{
routes, _ := cmd.Flags().GetStringSlice("route")
request := &v1.DebugCreateNodeRequest{
Key: registrationID,
Name: name,
User: user,
Routes: routes,
}
response, err := client.DebugCreateNode(ctx, request)
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, response.GetNode(), "Node created")
if resp.StatusCode() != http.StatusOK {
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
}
return printOutput(cmd, resp.JSON200.Node, "Node created")
}),
}

View file

@ -3,8 +3,9 @@ package cli
import (
"context"
"fmt"
"net/http"
v1 "github.com/juanfont/headscale/gen/go/headscale/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: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
response, err := client.Health(ctx, &v1.HealthRequest{})
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, response, "")
if resp.StatusCode() != http.StatusOK {
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
}
return printOutput(cmd, resp.JSON200, "")
}),
}

View file

@ -3,6 +3,7 @@ package cli
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
@ -16,19 +17,15 @@ import (
"github.com/spf13/cobra"
)
// Error is used to compare errors as per https://dave.cheney.net/2016/04/07/constant-errors
type Error string
func (e Error) Error() string { return string(e) }
const (
errMockOidcClientIDNotDefined = Error("MOCKOIDC_CLIENT_ID not defined")
errMockOidcClientSecretNotDefined = Error("MOCKOIDC_CLIENT_SECRET not defined")
errMockOidcPortNotDefined = Error("MOCKOIDC_PORT not defined")
errMockOidcUsersNotDefined = Error("MOCKOIDC_USERS not defined")
refreshTTL = 60 * time.Minute
var (
errMockOidcClientIDNotDefined = errors.New("MOCKOIDC_CLIENT_ID not defined")
errMockOidcClientSecretNotDefined = errors.New("MOCKOIDC_CLIENT_SECRET not defined")
errMockOidcPortNotDefined = errors.New("MOCKOIDC_PORT not defined")
errMockOidcUsersNotDefined = errors.New("MOCKOIDC_USERS not defined")
)
const refreshTTL = 60 * time.Minute
var accessTTL = 2 * time.Minute
func init() {

View file

@ -3,17 +3,17 @@ package cli
import (
"context"
"fmt"
"net/http"
"net/netip"
"strconv"
"strings"
"time"
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
clientv1 "github.com/juanfont/headscale/gen/client/v1"
"github.com/juanfont/headscale/hscontrol/util"
"github.com/pterm/pterm"
"github.com/samber/lo"
"github.com/spf13/cobra"
"google.golang.org/protobuf/types/known/timestamppb"
"tailscale.com/types/key"
)
@ -67,24 +67,31 @@ 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: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, 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")
request := &v1.RegisterNodeRequest{
Key: registrationID,
User: user,
params := &clientv1.RegisterNodeParams{
User: &user,
Key: &registrationID,
}
response, err := client.RegisterNode(ctx, request)
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,
response.GetNode(),
fmt.Sprintf("Node %s registered", response.GetNode().GetGivenName()))
node,
fmt.Sprintf("Node %s registered", node.GivenName),
)
}),
}
@ -92,16 +99,27 @@ var listNodesCmd = &cobra.Command{
Use: cmdList,
Short: "List nodes",
Aliases: []string{"ls", cmdShow},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, 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")
response, err := client.ListNodes(ctx, &v1.ListNodesRequest{User: 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, response.GetNodes(), func() error {
tableData, err := nodesToPtables(response.GetNodes())
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)
}
@ -115,27 +133,33 @@ var listNodeRoutesCmd = &cobra.Command{
Use: "list-routes",
Short: "List routes available on nodes",
Aliases: []string{"lsr", "routes"},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, 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")
response, err := client.ListNodes(ctx, &v1.ListNodesRequest{})
resp, err := client.ListNodesWithResponse(ctx, &clientv1.ListNodesParams{})
if err != nil {
return fmt.Errorf("listing nodes: %w", err)
}
nodes := response.GetNodes()
if resp.StatusCode() != http.StatusOK {
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
}
nodes := resp.JSON200.Nodes
if identifier != 0 {
for _, node := range response.GetNodes() {
if node.GetId() == identifier {
nodes = []*v1.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 *v1.Node, _ int) bool {
return (n.GetSubnetRoutes() != nil && len(n.GetSubnetRoutes()) > 0) || (n.GetApprovedRoutes() != nil && len(n.GetApprovedRoutes()) > 0) || (n.GetAvailableRoutes() != nil && len(n.GetAvailableRoutes()) > 0)
nodes = lo.Filter(nodes, func(n clientv1.Node, _ int) bool {
return len(n.SubnetRoutes) > 0 || len(n.ApprovedRoutes) > 0 || len(n.AvailableRoutes) > 0
})
return printListOutput(cmd, nodes, func() error {
@ -151,23 +175,27 @@ var expireNodeCmd = &cobra.Command{
Use --disable to disable key expiry (node will never expire).`,
Aliases: []string{"logout", aliasExp, "e"},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, 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 {
request := &v1.ExpireNodeRequest{
NodeId: identifier,
DisableExpiry: true,
}
disable := true
response, err := client.ExpireNode(ctx, request)
resp, err := client.ExpireNodeWithResponse(ctx, nodeID, clientv1.ExpireNodeJSONRequestBody{
DisableExpiry: &disable,
})
if err != nil {
return fmt.Errorf("disabling node expiry: %w", err)
}
return printOutput(cmd, response.GetNode(), "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")
@ -185,28 +213,31 @@ Use --disable to disable key expiry (node will never expire).`,
}
}
request := &v1.ExpireNodeRequest{
NodeId: identifier,
Expiry: timestamppb.New(expiryTime),
}
response, err := client.ExpireNode(ctx, request)
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, response.GetNode(), "Node expired")
if resp.StatusCode() != http.StatusOK {
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
}
return printOutput(cmd, response.GetNode(), "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: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, 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 := ""
@ -214,17 +245,16 @@ var renameNodeCmd = &cobra.Command{
newName = args[0]
}
request := &v1.RenameNodeRequest{
NodeId: identifier,
NewName: newName,
}
response, err := client.RenameNode(ctx, request)
resp, err := client.RenameNodeWithResponse(ctx, strconv.FormatUint(identifier, util.Base10), newName)
if err != nil {
return fmt.Errorf("renaming node: %w", err)
}
return printOutput(cmd, response.GetNode(), "Node renamed")
if resp.StatusCode() != http.StatusOK {
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
}
return printOutput(cmd, resp.JSON200.Node, "Node renamed")
}),
}
@ -232,34 +262,35 @@ var deleteNodeCmd = &cobra.Command{
Use: cmdDelete,
Short: "Delete a node",
Aliases: []string{aliasDel},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, 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)
getRequest := &v1.GetNodeRequest{
NodeId: identifier,
}
getResponse, err := client.GetNode(ctx, getRequest)
getResponse, err := client.GetNodeWithResponse(ctx, nodeID)
if err != nil {
return fmt.Errorf("getting node: %w", err)
}
deleteRequest := &v1.DeleteNodeRequest{
NodeId: identifier,
if getResponse.StatusCode() != http.StatusOK {
return apiError(getResponse.StatusCode(), getResponse.ApplicationproblemJSONDefault)
}
if !confirmAction(cmd, fmt.Sprintf(
"Do you want to remove the node %s?",
getResponse.GetNode().GetName(),
getResponse.JSON200.Node.Name,
)) {
return printOutput(cmd, map[string]string{colResult: "Node not deleted"}, "Node not deleted")
}
_, err = client.DeleteNode(ctx, deleteRequest)
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"},
@ -288,23 +319,26 @@ be assigned to nodes.`,
return nil
}
ctx, client, conn, cancel, err := newHeadscaleCLIWithConfig()
if err != nil {
return fmt.Errorf("connecting to headscale: %w", err)
}
defer cancel()
defer conn.Close()
return withClient(func(ctx context.Context, client *clientv1.ClientWithResponses) error {
confirmed := true
changes, err := client.BackfillNodeIPs(ctx, &v1.BackfillNodeIPsRequest{Confirmed: true})
if err != nil {
return fmt.Errorf("backfilling IPs: %w", err)
}
resp, err := client.BackfillNodeIPsWithResponse(ctx, &clientv1.BackfillNodeIPsParams{
Confirmed: &confirmed,
})
if err != nil {
return fmt.Errorf("backfilling IPs: %w", err)
}
return printOutput(cmd, changes, "Node IPs backfilled successfully")
if resp.StatusCode() != http.StatusOK {
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
}
return printOutput(cmd, resp.JSON200, "Node IPs backfilled successfully")
})
},
}
func nodesToPtables(nodes []*v1.Node) (pterm.TableData, error) {
func nodesToPtables(nodes []clientv1.Node) (pterm.TableData, error) {
tableHeader := []string{
"ID",
"Hostname",
@ -324,81 +358,49 @@ func nodesToPtables(nodes []*v1.Node) (pterm.TableData, error) {
tableData[0] = tableHeader
for _, node := range nodes {
var ephemeral bool
if node.GetPreAuthKey() != nil && node.GetPreAuthKey().GetEphemeral() {
ephemeral = true
// 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 != nil {
lastSeenTime = node.LastSeen.Format(HeadscaleDateTimeFormat)
}
var (
lastSeen time.Time
lastSeenTime string
)
if node.GetLastSeen() != nil {
lastSeen = node.GetLastSeen().AsTime()
lastSeenTime = lastSeen.Format(HeadscaleDateTimeFormat)
}
var (
expiry time.Time
expiryTime string
)
if node.GetExpiry() != nil {
expiry = node.GetExpiry().AsTime()
expiryTime = expiry.Format(HeadscaleDateTimeFormat)
} else {
expiryTime = "N/A"
expiryTime := "N/A"
if node.Expiry != nil {
expiryTime = node.Expiry.Format(HeadscaleDateTimeFormat)
}
var machineKey key.MachinePublic
err := machineKey.UnmarshalText(
[]byte(node.GetMachineKey()),
)
err := machineKey.UnmarshalText([]byte(node.MachineKey))
if err != nil {
machineKey = key.MachinePublic{}
}
var nodeKey key.NodePublic
err = nodeKey.UnmarshalText(
[]byte(node.GetNodeKey()),
)
err = nodeKey.UnmarshalText([]byte(node.NodeKey))
if err != nil {
return nil, err
}
var online string
if node.GetOnline() {
online := pterm.LightRed("offline")
if node.Online {
online = pterm.LightGreen("online")
} else {
online = pterm.LightRed("offline")
}
var expired string
if node.GetExpiry() != nil && node.GetExpiry().AsTime().Before(time.Now()) {
expired := pterm.LightGreen("no")
if node.Expiry != nil && node.Expiry.Before(time.Now()) {
expired = pterm.LightRed("yes")
} else {
expired = pterm.LightGreen("no")
}
var tagsBuilder strings.Builder
for _, tag := range node.GetTags() {
tagsBuilder.WriteString("\n" + tag)
}
tags := strings.TrimLeft(tagsBuilder.String(), "\n")
var user string
if node.GetUser() != nil {
user = node.GetUser().GetName()
}
tags := strings.Join(node.Tags, "\n")
var ipBuilder strings.Builder
for _, addr := range node.GetIpAddresses() {
for _, addr := range node.IpAddresses {
ip, err := netip.ParseAddr(addr)
if err == nil {
if ipBuilder.Len() > 0 {
@ -412,12 +414,12 @@ func nodesToPtables(nodes []*v1.Node) (pterm.TableData, error) {
ipAddresses := ipBuilder.String()
nodeData := []string{
strconv.FormatUint(node.GetId(), util.Base10),
node.GetName(),
node.GetGivenName(),
node.Id,
node.Name,
node.GivenName,
machineKey.ShortString(),
nodeKey.ShortString(),
user,
node.User.Name,
tags,
ipAddresses,
strconv.FormatBool(ephemeral),
@ -436,7 +438,7 @@ func nodesToPtables(nodes []*v1.Node) (pterm.TableData, error) {
}
func nodeRoutesToPtables(
nodes []*v1.Node,
nodes []clientv1.Node,
) pterm.TableData {
tableHeader := []string{
"ID",
@ -450,11 +452,11 @@ func nodeRoutesToPtables(
for _, node := range nodes {
nodeData := []string{
strconv.FormatUint(node.GetId(), util.Base10),
node.GetGivenName(),
strings.Join(node.GetApprovedRoutes(), "\n"),
strings.Join(node.GetAvailableRoutes(), "\n"),
strings.Join(node.GetSubnetRoutes(), "\n"),
node.Id,
node.GivenName,
strings.Join(node.ApprovedRoutes, "\n"),
strings.Join(node.AvailableRoutes, "\n"),
strings.Join(node.SubnetRoutes, "\n"),
}
tableData = append(
tableData,
@ -469,43 +471,43 @@ var tagCmd = &cobra.Command{
Use: "tag",
Short: "Manage the tags of a node",
Aliases: []string{"tags", "t"},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, 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")
// Sending tags to node
request := &v1.SetTagsRequest{
NodeId: identifier,
Tags: tagsToSet,
}
resp, err := client.SetTags(ctx, request)
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.GetNode(), "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: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, 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")
// Sending routes to node
request := &v1.SetApprovedRoutesRequest{
NodeId: identifier,
Routes: routes,
}
resp, err := client.SetApprovedRoutes(ctx, request)
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.GetNode(), "Node updated")
if resp.StatusCode() != http.StatusOK {
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
}
return printOutput(cmd, resp.JSON200.Node, "Node updated")
}),
}

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
}

View file

@ -1,11 +1,13 @@
package cli
import (
"context"
"errors"
"fmt"
"net/http"
"os"
v1 "github.com/juanfont/headscale/gen/go/headscale/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"
@ -14,14 +16,13 @@ import (
)
const (
bypassFlag = "bypass-grpc-and-access-database-directly" //nolint:gosec // not a credential
bypassFlag = "bypass-server-and-access-database-directly" //nolint:gosec // not a credential
)
var errAborted = errors.New("command aborted by user")
// bypassDatabase loads the server config and opens the database directly,
// bypassing the gRPC 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 {
@ -36,19 +37,29 @@ func bypassDatabase() (*db.HSDatabase, error) {
return d, nil
}
// openBypassDB confirms the destructive bypass action and opens the database
// directly. The caller is responsible for closing the returned handle.
func openBypassDB(cmd *cobra.Command) (*db.HSDatabase, error) {
if !confirmAction(cmd, "DO NOT run this command if an instance of headscale is running, are you sure headscale is not running?") {
return nil, errAborted
}
return bypassDatabase()
}
func init() {
rootCmd.AddCommand(policyCmd)
getPolicy.Flags().BoolP(bypassFlag, "", false, "Uses the headscale config to directly access the database, bypassing gRPC and does not require the server to be running")
getPolicy.Flags().BoolP(bypassFlag, "", false, "Uses the headscale config to directly access the database, bypassing the API and does not require the server to be running")
policyCmd.AddCommand(getPolicy)
setPolicy.Flags().StringP("file", "f", "", "Path to a policy file in HuJSON format")
setPolicy.Flags().BoolP(bypassFlag, "", false, "Uses the headscale config to directly access the database, bypassing gRPC and does not require the server to be running")
setPolicy.Flags().BoolP(bypassFlag, "", false, "Uses the headscale config to directly access the database, bypassing the API and does not require the server to be running")
mustMarkRequired(setPolicy, "file")
policyCmd.AddCommand(setPolicy)
checkPolicy.Flags().StringP("file", "f", "", "Path to a policy file in HuJSON format")
checkPolicy.Flags().BoolP(bypassFlag, "", false, "Open the database directly (no gRPC, no running server) to resolve user references and to evaluate the policy's tests and sshTests blocks. Required when those checks are needed.")
checkPolicy.Flags().BoolP(bypassFlag, "", false, "Open the database directly (no running server required) to resolve user references and to evaluate the policy's tests and sshTests blocks. Required when those checks are needed.")
mustMarkRequired(checkPolicy, "file")
policyCmd.AddCommand(checkPolicy)
}
@ -66,11 +77,7 @@ var getPolicy = &cobra.Command{
var policyData string
if bypass, _ := cmd.Flags().GetBool(bypassFlag); bypass {
if !confirmAction(cmd, "DO NOT run this command if an instance of headscale is running, are you sure headscale is not running?") {
return errAborted
}
d, err := bypassDatabase()
d, err := openBypassDB(cmd)
if err != nil {
return err
}
@ -83,19 +90,23 @@ var getPolicy = &cobra.Command{
policyData = pol.Data
} else {
ctx, client, conn, cancel, err := newHeadscaleCLIWithConfig()
if err != nil {
return fmt.Errorf("connecting to headscale: %w", err)
}
defer cancel()
defer conn.Close()
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)
}
response, err := client.GetPolicy(ctx, &v1.GetPolicyRequest{})
if err != nil {
return fmt.Errorf("loading ACL policy: %w", err)
}
if resp.StatusCode() != http.StatusOK {
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
}
policyData = response.GetPolicy()
policyData = resp.JSON200.Policy
return nil
})
if err != nil {
return err
}
}
// This does not pass output format as we don't support yaml, json or
@ -122,17 +133,13 @@ var setPolicy = &cobra.Command{
}
if bypass, _ := cmd.Flags().GetBool(bypassFlag); bypass {
if !confirmAction(cmd, "DO NOT run this command if an instance of headscale is running, are you sure headscale is not running?") {
return errAborted
}
d, err := bypassDatabase()
d, err := openBypassDB(cmd)
if err != nil {
return err
}
defer d.Close()
users, err := d.ListUsers()
users, err := d.ListUsers(nil)
if err != nil {
return fmt.Errorf("loading users for policy validation: %w", err)
}
@ -147,18 +154,24 @@ var setPolicy = &cobra.Command{
return fmt.Errorf("setting ACL policy: %w", err)
}
} else {
request := &v1.SetPolicyRequest{Policy: string(policyBytes)}
policyStr := string(policyBytes)
ctx, client, conn, cancel, err := newHeadscaleCLIWithConfig()
if err != nil {
return fmt.Errorf("connecting to headscale: %w", err)
}
defer cancel()
defer conn.Close()
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)
}
_, err = client.SetPolicy(ctx, request)
if resp.StatusCode() != http.StatusOK {
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
}
return nil
})
if err != nil {
return fmt.Errorf("setting ACL policy: %w", err)
return err
}
}
@ -173,8 +186,8 @@ var checkPolicy = &cobra.Command{
Short: "Check the Policy file for errors",
Long: `
Check validates the policy against the server's live users and nodes,
running any "tests" or "sshTests" block. By default the command is a
thin frontend for a gRPC call to a running headscale; pass --` + bypassFlag + ` to
running any "tests" or "sshTests" block. By default the command calls a
running headscale over its API; pass --` + bypassFlag + ` to
open the database directly when headscale is not running.`,
RunE: func(cmd *cobra.Command, args []string) error {
policyPath, _ := cmd.Flags().GetString("file")
@ -185,17 +198,13 @@ var checkPolicy = &cobra.Command{
}
if bypass, _ := cmd.Flags().GetBool(bypassFlag); bypass {
if !confirmAction(cmd, "DO NOT run this command if an instance of headscale is running, are you sure headscale is not running?") {
return errAborted
}
d, err := bypassDatabase()
d, err := openBypassDB(cmd)
if err != nil {
return err
}
defer d.Close()
users, err := d.ListUsers()
users, err := d.ListUsers(nil)
if err != nil {
return fmt.Errorf("loading users: %w", err)
}
@ -224,14 +233,22 @@ var checkPolicy = &cobra.Command{
return nil
}
ctx, client, conn, cancel, err := newHeadscaleCLIWithConfig()
if err != nil {
return fmt.Errorf("connecting to headscale: %w", err)
}
defer cancel()
defer conn.Close()
policyStr := string(policyBytes)
_, err = client.CheckPolicy(ctx, &v1.CheckPolicyRequest{Policy: 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
}

View file

@ -3,12 +3,12 @@ package cli
import (
"context"
"fmt"
"net/http"
"strconv"
"strings"
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
clientv1 "github.com/juanfont/headscale/gen/client/v1"
"github.com/juanfont/headscale/hscontrol/util"
"github.com/pterm/pterm"
"github.com/spf13/cobra"
)
@ -45,15 +45,45 @@ var listPreAuthKeys = &cobra.Command{
Use: cmdList,
Short: "List all preauthkeys",
Aliases: []string{"ls", cmdShow},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
response, err := client.ListPreAuthKeys(ctx, &v1.ListPreAuthKeysRequest{})
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, response.GetPreAuthKeys(), func() error {
tableData := make(pterm.TableData, 1, 1+len(response.GetPreAuthKeys()))
tableData[0] = []string{
if resp.StatusCode() != http.StatusOK {
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
}
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.Id != "":
owner = key.User.Name
}
rows = append(rows, []string{
key.Id,
key.Key,
strconv.FormatBool(key.Reusable),
strconv.FormatBool(key.Ephemeral),
strconv.FormatBool(key.Used),
expiration,
key.CreatedAt.Format(HeadscaleDateTimeFormat),
owner,
})
}
return renderTable([]string{
"ID",
"Key/Prefix",
"Reusable",
@ -62,36 +92,7 @@ var listPreAuthKeys = &cobra.Command{
colExpiration,
colCreated,
"Owner",
}
for _, key := range response.GetPreAuthKeys() {
expiration := "-"
if key.GetExpiration() != nil {
expiration = ColourTime(key.GetExpiration().AsTime())
}
var owner string
if len(key.GetAclTags()) > 0 {
owner = strings.Join(key.GetAclTags(), "\n")
} else if key.GetUser() != nil {
owner = key.GetUser().GetName()
} else {
owner = "-"
}
tableData = append(tableData, []string{
strconv.FormatUint(key.GetId(), util.Base10),
key.GetKey(),
strconv.FormatBool(key.GetReusable()),
strconv.FormatBool(key.GetEphemeral()),
strconv.FormatBool(key.GetUsed()),
expiration,
key.GetCreatedAt().AsTime().Format(HeadscaleDateTimeFormat),
owner,
})
}
return pterm.DefaultTable.WithHasHeader().WithData(tableData).Render()
}, rows)
})
}),
}
@ -100,55 +101,76 @@ var createPreAuthKeyCmd = &cobra.Command{
Use: "create",
Short: "Creates a new preauthkey",
Aliases: []string{"c", cmdNew},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, 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
}
request := &v1.CreatePreAuthKeyRequest{
User: user,
Reusable: reusable,
Ephemeral: ephemeral,
AclTags: tags,
Expiration: expiration,
userStr := strconv.FormatUint(user, util.Base10)
request := clientv1.CreatePreAuthKeyJSONRequestBody{
User: &userStr,
Reusable: &reusable,
Ephemeral: &ephemeral,
AclTags: &tags,
Expiration: &expiryTime,
}
response, err := client.CreatePreAuthKey(ctx, request)
resp, err := client.CreatePreAuthKeyWithResponse(ctx, request)
if err != nil {
return fmt.Errorf("creating preauthkey: %w", err)
}
return printOutput(cmd, response.GetPreAuthKey(), response.GetPreAuthKey().GetKey())
if resp.StatusCode() != http.StatusOK {
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
}
preAuthKey := resp.JSON200.PreAuthKey
return printOutput(cmd, preAuthKey, preAuthKey.Key)
}),
}
// preAuthKeyID reads the required --id flag for preauthkey commands.
func preAuthKeyID(cmd *cobra.Command) (uint64, error) {
id, _ := cmd.Flags().GetUint64("id")
if id == 0 {
return 0, fmt.Errorf("missing --id parameter: %w", errMissingParameter)
}
return id, nil
}
var expirePreAuthKeyCmd = &cobra.Command{
Use: cmdExpire,
Short: "Expire a preauthkey",
Aliases: []string{"revoke", aliasExp, "e"},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
id, _ := cmd.Flags().GetUint64("id")
if id == 0 {
return fmt.Errorf("missing --id parameter: %w", errMissingParameter)
RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error {
id, err := preAuthKeyID(cmd)
if err != nil {
return err
}
request := &v1.ExpirePreAuthKeyRequest{
Id: id,
}
idStr := strconv.FormatUint(id, util.Base10)
response, err := client.ExpirePreAuthKey(ctx, request)
resp, err := client.ExpirePreAuthKeyWithResponse(ctx, clientv1.ExpirePreAuthKeyJSONRequestBody{
Id: &idStr,
})
if err != nil {
return fmt.Errorf("expiring preauthkey: %w", err)
}
return printOutput(cmd, response, "Key expired")
if resp.StatusCode() != http.StatusOK {
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
}
return printOutput(cmd, resp.JSON200, "Key expired")
}),
}
@ -156,22 +178,25 @@ var deletePreAuthKeyCmd = &cobra.Command{
Use: cmdDelete,
Short: "Delete a preauthkey",
Aliases: []string{aliasDel, "rm", "d"},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
id, _ := cmd.Flags().GetUint64("id")
if id == 0 {
return fmt.Errorf("missing --id parameter: %w", errMissingParameter)
RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error {
id, err := preAuthKeyID(cmd)
if err != nil {
return err
}
request := &v1.DeletePreAuthKeyRequest{
Id: id,
}
idStr := strconv.FormatUint(id, util.Base10)
response, err := client.DeletePreAuthKey(ctx, request)
resp, err := client.DeletePreAuthKeyWithResponse(ctx, &clientv1.DeletePreAuthKeyParams{
Id: &idStr,
})
if err != nil {
return fmt.Errorf("deleting preauthkey: %w", err)
}
return printOutput(cmd, response, "Key deleted")
if resp.StatusCode() != http.StatusOK {
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
}
return printOutput(cmd, resp.JSON200, "Key deleted")
}),
}

View file

@ -3,6 +3,7 @@ package cli
import (
"os"
"runtime"
"slices"
"strings"
"github.com/juanfont/headscale/hscontrol/types"
@ -95,13 +96,9 @@ func initConfig() {
var prereleases = []string{"alpha", "beta", "rc", "dev"}
func isPreReleaseVersion(version string) bool {
for _, unstable := range prereleases {
if strings.Contains(version, unstable) {
return true
}
}
return false
return slices.ContainsFunc(prereleases, func(unstable string) bool {
return strings.Contains(version, unstable)
})
}
// filterPreReleasesIfStable returns a function that filters out
@ -120,13 +117,7 @@ func filterPreReleasesIfStable(versionFunc func() string) func(string) bool {
}
// If we are on a stable release, filter out pre-releases.
for _, ignore := range prereleases {
if strings.Contains(tag, ignore) {
return true
}
}
return false
return isPreReleaseVersion(tag)
}
}

View file

@ -213,7 +213,8 @@ func TestFilterPreReleasesIfStable(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
result := filterPreReleasesIfStable(func() string { return tt.currentVersion })(tt.tag)
if result != tt.expectedFilter {
t.Errorf("%s: got %v, want %v\nDescription: %s\nCurrent version: %s, Tag: %s",
t.Errorf(
"%s: got %v, want %v\nDescription: %s\nCurrent version: %s, Tag: %s",
tt.name,
result,
tt.expectedFilter,
@ -293,7 +294,8 @@ func TestIsPreReleaseVersion(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
result := isPreReleaseVersion(tt.version)
if result != tt.expected {
t.Errorf("%s: got %v, want %v\nDescription: %s\nVersion: %s",
t.Errorf(
"%s: got %v, want %v\nDescription: %s\nVersion: %s",
tt.name,
result,
tt.expected,

View file

@ -4,13 +4,13 @@ import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
v1 "github.com/juanfont/headscale/gen/go/headscale/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/pterm/pterm"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
)
@ -37,13 +37,50 @@ func usernameAndIDFromFlag(cmd *cobra.Command) (uint64, string, error) {
// Normalise unset/negative identifiers to 0 so the uint64
// conversion does not produce a bogus large value.
if identifier < 0 {
identifier = 0
}
identifier = max(identifier, 0)
return uint64(identifier), username, nil //nolint:gosec // identifier is clamped to >= 0 above
}
// resolveSingleUser resolves exactly one user from the --name/--id flags,
// returning the raw flag id and the matched user.
func resolveSingleUser(
ctx context.Context,
client *clientv1.ClientWithResponses,
cmd *cobra.Command,
) (uint64, *clientv1.User, error) {
id, username, err := usernameAndIDFromFlag(cmd)
if err != nil {
return 0, nil, err
}
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 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, &users[0], nil
}
func init() {
rootCmd.AddCommand(userCmd)
userCmd.AddCommand(createUserCmd)
@ -78,19 +115,19 @@ var createUserCmd = &cobra.Command{
return nil
},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
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 gRPC client")
log.Trace().Interface(zf.Client, client).Msg("obtained API client")
request := &v1.CreateUserRequest{Name: userName}
request := clientv1.CreateUserJSONRequestBody{Name: &userName}
if displayName, _ := cmd.Flags().GetString("display-name"); displayName != "" {
request.DisplayName = displayName
request.DisplayName = &displayName
}
if email, _ := cmd.Flags().GetString("email"); email != "" {
request.Email = email
request.Email = &email
}
if pictureURL, _ := cmd.Flags().GetString("picture-url"); pictureURL != "" {
@ -98,17 +135,21 @@ var createUserCmd = &cobra.Command{
return fmt.Errorf("invalid picture URL: %w", err)
}
request.PictureUrl = pictureURL
request.PictureUrl = &pictureURL
}
log.Trace().Interface(zf.Request, request).Msg("sending CreateUser request")
response, err := client.CreateUser(ctx, request)
resp, err := client.CreateUserWithResponse(ctx, request)
if err != nil {
return fmt.Errorf("creating user: %w", err)
}
return printOutput(cmd, response.GetUser(), "User created")
if resp.StatusCode() != http.StatusOK {
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
}
return printOutput(cmd, resp.JSON200.User, "User created")
}),
}
@ -116,43 +157,29 @@ var destroyUserCmd = &cobra.Command{
Use: "destroy --identifier ID or --name NAME",
Short: "Destroys a user",
Aliases: []string{cmdDelete},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
id, username, err := usernameAndIDFromFlag(cmd)
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
}
request := &v1.ListUsersRequest{
Name: username,
Id: id,
}
users, err := client.ListUsers(ctx, request)
if err != nil {
return fmt.Errorf("listing users: %w", err)
}
if len(users.GetUsers()) != 1 {
return errMultipleUsersMatch
}
user := users.GetUsers()[0]
if !confirmAction(cmd, fmt.Sprintf(
"Do you want to remove the user %q (%d) and any associated preauthkeys?",
user.GetName(), user.GetId(),
"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")
}
deleteRequest := &v1.DeleteUserRequest{Id: user.GetId()}
response, err := client.DeleteUser(ctx, deleteRequest)
resp, err := client.DeleteUserWithResponse(ctx, user.Id)
if err != nil {
return fmt.Errorf("destroying user: %w", err)
}
return printOutput(cmd, response, "User destroyed")
if resp.StatusCode() != http.StatusOK {
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
}
return printOutput(cmd, resp.JSON200, "User destroyed")
}),
}
@ -160,8 +187,8 @@ var listUsersCmd = &cobra.Command{
Use: cmdList,
Short: "List all the users",
Aliases: []string{"ls", cmdShow},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
request := &v1.ListUsersRequest{}
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")
@ -170,36 +197,41 @@ var listUsersCmd = &cobra.Command{
// filter by one param at most
switch {
case id > 0:
request.Id = uint64(id)
idStr := strconv.FormatInt(id, util.Base10)
params.Id = &idStr
case username != "":
request.Name = username
params.Name = &username
case email != "":
request.Email = email
params.Email = &email
}
response, err := client.ListUsers(ctx, request)
resp, err := client.ListUsersWithResponse(ctx, params)
if err != nil {
return fmt.Errorf("listing users: %w", err)
}
return printListOutput(cmd, response.GetUsers(), func() error {
tableData := make(pterm.TableData, 1, 1+len(response.GetUsers()))
if resp.StatusCode() != http.StatusOK {
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
}
tableData[0] = []string{"ID", "Name", "Username", "Email", colCreated}
for _, user := range response.GetUsers() {
tableData = append(
tableData,
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.GetId(), util.Base10),
user.GetDisplayName(),
user.GetName(),
user.GetEmail(),
user.GetCreatedAt().AsTime().Format(HeadscaleDateTimeFormat),
user.Id,
user.DisplayName,
user.Name,
user.Email,
user.CreatedAt.Format(HeadscaleDateTimeFormat),
},
)
}
return pterm.DefaultTable.WithHasHeader().WithData(tableData).Render()
return renderTable([]string{"ID", "Name", "Username", "Email", colCreated}, rows)
})
}),
}
@ -208,38 +240,23 @@ var renameUserCmd = &cobra.Command{
Use: "rename",
Short: "Renames a user",
Aliases: []string{"mv"},
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
id, username, err := usernameAndIDFromFlag(cmd)
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
}
listReq := &v1.ListUsersRequest{
Name: username,
Id: id,
}
users, err := client.ListUsers(ctx, listReq)
if err != nil {
return fmt.Errorf("listing users: %w", err)
}
if len(users.GetUsers()) != 1 {
return errMultipleUsersMatch
}
newName, _ := cmd.Flags().GetString("new-name")
renameReq := &v1.RenameUserRequest{
OldId: id,
NewName: newName,
}
response, err := client.RenameUser(ctx, renameReq)
resp, err := client.RenameUserWithResponse(ctx, strconv.FormatUint(id, util.Base10), newName)
if err != nil {
return fmt.Errorf("renaming user: %w", err)
}
return printOutput(cmd, response.GetUser(), "User renamed")
if resp.StatusCode() != http.StatusOK {
return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault)
}
return printOutput(cmd, resp.JSON200.User, "User renamed")
}),
}

View file

@ -6,21 +6,23 @@ import (
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"os"
"slices"
"strings"
"time"
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
"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"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/protobuf/types/known/timestamppb"
"gopkg.in/yaml.v3"
)
@ -36,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)
@ -67,28 +103,47 @@ func newHeadscaleServerWithConfig() (*hscontrol.Headscale, error) {
return app, nil
}
// grpcRunE wraps a cobra [cobra.Command.RunE] func, injecting a ready
// gRPC client and context. Connection lifecycle is managed by the
// wrapper — callers never see the underlying conn or cancel func.
func grpcRunE(
fn func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error,
// 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, conn, cancel, err := newHeadscaleCLIWithConfig()
ctx, client, cancel, err := newHeadscaleCLIWithConfig()
if err != nil {
return fmt.Errorf("connecting to headscale: %w", err)
}
defer cancel()
defer conn.Close()
return fn(ctx, client, cmd, args)
}
}
func newHeadscaleCLIWithConfig() (context.Context, v1.HeadscaleServiceClient, *grpc.ClientConn, context.CancelFunc, error) {
// 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, nil, fmt.Errorf("loading configuration: %w", err)
return nil, nil, nil, fmt.Errorf("loading configuration: %w", err)
}
log.Debug().
@ -97,10 +152,6 @@ func newHeadscaleCLIWithConfig() (context.Context, v1.HeadscaleServiceClient, *g
ctx, cancel := context.WithTimeout(context.Background(), cfg.CLI.Timeout)
grpcOptions := []grpc.DialOption{
grpc.WithBlock(), //nolint:staticcheck // SA1019: deprecated but supported in 1.x
}
address := cfg.CLI.Address
// If the address is not set, we assume that we are on the server hosting [hscontrol].
@ -109,77 +160,123 @@ func newHeadscaleCLIWithConfig() (context.Context, v1.HeadscaleServiceClient, *g
Str("socket", cfg.UnixSocket).
Msgf("HEADSCALE_CLI_ADDRESS environment is not set, connecting to unix socket.")
address = cfg.UnixSocket
// Try to give the user better feedback if we cannot write to the headscale
// socket. Note: [os.OpenFile] on a Unix domain socket returns ENXIO on
// Linux which is expected — only permission errors are actionable here.
// The actual gRPC connection uses [net.Dial] which handles sockets properly.
socket, err := os.OpenFile(cfg.UnixSocket, os.O_WRONLY, SocketWritePermissions) //nolint
client, err := newSocketClient(cfg.UnixSocket)
if err != nil {
if os.IsPermission(err) {
cancel()
return nil, nil, nil, nil, fmt.Errorf(
"unable to read/write to headscale socket %q, do you have the correct permissions? %w",
cfg.UnixSocket,
err,
)
}
} else {
socket.Close()
}
grpcOptions = append(
grpcOptions,
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithContextDialer(util.GrpcSocketDialer),
)
} else {
// If we are not connecting to a local server, require an API key for authentication
apiKey := cfg.CLI.APIKey
if apiKey == "" {
cancel()
return nil, nil, nil, nil, errAPIKeyNotSet
return nil, nil, nil, err
}
grpcOptions = append(grpcOptions,
grpc.WithPerRPCCredentials(tokenAuth{
token: apiKey,
}),
)
log.Trace().Caller().Str(zf.Address, cfg.UnixSocket).Msg("connecting via unix socket")
if cfg.CLI.Insecure {
tlsConfig := &tls.Config{
// turn of gosec as we are intentionally setting
// insecure.
//nolint:gosec
InsecureSkipVerify: true,
}
grpcOptions = append(grpcOptions,
grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig)),
)
} else {
grpcOptions = append(grpcOptions,
grpc.WithTransportCredentials(credentials.NewClientTLSFromCert(nil, "")),
)
}
return ctx, client, cancel, nil
}
log.Trace().Caller().Str(zf.Address, address).Msg("connecting via gRPC")
// Remote connections require an API key for authentication.
apiKey := cfg.CLI.APIKey
if apiKey == "" {
cancel()
conn, err := grpc.DialContext(ctx, address, grpcOptions...) //nolint:staticcheck // SA1019: deprecated but supported in 1.x
return nil, nil, nil, errAPIKeyNotSet
}
client, err := newRemoteClient(address, apiKey, cfg.CLI.Insecure)
if err != nil {
cancel()
return nil, nil, nil, nil, fmt.Errorf("connecting to %s: %w", address, err)
return nil, nil, nil, err
}
client := v1.NewHeadscaleServiceClient(conn)
log.Trace().Caller().Str(zf.Address, address).Msg("connecting via HTTPS")
return ctx, client, conn, cancel, nil
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
@ -228,16 +325,16 @@ func printOutput(cmd *cobra.Command, result any, override string) error {
}
// expirationFromFlag parses the --expiration flag as a Prometheus-style
// duration (e.g. "90d", "1h") and returns an absolute timestamp.
func expirationFromFlag(cmd *cobra.Command) (*timestamppb.Timestamp, error) {
// 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 nil, fmt.Errorf("parsing duration: %w", err)
return time.Time{}, fmt.Errorf("parsing duration: %w", err)
}
return timestamppb.New(time.Now().UTC().Add(time.Duration(duration))), nil
return time.Now().UTC().Add(time.Duration(duration)), nil
}
// confirmAction returns true when the user confirms a prompt, or when
@ -251,9 +348,19 @@ func confirmAction(cmd *cobra.Command, prompt string) bool {
return util.YesNo(prompt)
}
// renderTable prints a human-readable pterm table with the given header row
// and data rows, using the shared header styling.
func renderTable(header []string, rows [][]string) error {
tableData := make(pterm.TableData, 0, 1+len(rows))
tableData = append(tableData, header)
tableData = append(tableData, rows...)
return pterm.DefaultTable.WithHasHeader().WithData(tableData).Render()
}
// printListOutput checks the --output flag: when a machine-readable format is
// requested it serialises data as JSON/YAML; otherwise it calls renderTable
// to produce the human-readable pterm table.
// requested it serialises data as JSON/YAML; otherwise it calls the render
// callback to produce the human-readable pterm table.
func printListOutput(
cmd *cobra.Command,
data any,
@ -275,50 +382,19 @@ func printError(err error, outputFormat string) {
Error string `json:"error"`
}
e := errOutput{Error: err.Error()}
var formatted []byte
switch outputFormat {
case outputFormatJSON:
formatted, _ = json.MarshalIndent(e, "", "\t") //nolint:errchkjson // errOutput contains only a string field
case outputFormatJSONLine:
formatted, _ = json.Marshal(e) //nolint:errchkjson // errOutput contains only a string field
case outputFormatYAML:
formatted, _ = yaml.Marshal(e)
default:
if outputFormat == "" {
fmt.Fprintf(os.Stderr, "Error: %s\n", err)
return
}
fmt.Fprintf(os.Stderr, "%s\n", formatted)
// formatOutput cannot fail here: errOutput is a single string field.
out, _ := formatOutput(errOutput{Error: err.Error()}, "", outputFormat)
fmt.Fprintf(os.Stderr, "%s\n", out)
}
func hasMachineOutputFlag() bool {
for _, arg := range os.Args {
if arg == outputFormatJSON || arg == outputFormatJSONLine || arg == outputFormatYAML {
return true
}
}
return false
}
type tokenAuth struct {
token string
}
// Return value is mapped to request headers.
func (t tokenAuth) GetRequestMetadata(
ctx context.Context,
in ...string,
) (map[string]string, error) {
return map[string]string{
"authorization": "Bearer " + t.token,
}, nil
}
func (tokenAuth) RequireTransportSecurity() bool {
return true
return slices.ContainsFunc(os.Args, func(arg string) bool {
return arg == outputFormatJSON || arg == outputFormatJSONLine || arg == outputFormatYAML
})
}

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")
}

View file

@ -0,0 +1,35 @@
package cli
import "testing"
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",
},
}
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)
}
})
}
}

View file

@ -11,21 +11,7 @@ import (
)
func main() {
var colors bool
switch l := termcolor.SupportLevel(os.Stderr); l {
case termcolor.Level16M:
colors = true
case termcolor.Level256:
colors = true
case termcolor.LevelBasic:
colors = true
case termcolor.LevelNone:
colors = false
default:
// no color, return text as is.
colors = false
}
colors := termcolor.SupportLevel(os.Stderr) != termcolor.LevelNone
// Adhere to no-color.org manifesto of allowing users to
// turn off color in cli/services

View file

@ -71,26 +71,8 @@ func killTestContainers(ctx context.Context) error {
removed := 0
for _, cont := range containers {
shouldRemove := false
for _, name := range cont.Names {
if strings.Contains(name, "headscale-test-suite") ||
strings.Contains(name, "hs-") ||
strings.Contains(name, "ts-") ||
strings.Contains(name, "derp-") {
shouldRemove = true
break
}
}
if shouldRemove {
// First kill the container if it's running
if cont.State == "running" {
_ = cli.ContainerKill(ctx, cont.ID, "KILL")
}
// Then remove the container with retry logic
if removeContainerWithRetry(ctx, cli, cont.ID) {
if isTestContainerName(cont.Names) {
if killAndRemove(ctx, cli, cont) {
removed++
}
}
@ -129,13 +111,7 @@ func killTestContainersByRunID(ctx context.Context, runID string) error {
removed := 0
for _, cont := range containers {
// Kill the container if it's running
if cont.State == "running" {
_ = cli.ContainerKill(ctx, cont.ID, "KILL")
}
// Remove the container with retry logic
if removeContainerWithRetry(ctx, cli, cont.ID) {
if killAndRemove(ctx, cli, cont) {
removed++
}
}
@ -173,20 +149,8 @@ func cleanupStaleTestContainers(ctx context.Context) error {
for _, cont := range containers {
// Only remove containers that look like test containers
shouldRemove := false
for _, name := range cont.Names {
if strings.Contains(name, "headscale-test-suite") ||
strings.Contains(name, "hs-") ||
strings.Contains(name, "ts-") ||
strings.Contains(name, "derp-") {
shouldRemove = true
break
}
}
if shouldRemove {
if removeContainerWithRetry(ctx, cli, cont.ID) {
if isTestContainerName(cont.Names) {
if killAndRemove(ctx, cli, cont) {
removed++
}
}
@ -223,6 +187,46 @@ func removeContainerWithRetry(ctx context.Context, cli *client.Client, container
return err == nil
}
// testContainerNamePrefixes are the name prefixes used by containers that the
// integration test harness creates (headscale, tailscale, DERP, and k3s).
var testContainerNamePrefixes = []string{"hs-", "ts-", "derp-", "k3s-"}
// matchesTestContainerPrefix reports whether name belongs to an integration
// test container, ignoring any leading "/" that Docker prefixes names with.
func matchesTestContainerPrefix(name string) bool {
name = strings.TrimPrefix(name, "/")
for _, prefix := range testContainerNamePrefixes {
if strings.HasPrefix(name, prefix) {
return true
}
}
return false
}
// isTestContainerName reports whether any of the container names belong to an
// integration test container.
func isTestContainerName(names []string) bool {
for _, name := range names {
if strings.Contains(name, "headscale-test-suite") ||
matchesTestContainerPrefix(name) {
return true
}
}
return false
}
// killAndRemove kills a running container then removes it with retry logic,
// reporting whether the removal succeeded.
func killAndRemove(ctx context.Context, cli *client.Client, cont container.Summary) bool {
if cont.State == "running" {
_ = cli.ContainerKill(ctx, cont.ID, "KILL")
}
return removeContainerWithRetry(ctx, cli, cont.ID)
}
// pruneDockerNetworks removes unused Docker networks.
func pruneDockerNetworks(ctx context.Context) error {
cli, err := createDockerClient(ctx)

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{
@ -769,17 +769,6 @@ func extractContainerArtifacts(ctx context.Context, cli *client.Client, containe
return fmt.Errorf("extracting logs: %w", err)
}
// Extract tar files for headscale containers only
if strings.HasPrefix(containerName, "hs-") {
err := extractContainerFiles(ctx, cli, containerID, containerName, logsDir, verbose)
if err != nil {
if verbose {
log.Printf("Warning: failed to extract files from %s: %v", containerName, err)
}
// Don't fail the whole extraction if files are missing
}
}
return nil
}
@ -827,12 +816,3 @@ func extractContainerLogs(ctx context.Context, cli *client.Client, containerID,
return nil
}
// extractContainerFiles extracts database file and directories from headscale containers.
// Note: The actual file extraction is now handled by the integration tests themselves
// via [SaveProfile], [SaveMapResponses], and [SaveDatabase] functions in hsic.go.
func extractContainerFiles(ctx context.Context, cli *client.Client, containerID, containerName, logsDir string, verbose bool) error {
// Files are now extracted directly by the integration tests
// This function is kept for potential future use or other file types
return nil
}

View file

@ -5,10 +5,12 @@ import (
"errors"
"fmt"
"log"
"os"
"os/exec"
"strings"
"github.com/juanfont/headscale/integration/dockertestutil"
"github.com/juanfont/headscale/integration/k3sic"
)
const (
@ -20,6 +22,7 @@ const (
nameDockerContext = "Docker Context"
nameDockerSocket = "Docker Socket"
nameGolangImage = "Golang Image"
nameK3sImage = "K3s Image"
nameGoInstall = "Go Installation"
)
@ -33,6 +36,21 @@ type DoctorResult struct {
Suggestions []string
}
// pass builds a passing DoctorResult.
func pass(name, message string) DoctorResult {
return DoctorResult{Name: name, Status: statusPass, Message: message}
}
// warn builds a warning DoctorResult with optional suggestions.
func warn(name, message string, suggestions ...string) DoctorResult {
return DoctorResult{Name: name, Status: statusWarn, Message: message, Suggestions: suggestions}
}
// fail builds a failing DoctorResult with optional suggestions.
func fail(name, message string, suggestions ...string) DoctorResult {
return DoctorResult{Name: name, Status: statusFail, Message: message, Suggestions: suggestions}
}
// runDoctorCheck performs comprehensive pre-flight checks for integration testing.
func runDoctorCheck(ctx context.Context) error {
results := []DoctorResult{}
@ -50,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
@ -80,129 +99,91 @@ func runDoctorCheck(ctx context.Context) error {
func checkDockerBinary() DoctorResult {
_, err := exec.LookPath("docker")
if err != nil {
return DoctorResult{
Name: "Docker Binary",
Status: statusFail,
Message: "Docker binary not found in PATH",
Suggestions: []string{
"Install Docker: https://docs.docker.com/get-docker/",
"For macOS: consider using colima or Docker Desktop",
"Ensure docker is in your PATH",
},
}
return fail(
"Docker Binary",
"Docker binary not found in PATH",
"Install Docker: https://docs.docker.com/get-docker/",
"For macOS: consider using colima or Docker Desktop",
"Ensure docker is in your PATH",
)
}
return DoctorResult{
Name: "Docker Binary",
Status: statusPass,
Message: "Docker binary found",
}
return pass("Docker Binary", "Docker binary found")
}
// checkDockerDaemon verifies Docker daemon is running and accessible.
func checkDockerDaemon(ctx context.Context) DoctorResult {
cli, err := createDockerClient(ctx)
if err != nil {
return DoctorResult{
Name: nameDockerDaemon,
Status: statusFail,
Message: fmt.Sprintf("Cannot create Docker client: %v", err),
Suggestions: []string{
"Start Docker daemon/service",
"Check Docker Desktop is running (if using Docker Desktop)",
"For colima: run 'colima start'",
"Verify DOCKER_HOST environment variable if set",
},
}
return fail(
nameDockerDaemon,
fmt.Sprintf("Cannot create Docker client: %v", err),
"Start Docker daemon/service",
"Check Docker Desktop is running (if using Docker Desktop)",
"For colima: run 'colima start'",
"Verify DOCKER_HOST environment variable if set",
)
}
defer cli.Close()
_, err = cli.Ping(ctx)
if err != nil {
return DoctorResult{
Name: nameDockerDaemon,
Status: statusFail,
Message: fmt.Sprintf("Cannot ping Docker daemon: %v", err),
Suggestions: []string{
"Ensure Docker daemon is running",
"Check Docker socket permissions",
"Try: docker info",
},
}
return fail(
nameDockerDaemon,
fmt.Sprintf("Cannot ping Docker daemon: %v", err),
"Ensure Docker daemon is running",
"Check Docker socket permissions",
"Try: docker info",
)
}
return DoctorResult{
Name: nameDockerDaemon,
Status: statusPass,
Message: "Docker daemon is running and accessible",
}
return pass(nameDockerDaemon, "Docker daemon is running and accessible")
}
// checkDockerContext verifies Docker context configuration.
func checkDockerContext(ctx context.Context) DoctorResult {
contextInfo, err := getCurrentDockerContext(ctx)
if err != nil {
return DoctorResult{
Name: nameDockerContext,
Status: statusWarn,
Message: "Could not detect Docker context, using default settings",
Suggestions: []string{
"Check: docker context ls",
"Consider setting up a specific context if needed",
},
}
return warn(
nameDockerContext,
"Could not detect Docker context, using default settings",
"Check: docker context ls",
"Consider setting up a specific context if needed",
)
}
if contextInfo == nil {
return DoctorResult{
Name: nameDockerContext,
Status: statusPass,
Message: "Using default Docker context",
}
return pass(nameDockerContext, "Using default Docker context")
}
return DoctorResult{
Name: nameDockerContext,
Status: statusPass,
Message: "Using Docker context: " + contextInfo.Name,
}
return pass(nameDockerContext, "Using Docker context: "+contextInfo.Name)
}
// checkDockerSocket verifies Docker socket accessibility.
func checkDockerSocket(ctx context.Context) DoctorResult {
cli, err := createDockerClient(ctx)
if err != nil {
return DoctorResult{
Name: nameDockerSocket,
Status: statusFail,
Message: fmt.Sprintf("Cannot access Docker socket: %v", err),
Suggestions: []string{
"Check Docker socket permissions",
"Add user to docker group: sudo usermod -aG docker $USER",
"For colima: ensure socket is accessible",
},
}
return fail(
nameDockerSocket,
fmt.Sprintf("Cannot access Docker socket: %v", err),
"Check Docker socket permissions",
"Add user to docker group: sudo usermod -aG docker $USER",
"For colima: ensure socket is accessible",
)
}
defer cli.Close()
info, err := cli.Info(ctx)
if err != nil {
return DoctorResult{
Name: nameDockerSocket,
Status: statusFail,
Message: fmt.Sprintf("Cannot get Docker info: %v", err),
Suggestions: []string{
"Check Docker daemon status",
"Verify socket permissions",
},
}
return fail(
nameDockerSocket,
fmt.Sprintf("Cannot get Docker info: %v", err),
"Check Docker daemon status",
"Verify socket permissions",
)
}
return DoctorResult{
Name: nameDockerSocket,
Status: statusPass,
Message: fmt.Sprintf("Docker socket accessible (Server: %s)", info.ServerVersion),
}
return pass(nameDockerSocket, fmt.Sprintf("Docker socket accessible (Server: %s)", info.ServerVersion))
}
// checkDockerHubCredentials warns when pulls would be anonymous and
@ -210,34 +191,23 @@ func checkDockerSocket(ctx context.Context) DoctorResult {
func checkDockerHubCredentials() DoctorResult {
_, _, source := dockertestutil.Credentials()
if source == dockertestutil.CredentialSourceAnonymous {
return DoctorResult{
Name: "Docker Hub Credentials",
Status: "WARN",
Message: "No Docker Hub credentials found — pulls will be rate-limited (100/6h per IP)",
Suggestions: []string{
"Run: docker login",
"Or export DOCKERHUB_USERNAME and DOCKERHUB_TOKEN",
"In CI: ensure the docker/login-action step is configured with secrets",
},
}
return warn(
"Docker Hub Credentials",
"No Docker Hub credentials found — pulls will be rate-limited (100/6h per IP)",
"Run: docker login",
"Or export DOCKERHUB_USERNAME and DOCKERHUB_TOKEN",
"In CI: ensure the docker/login-action step is configured with secrets",
)
}
return DoctorResult{
Name: "Docker Hub Credentials",
Status: "PASS",
Message: fmt.Sprintf("Credentials available (source: %s)", source),
}
return pass("Docker Hub Credentials", fmt.Sprintf("Credentials available (source: %s)", source))
}
// checkGolangImage verifies the golang Docker image is available locally or can be pulled.
func checkGolangImage(ctx context.Context) DoctorResult {
cli, err := createDockerClient(ctx)
if err != nil {
return DoctorResult{
Name: nameGolangImage,
Status: statusFail,
Message: "Cannot create Docker client for image check",
}
return fail(nameGolangImage, "Cannot create Docker client for image check")
}
defer cli.Close()
@ -247,81 +217,94 @@ func checkGolangImage(ctx context.Context) DoctorResult {
// First check if image is available locally
available, err := checkImageAvailableLocally(ctx, cli, imageName)
if err != nil {
return DoctorResult{
Name: nameGolangImage,
Status: statusFail,
Message: fmt.Sprintf("Cannot check golang image %s: %v", imageName, err),
Suggestions: []string{
"Check Docker daemon status",
"Try: docker images | grep golang",
},
}
return fail(
nameGolangImage,
fmt.Sprintf("Cannot check golang image %s: %v", imageName, err),
"Check Docker daemon status",
"Try: docker images | grep golang",
)
}
if available {
return DoctorResult{
Name: nameGolangImage,
Status: statusPass,
Message: fmt.Sprintf("Golang image %s is available locally", imageName),
}
return pass(nameGolangImage, fmt.Sprintf("Golang image %s is available locally", imageName))
}
// Image not available locally, try to pull it
err = ensureImageAvailable(ctx, cli, imageName, false)
if err != nil {
return DoctorResult{
Name: nameGolangImage,
Status: statusFail,
Message: fmt.Sprintf("Golang image %s not available locally and cannot pull: %v", imageName, err),
Suggestions: []string{
"Check internet connectivity",
"Verify Docker Hub access",
"Try: docker pull " + imageName,
"Or run tests offline if image was pulled previously",
},
}
return fail(
nameGolangImage,
fmt.Sprintf("Golang image %s not available locally and cannot pull: %v", imageName, err),
"Check internet connectivity",
"Verify Docker Hub access",
"Try: docker pull "+imageName,
"Or run tests offline if image was pulled previously",
)
}
return DoctorResult{
Name: nameGolangImage,
Status: statusPass,
Message: fmt.Sprintf("Golang image %s is now available", imageName),
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")
if err != nil {
return DoctorResult{
Name: nameGoInstall,
Status: statusFail,
Message: "Go binary not found in PATH",
Suggestions: []string{
"Install Go: https://golang.org/dl/",
"Ensure go is in your PATH",
},
}
return fail(
nameGoInstall,
"Go binary not found in PATH",
"Install Go: https://golang.org/dl/",
"Ensure go is in your PATH",
)
}
cmd := exec.CommandContext(ctx, "go", "version")
output, err := cmd.Output()
if err != nil {
return DoctorResult{
Name: nameGoInstall,
Status: statusFail,
Message: fmt.Sprintf("Cannot get Go version: %v", err),
}
return fail(nameGoInstall, fmt.Sprintf("Cannot get Go version: %v", err))
}
version := strings.TrimSpace(string(output))
return DoctorResult{
Name: nameGoInstall,
Status: statusPass,
Message: version,
}
return pass(nameGoInstall, version)
}
// checkGitRepository verifies we're in a git repository.
@ -330,26 +313,19 @@ func checkGitRepository(ctx context.Context) DoctorResult {
err := cmd.Run()
if err != nil {
return DoctorResult{
Name: "Git Repository",
Status: statusFail,
Message: "Not in a Git repository",
Suggestions: []string{
"Run from within the headscale git repository",
"Clone the repository: git clone https://github.com/juanfont/headscale.git",
},
}
return fail(
"Git Repository",
"Not in a Git repository",
"Run from within the headscale git repository",
"Clone the repository: git clone https://github.com/juanfont/headscale.git",
)
}
return DoctorResult{
Name: "Git Repository",
Status: statusPass,
Message: "Running in Git repository",
}
return pass("Git Repository", "Running in Git repository")
}
// checkRequiredFiles verifies required files exist.
func checkRequiredFiles(ctx context.Context) DoctorResult {
func checkRequiredFiles(_ context.Context) DoctorResult {
requiredFiles := []string{
"go.mod",
"integration/",
@ -359,32 +335,23 @@ func checkRequiredFiles(ctx context.Context) DoctorResult {
var missingFiles []string
for _, file := range requiredFiles {
cmd := exec.CommandContext(ctx, "test", "-e", file)
err := cmd.Run()
_, err := os.Stat(file)
if err != nil {
missingFiles = append(missingFiles, file)
}
}
if len(missingFiles) > 0 {
return DoctorResult{
Name: "Required Files",
Status: statusFail,
Message: "Missing required files: " + strings.Join(missingFiles, ", "),
Suggestions: []string{
"Ensure you're in the headscale project root directory",
"Check that integration/ directory exists",
"Verify this is a complete headscale repository",
},
}
return fail(
"Required Files",
"Missing required files: "+strings.Join(missingFiles, ", "),
"Ensure you're in the headscale project root directory",
"Check that integration/ directory exists",
"Verify this is a complete headscale repository",
)
}
return DoctorResult{
Name: "Required Files",
Status: statusPass,
Message: "All required files found",
}
return pass("Required Files", "All required files found")
}
// displayDoctorResults shows the results in a formatted way.

View file

@ -86,20 +86,17 @@ func main() {
}
func cleanAll(ctx context.Context) error {
err := killTestContainers(ctx)
if err != nil {
return err
for _, step := range []func(context.Context) error{
killTestContainers,
pruneDockerNetworks,
cleanOldImages,
cleanCacheVolume,
} {
err := step(ctx)
if err != nil {
return err
}
}
err = pruneDockerNetworks(ctx)
if err != nil {
return err
}
err = cleanOldImages(ctx)
if err != nil {
return err
}
return cleanCacheVolume(ctx)
return nil
}

View file

@ -6,6 +6,7 @@ import (
"log"
"os"
"path/filepath"
"strings"
"time"
"github.com/creachadair/command"
@ -66,64 +67,21 @@ func runIntegrationTest(env *command.Env) error {
// detectGoVersion reads the Go version from go.mod file.
func detectGoVersion() string {
goModPath := filepath.Join("..", "..", "go.mod")
if _, err := os.Stat("go.mod"); err == nil { //nolint:noinlineerr
goModPath = "go.mod"
} else if _, err := os.Stat("../../go.mod"); err == nil { //nolint:noinlineerr
goModPath = "../../go.mod"
}
content, err := os.ReadFile(goModPath)
content, err := os.ReadFile("go.mod")
if err != nil {
return "1.26.1"
content, err = os.ReadFile(filepath.Join("..", "..", "go.mod"))
if err != nil {
return "1.26.1"
}
}
lines := splitLines(string(content))
for _, line := range lines {
if len(line) > 3 && line[:3] == "go " {
version := line[3:]
if idx := indexOf(version, " "); idx != -1 {
version = version[:idx]
for line := range strings.Lines(string(content)) {
if rest, ok := strings.CutPrefix(line, "go "); ok {
if f := strings.Fields(rest); len(f) > 0 {
return f[0]
}
return version
}
}
return "1.26.1"
}
// splitLines splits a string into lines without using [strings.Split].
func splitLines(s string) []string {
var (
lines []string
current string
)
for _, char := range s {
if char == '\n' {
lines = append(lines, current)
current = ""
} else {
current += string(char)
}
}
if current != "" {
lines = append(lines, current)
}
return lines
}
// indexOf finds the first occurrence of substr in s.
func indexOf(s, substr string) int {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return i
}
}
return -1
}

View file

@ -1,12 +1,13 @@
package main
import (
"cmp"
"context"
"encoding/json"
"errors"
"fmt"
"log"
"sort"
"slices"
"strings"
"sync"
"time"
@ -374,24 +375,24 @@ func (sc *StatsCollector) GetSummary() []ContainerStatsSummary {
SampleCount: len(stats),
}
// Calculate CPU stats
cpuValues := make([]float64, len(stats))
memoryValues := make([]float64, len(stats))
extract := func(get func(StatsSample) float64) []float64 {
values := make([]float64, len(stats))
for i, sample := range stats {
values[i] = get(sample)
}
for i, sample := range stats {
cpuValues[i] = sample.CPUUsage
memoryValues[i] = sample.MemoryMB
return values
}
summary.CPU = calculateStatsSummary(cpuValues)
summary.Memory = calculateStatsSummary(memoryValues)
summary.CPU = calculateStatsSummary(extract(func(s StatsSample) float64 { return s.CPUUsage }))
summary.Memory = calculateStatsSummary(extract(func(s StatsSample) float64 { return s.MemoryMB }))
summaries = append(summaries, summary)
}
// Sort by container name for consistent output
sort.Slice(summaries, func(i, j int) bool {
return summaries[i].ContainerName < summaries[j].ContainerName
slices.SortFunc(summaries, func(a, b ContainerStatsSummary) int {
return cmp.Compare(a.ContainerName, b.ContainerName)
})
return summaries
@ -408,14 +409,8 @@ func calculateStatsSummary(values []float64) StatsSummary {
sum := 0.0
for _, value := range values {
if value < minVal {
minVal = value
}
if value > maxVal {
maxVal = value
}
minVal = min(minVal, value)
maxVal = max(maxVal, value)
sum += value
}

View file

@ -23,22 +23,6 @@ listen_addr: 127.0.0.1:8080
# Use an empty value to disable the metrics listener.
metrics_listen_addr: 127.0.0.1:9090
# Address to listen for gRPC.
# gRPC is used for controlling a headscale server
# remotely with the CLI
# Note: Remote access _only_ works if you have
# valid certificates.
#
# For production:
# grpc_listen_addr: 0.0.0.0:50443
grpc_listen_addr: 127.0.0.1:50443
# Allow the gRPC admin interface to run in INSECURE
# mode. This is not recommended as the traffic will
# be unencrypted. Only enable if you know what you
# are doing.
grpc_allow_insecure: false
# CIDR(s) of reverse proxies (e.g. 127.0.0.1/32) whose
# True-Client-IP, X-Real-IP and X-Forwarded-For headers should
# be honoured. Empty (default) ignores those headers; setting
@ -246,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
@ -261,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
@ -295,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: ""
@ -476,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.

View file

@ -152,15 +152,15 @@ See also <https://tailscale.com/docs/concepts/device-visibility>.
Headscale checks if the policy is valid during startup and refuses to start if it detects an error. The error message
indicates which part of the policy is invalid. Follow these steps to fix your policy:
- Dump the policy to a file: `headscale policy get --bypass-grpc-and-access-database-directly > policy.json`
- Dump the policy to a file: `headscale policy get --bypass-server-and-access-database-directly > policy.json`
- Edit and fixup `policy.json`. Use the command `headscale policy check --file policy.json` to validate the policy.
- Load the modified policy: `headscale policy set --bypass-grpc-and-access-database-directly --file policy.json`
- Load the modified policy: `headscale policy set --bypass-server-and-access-database-directly --file policy.json`
- Start Headscale as usual.
!!! warning "Full server configuration required"
The above commands to get/set the policy require a complete server configuration file including database settings. A
minimal config to [control Headscale via remote CLI](../ref/api.md#grpc) is not sufficient. You may use
minimal config to [control Headscale via remote CLI](../ref/api.md#remote-control) is not sufficient. You may use
`headscale -c /path/to/config.yaml` to specify the path to an alternative configuration file.
## How can I migrate back to the recommended IP prefixes?

View file

@ -1,10 +1,10 @@
# API
Headscale provides a [HTTP REST API](#rest-api) and a [gRPC interface](#grpc) which may be used to integrate a [web
interface](integration/web-ui.md), [remote control Headscale](#setup-remote-control) or provide a base for custom
Headscale provides a [HTTP REST API](#rest-api) which may be used to integrate a [web
interface](integration/web-ui.md), [remote control Headscale](#remote-control) or provide a base for custom
integration and tooling.
Both interfaces require a valid API key before use. To create an API key, log into your Headscale server and generate
The API requires a valid API key before use. To create an API key, log into your Headscale server and generate
one with the default expiration of 90 days:
```shell
@ -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,20 +54,18 @@ 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
```
## gRPC
## Remote control
The gRPC interface can be used to control a Headscale instance from a remote machine with the `headscale` binary.
The `headscale` binary can control a Headscale instance from a remote machine over the HTTP API.
### Prerequisite
- A workstation to run `headscale` (any supported platform, e.g. Linux).
- A Headscale server with gRPC enabled.
- Connections to the gRPC port (default: `50443`) are allowed.
- Remote access requires an encrypted connection via TLS.
- The Headscale server reachable over HTTP(S).
- An [API key](#api) to authenticate with the Headscale server.
### Setup remote control
@ -88,19 +86,20 @@ The gRPC interface can be used to control a Headscale instance from a remote mac
```yaml title="config.yaml"
cli:
address: <HEADSCALE_ADDRESS>:<PORT>
address: <HEADSCALE_URL>
api_key: <API_KEY>
```
=== "Environment variables"
```shell
export HEADSCALE_CLI_ADDRESS="<HEADSCALE_ADDRESS>:<PORT>"
export HEADSCALE_CLI_ADDRESS="<HEADSCALE_URL>"
export HEADSCALE_CLI_API_KEY="<API_KEY>"
```
This instructs the `headscale` binary to connect to a remote instance at `<HEADSCALE_ADDRESS>:<PORT>`, instead of
connecting to the local instance.
This instructs the `headscale` binary to connect to a remote instance at `<HEADSCALE_URL>` (e.g.
`https://headscale.example.com`), instead of connecting to the local instance. A bare host without a scheme is
assumed to be `https`.
1. Test the connection by listing all nodes:
@ -113,15 +112,12 @@ The gRPC interface can be used to control a Headscale instance from a remote mac
### Behind a proxy
It's possible to run the gRPC remote endpoint behind a reverse proxy, like Nginx, and have it run on the _same_ port as Headscale.
While this is _not a supported_ feature, an example on how this can be set up on
[NixOS is shown here](https://github.com/kradalby/dotfiles/blob/4489cdbb19cddfbfae82cd70448a38fde5a76711/machines/headscale.oracldn/headscale.nix#L61-L91).
The remote CLI uses the same HTTP API as everything else, so it works through the reverse proxy already in front of
Headscale with no extra setup.
### Troubleshooting
- Make sure you have the _same_ Headscale version on your server and workstation.
- Ensure that connections to the gRPC port are allowed.
- Verify that your TLS certificate is valid and trusted.
- If you don't have access to a trusted certificate (e.g. from Let's Encrypt), either:
- Add your self-signed certificate to the trust store of your OS _or_

View file

@ -69,7 +69,6 @@ on inbound requests with sanitized values. Headscale picks the first valid IP ad
- A reverse proxy adds another layer of complexity that needs to be able to handle the [Tailscale Control
Protocol](#websocket) properly. Be sure to test your setup without a reverse proxy before raising an issue.
- STUN (used along with the [embedded DERP server](../derp.md)) requires udp/3478 to be served publicly.
- [gRPC](../api.md#grpc) (used to remote control Headscale) may not be proxied.
## Reverse proxy specific configuration
@ -84,14 +83,12 @@ is [assumed](../../setup/requirements.md):
- Service for Tailscale clients is served via HTTPS on port 443.
- The reverse proxy redirects HTTP to HTTPS and is terminating TLS.
- Both Headscale and the reverse proxy are running on the same host.
- [Metrics](../debug.md#metrics-and-debug-endpoint) and [gRPC](../api.md#grpc) are not proxied, those are available via
localhost.
- [Metrics](../debug.md#metrics-and-debug-endpoint) are not proxied, those are available via localhost.
```yaml title="config.yaml" hl_lines="1"
server_url: https://<SERVER_NAME>
listen_addr: 127.0.0.1:8080
metrics_listen_addr: 127.0.0.1:9090
grpc_listen_addr: 127.0.0.1:50443
trusted_proxies:
- 127.0.0.1/32
- ::1/128

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.

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": ["*"]
}
]
}

View file

@ -26,9 +26,6 @@ The ports in use vary with the intended scenario and enabled features. Some of t
- udp/3478
- Expose publicly: yes
- STUN, required if the [embedded DERP server](../ref/derp.md) is enabled
- tcp/50443
- Expose publicly: yes
- Only required if the gRPC interface is used to [remote-control Headscale](../ref/api.md#grpc).
- tcp/9090
- Expose publicly: no
- [Metrics and debug endpoint](../ref/debug.md#metrics-and-debug-endpoint)

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

79
flake.lock generated
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",

111
flake.nix
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
@ -66,40 +71,6 @@
subPackages = [ "cmd/hi" ];
};
protoc-gen-grpc-gateway = buildGo rec {
pname = "grpc-gateway";
version = "2.29.0";
src = pkgs.fetchFromGitHub {
owner = "grpc-ecosystem";
repo = "grpc-gateway";
rev = "v${version}";
sha256 = "sha256-d9OIIGttyMBSNgpS6mbR5JEIm13qGu2gFHJazJAexdw=";
};
vendorHash = "sha256-p51yD+v8+rPs+ztlX7r0VQ4XlwUkxu+PxgknKEvH00k=";
nativeBuildInputs = [ pkgs.installShellFiles ];
subPackages = [ "protoc-gen-grpc-gateway" "protoc-gen-openapiv2" ];
};
protobuf-language-server = buildGo rec {
pname = "protobuf-language-server";
version = "ab4c128";
src = pkgs.fetchFromGitHub {
owner = "lasorda";
repo = "protobuf-language-server";
rev = "ab4c128f00774d51bd6d1f4cfa735f4b7c8619e3";
sha256 = "sha256-yF6kG+qTRxVO/qp2V9HgTyFBeOm5RQzeqdZFrdidwxM=";
};
vendorHash = "sha256-4nTpKBe7ekJsfQf+P6edT/9Vp2SBYbKz1ITawD3bhkI=";
subPackages = [ "." ];
};
# Build golangci-lint with stock Go 1.26 (upstream uses hardcoded Go
# version); it does not build against the pinned 1.26.4.
golangci-lint = buildGo rec {
@ -186,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
@ -195,15 +171,6 @@
# 'dot' is needed for pprof graphs
# go tool pprof -http=: <source>
graphviz
# Protobuf dependencies
protobuf
protoc-gen-go
protoc-gen-go-grpc
protoc-gen-grpc-gateway
buf
clang-tools # clang-format
protobuf-language-server
]
++ lib.optionals pkgs.stdenv.isLinux [ traceroute ];
@ -219,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`
@ -264,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;
});
}

View file

@ -1,6 +1,6 @@
{
"vendor": {
"goModSum": "sha256-csVm5v6HZ49PBp/FCX+yK1sjV8/nuUQz3GKN21Ne1mg=",
"sri": "sha256-fzKyXNMw/2yAEhaTZu0n1NXatPO2IP0HFA2ey1vZIYM="
"goModSum": "sha256-SJml8RXGmb2p0g1nOsHn86FA1hwgd5ZffLSkUj5zek8=",
"sri": "sha256-pjGNuVtgFFzWNq/2cK7a4iyF13AfcHz098nk92a9Ido="
}
}

4728
gen/client/v1/client.gen.go Normal file

File diff suppressed because it is too large Load diff

4013
gen/client/v2/client.gen.go Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,537 +0,0 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc (unknown)
// source: headscale/v1/apikey.proto
package v1
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
timestamppb "google.golang.org/protobuf/types/known/timestamppb"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type ApiKey struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id uint64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
Prefix string `protobuf:"bytes,2,opt,name=prefix,proto3" json:"prefix,omitempty"`
Expiration *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=expiration,proto3" json:"expiration,omitempty"`
CreatedAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
LastSeen *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=last_seen,json=lastSeen,proto3" json:"last_seen,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ApiKey) Reset() {
*x = ApiKey{}
mi := &file_headscale_v1_apikey_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ApiKey) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ApiKey) ProtoMessage() {}
func (x *ApiKey) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_apikey_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ApiKey.ProtoReflect.Descriptor instead.
func (*ApiKey) Descriptor() ([]byte, []int) {
return file_headscale_v1_apikey_proto_rawDescGZIP(), []int{0}
}
func (x *ApiKey) GetId() uint64 {
if x != nil {
return x.Id
}
return 0
}
func (x *ApiKey) GetPrefix() string {
if x != nil {
return x.Prefix
}
return ""
}
func (x *ApiKey) GetExpiration() *timestamppb.Timestamp {
if x != nil {
return x.Expiration
}
return nil
}
func (x *ApiKey) GetCreatedAt() *timestamppb.Timestamp {
if x != nil {
return x.CreatedAt
}
return nil
}
func (x *ApiKey) GetLastSeen() *timestamppb.Timestamp {
if x != nil {
return x.LastSeen
}
return nil
}
type CreateApiKeyRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Expiration *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=expiration,proto3" json:"expiration,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CreateApiKeyRequest) Reset() {
*x = CreateApiKeyRequest{}
mi := &file_headscale_v1_apikey_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CreateApiKeyRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CreateApiKeyRequest) ProtoMessage() {}
func (x *CreateApiKeyRequest) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_apikey_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CreateApiKeyRequest.ProtoReflect.Descriptor instead.
func (*CreateApiKeyRequest) Descriptor() ([]byte, []int) {
return file_headscale_v1_apikey_proto_rawDescGZIP(), []int{1}
}
func (x *CreateApiKeyRequest) GetExpiration() *timestamppb.Timestamp {
if x != nil {
return x.Expiration
}
return nil
}
type CreateApiKeyResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
ApiKey string `protobuf:"bytes,1,opt,name=api_key,json=apiKey,proto3" json:"api_key,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CreateApiKeyResponse) Reset() {
*x = CreateApiKeyResponse{}
mi := &file_headscale_v1_apikey_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CreateApiKeyResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CreateApiKeyResponse) ProtoMessage() {}
func (x *CreateApiKeyResponse) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_apikey_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CreateApiKeyResponse.ProtoReflect.Descriptor instead.
func (*CreateApiKeyResponse) Descriptor() ([]byte, []int) {
return file_headscale_v1_apikey_proto_rawDescGZIP(), []int{2}
}
func (x *CreateApiKeyResponse) GetApiKey() string {
if x != nil {
return x.ApiKey
}
return ""
}
type ExpireApiKeyRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Prefix string `protobuf:"bytes,1,opt,name=prefix,proto3" json:"prefix,omitempty"`
Id uint64 `protobuf:"varint,2,opt,name=id,proto3" json:"id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ExpireApiKeyRequest) Reset() {
*x = ExpireApiKeyRequest{}
mi := &file_headscale_v1_apikey_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ExpireApiKeyRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ExpireApiKeyRequest) ProtoMessage() {}
func (x *ExpireApiKeyRequest) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_apikey_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ExpireApiKeyRequest.ProtoReflect.Descriptor instead.
func (*ExpireApiKeyRequest) Descriptor() ([]byte, []int) {
return file_headscale_v1_apikey_proto_rawDescGZIP(), []int{3}
}
func (x *ExpireApiKeyRequest) GetPrefix() string {
if x != nil {
return x.Prefix
}
return ""
}
func (x *ExpireApiKeyRequest) GetId() uint64 {
if x != nil {
return x.Id
}
return 0
}
type ExpireApiKeyResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ExpireApiKeyResponse) Reset() {
*x = ExpireApiKeyResponse{}
mi := &file_headscale_v1_apikey_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ExpireApiKeyResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ExpireApiKeyResponse) ProtoMessage() {}
func (x *ExpireApiKeyResponse) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_apikey_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ExpireApiKeyResponse.ProtoReflect.Descriptor instead.
func (*ExpireApiKeyResponse) Descriptor() ([]byte, []int) {
return file_headscale_v1_apikey_proto_rawDescGZIP(), []int{4}
}
type ListApiKeysRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ListApiKeysRequest) Reset() {
*x = ListApiKeysRequest{}
mi := &file_headscale_v1_apikey_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ListApiKeysRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ListApiKeysRequest) ProtoMessage() {}
func (x *ListApiKeysRequest) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_apikey_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ListApiKeysRequest.ProtoReflect.Descriptor instead.
func (*ListApiKeysRequest) Descriptor() ([]byte, []int) {
return file_headscale_v1_apikey_proto_rawDescGZIP(), []int{5}
}
type ListApiKeysResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
ApiKeys []*ApiKey `protobuf:"bytes,1,rep,name=api_keys,json=apiKeys,proto3" json:"api_keys,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ListApiKeysResponse) Reset() {
*x = ListApiKeysResponse{}
mi := &file_headscale_v1_apikey_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ListApiKeysResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ListApiKeysResponse) ProtoMessage() {}
func (x *ListApiKeysResponse) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_apikey_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ListApiKeysResponse.ProtoReflect.Descriptor instead.
func (*ListApiKeysResponse) Descriptor() ([]byte, []int) {
return file_headscale_v1_apikey_proto_rawDescGZIP(), []int{6}
}
func (x *ListApiKeysResponse) GetApiKeys() []*ApiKey {
if x != nil {
return x.ApiKeys
}
return nil
}
type DeleteApiKeyRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Prefix string `protobuf:"bytes,1,opt,name=prefix,proto3" json:"prefix,omitempty"`
Id uint64 `protobuf:"varint,2,opt,name=id,proto3" json:"id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DeleteApiKeyRequest) Reset() {
*x = DeleteApiKeyRequest{}
mi := &file_headscale_v1_apikey_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DeleteApiKeyRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DeleteApiKeyRequest) ProtoMessage() {}
func (x *DeleteApiKeyRequest) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_apikey_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use DeleteApiKeyRequest.ProtoReflect.Descriptor instead.
func (*DeleteApiKeyRequest) Descriptor() ([]byte, []int) {
return file_headscale_v1_apikey_proto_rawDescGZIP(), []int{7}
}
func (x *DeleteApiKeyRequest) GetPrefix() string {
if x != nil {
return x.Prefix
}
return ""
}
func (x *DeleteApiKeyRequest) GetId() uint64 {
if x != nil {
return x.Id
}
return 0
}
type DeleteApiKeyResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DeleteApiKeyResponse) Reset() {
*x = DeleteApiKeyResponse{}
mi := &file_headscale_v1_apikey_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DeleteApiKeyResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DeleteApiKeyResponse) ProtoMessage() {}
func (x *DeleteApiKeyResponse) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_apikey_proto_msgTypes[8]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use DeleteApiKeyResponse.ProtoReflect.Descriptor instead.
func (*DeleteApiKeyResponse) Descriptor() ([]byte, []int) {
return file_headscale_v1_apikey_proto_rawDescGZIP(), []int{8}
}
var File_headscale_v1_apikey_proto protoreflect.FileDescriptor
const file_headscale_v1_apikey_proto_rawDesc = "" +
"\n" +
"\x19headscale/v1/apikey.proto\x12\fheadscale.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\xe0\x01\n" +
"\x06ApiKey\x12\x0e\n" +
"\x02id\x18\x01 \x01(\x04R\x02id\x12\x16\n" +
"\x06prefix\x18\x02 \x01(\tR\x06prefix\x12:\n" +
"\n" +
"expiration\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\n" +
"expiration\x129\n" +
"\n" +
"created_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x127\n" +
"\tlast_seen\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\blastSeen\"Q\n" +
"\x13CreateApiKeyRequest\x12:\n" +
"\n" +
"expiration\x18\x01 \x01(\v2\x1a.google.protobuf.TimestampR\n" +
"expiration\"/\n" +
"\x14CreateApiKeyResponse\x12\x17\n" +
"\aapi_key\x18\x01 \x01(\tR\x06apiKey\"=\n" +
"\x13ExpireApiKeyRequest\x12\x16\n" +
"\x06prefix\x18\x01 \x01(\tR\x06prefix\x12\x0e\n" +
"\x02id\x18\x02 \x01(\x04R\x02id\"\x16\n" +
"\x14ExpireApiKeyResponse\"\x14\n" +
"\x12ListApiKeysRequest\"F\n" +
"\x13ListApiKeysResponse\x12/\n" +
"\bapi_keys\x18\x01 \x03(\v2\x14.headscale.v1.ApiKeyR\aapiKeys\"=\n" +
"\x13DeleteApiKeyRequest\x12\x16\n" +
"\x06prefix\x18\x01 \x01(\tR\x06prefix\x12\x0e\n" +
"\x02id\x18\x02 \x01(\x04R\x02id\"\x16\n" +
"\x14DeleteApiKeyResponseB)Z'github.com/juanfont/headscale/gen/go/v1b\x06proto3"
var (
file_headscale_v1_apikey_proto_rawDescOnce sync.Once
file_headscale_v1_apikey_proto_rawDescData []byte
)
func file_headscale_v1_apikey_proto_rawDescGZIP() []byte {
file_headscale_v1_apikey_proto_rawDescOnce.Do(func() {
file_headscale_v1_apikey_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_headscale_v1_apikey_proto_rawDesc), len(file_headscale_v1_apikey_proto_rawDesc)))
})
return file_headscale_v1_apikey_proto_rawDescData
}
var file_headscale_v1_apikey_proto_msgTypes = make([]protoimpl.MessageInfo, 9)
var file_headscale_v1_apikey_proto_goTypes = []any{
(*ApiKey)(nil), // 0: headscale.v1.ApiKey
(*CreateApiKeyRequest)(nil), // 1: headscale.v1.CreateApiKeyRequest
(*CreateApiKeyResponse)(nil), // 2: headscale.v1.CreateApiKeyResponse
(*ExpireApiKeyRequest)(nil), // 3: headscale.v1.ExpireApiKeyRequest
(*ExpireApiKeyResponse)(nil), // 4: headscale.v1.ExpireApiKeyResponse
(*ListApiKeysRequest)(nil), // 5: headscale.v1.ListApiKeysRequest
(*ListApiKeysResponse)(nil), // 6: headscale.v1.ListApiKeysResponse
(*DeleteApiKeyRequest)(nil), // 7: headscale.v1.DeleteApiKeyRequest
(*DeleteApiKeyResponse)(nil), // 8: headscale.v1.DeleteApiKeyResponse
(*timestamppb.Timestamp)(nil), // 9: google.protobuf.Timestamp
}
var file_headscale_v1_apikey_proto_depIdxs = []int32{
9, // 0: headscale.v1.ApiKey.expiration:type_name -> google.protobuf.Timestamp
9, // 1: headscale.v1.ApiKey.created_at:type_name -> google.protobuf.Timestamp
9, // 2: headscale.v1.ApiKey.last_seen:type_name -> google.protobuf.Timestamp
9, // 3: headscale.v1.CreateApiKeyRequest.expiration:type_name -> google.protobuf.Timestamp
0, // 4: headscale.v1.ListApiKeysResponse.api_keys:type_name -> headscale.v1.ApiKey
5, // [5:5] is the sub-list for method output_type
5, // [5:5] is the sub-list for method input_type
5, // [5:5] is the sub-list for extension type_name
5, // [5:5] is the sub-list for extension extendee
0, // [0:5] is the sub-list for field type_name
}
func init() { file_headscale_v1_apikey_proto_init() }
func file_headscale_v1_apikey_proto_init() {
if File_headscale_v1_apikey_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_headscale_v1_apikey_proto_rawDesc), len(file_headscale_v1_apikey_proto_rawDesc)),
NumEnums: 0,
NumMessages: 9,
NumExtensions: 0,
NumServices: 0,
},
GoTypes: file_headscale_v1_apikey_proto_goTypes,
DependencyIndexes: file_headscale_v1_apikey_proto_depIdxs,
MessageInfos: file_headscale_v1_apikey_proto_msgTypes,
}.Build()
File_headscale_v1_apikey_proto = out.File
file_headscale_v1_apikey_proto_goTypes = nil
file_headscale_v1_apikey_proto_depIdxs = nil
}

View file

@ -1,351 +0,0 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc (unknown)
// source: headscale/v1/auth.proto
package v1
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type AuthRegisterRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
User string `protobuf:"bytes,1,opt,name=user,proto3" json:"user,omitempty"`
AuthId string `protobuf:"bytes,2,opt,name=auth_id,json=authId,proto3" json:"auth_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *AuthRegisterRequest) Reset() {
*x = AuthRegisterRequest{}
mi := &file_headscale_v1_auth_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *AuthRegisterRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*AuthRegisterRequest) ProtoMessage() {}
func (x *AuthRegisterRequest) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_auth_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use AuthRegisterRequest.ProtoReflect.Descriptor instead.
func (*AuthRegisterRequest) Descriptor() ([]byte, []int) {
return file_headscale_v1_auth_proto_rawDescGZIP(), []int{0}
}
func (x *AuthRegisterRequest) GetUser() string {
if x != nil {
return x.User
}
return ""
}
func (x *AuthRegisterRequest) GetAuthId() string {
if x != nil {
return x.AuthId
}
return ""
}
type AuthRegisterResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Node *Node `protobuf:"bytes,1,opt,name=node,proto3" json:"node,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *AuthRegisterResponse) Reset() {
*x = AuthRegisterResponse{}
mi := &file_headscale_v1_auth_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *AuthRegisterResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*AuthRegisterResponse) ProtoMessage() {}
func (x *AuthRegisterResponse) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_auth_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use AuthRegisterResponse.ProtoReflect.Descriptor instead.
func (*AuthRegisterResponse) Descriptor() ([]byte, []int) {
return file_headscale_v1_auth_proto_rawDescGZIP(), []int{1}
}
func (x *AuthRegisterResponse) GetNode() *Node {
if x != nil {
return x.Node
}
return nil
}
type AuthApproveRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
AuthId string `protobuf:"bytes,1,opt,name=auth_id,json=authId,proto3" json:"auth_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *AuthApproveRequest) Reset() {
*x = AuthApproveRequest{}
mi := &file_headscale_v1_auth_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *AuthApproveRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*AuthApproveRequest) ProtoMessage() {}
func (x *AuthApproveRequest) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_auth_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use AuthApproveRequest.ProtoReflect.Descriptor instead.
func (*AuthApproveRequest) Descriptor() ([]byte, []int) {
return file_headscale_v1_auth_proto_rawDescGZIP(), []int{2}
}
func (x *AuthApproveRequest) GetAuthId() string {
if x != nil {
return x.AuthId
}
return ""
}
type AuthApproveResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *AuthApproveResponse) Reset() {
*x = AuthApproveResponse{}
mi := &file_headscale_v1_auth_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *AuthApproveResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*AuthApproveResponse) ProtoMessage() {}
func (x *AuthApproveResponse) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_auth_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use AuthApproveResponse.ProtoReflect.Descriptor instead.
func (*AuthApproveResponse) Descriptor() ([]byte, []int) {
return file_headscale_v1_auth_proto_rawDescGZIP(), []int{3}
}
type AuthRejectRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
AuthId string `protobuf:"bytes,1,opt,name=auth_id,json=authId,proto3" json:"auth_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *AuthRejectRequest) Reset() {
*x = AuthRejectRequest{}
mi := &file_headscale_v1_auth_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *AuthRejectRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*AuthRejectRequest) ProtoMessage() {}
func (x *AuthRejectRequest) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_auth_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use AuthRejectRequest.ProtoReflect.Descriptor instead.
func (*AuthRejectRequest) Descriptor() ([]byte, []int) {
return file_headscale_v1_auth_proto_rawDescGZIP(), []int{4}
}
func (x *AuthRejectRequest) GetAuthId() string {
if x != nil {
return x.AuthId
}
return ""
}
type AuthRejectResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *AuthRejectResponse) Reset() {
*x = AuthRejectResponse{}
mi := &file_headscale_v1_auth_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *AuthRejectResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*AuthRejectResponse) ProtoMessage() {}
func (x *AuthRejectResponse) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_auth_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use AuthRejectResponse.ProtoReflect.Descriptor instead.
func (*AuthRejectResponse) Descriptor() ([]byte, []int) {
return file_headscale_v1_auth_proto_rawDescGZIP(), []int{5}
}
var File_headscale_v1_auth_proto protoreflect.FileDescriptor
const file_headscale_v1_auth_proto_rawDesc = "" +
"\n" +
"\x17headscale/v1/auth.proto\x12\fheadscale.v1\x1a\x17headscale/v1/node.proto\"B\n" +
"\x13AuthRegisterRequest\x12\x12\n" +
"\x04user\x18\x01 \x01(\tR\x04user\x12\x17\n" +
"\aauth_id\x18\x02 \x01(\tR\x06authId\">\n" +
"\x14AuthRegisterResponse\x12&\n" +
"\x04node\x18\x01 \x01(\v2\x12.headscale.v1.NodeR\x04node\"-\n" +
"\x12AuthApproveRequest\x12\x17\n" +
"\aauth_id\x18\x01 \x01(\tR\x06authId\"\x15\n" +
"\x13AuthApproveResponse\",\n" +
"\x11AuthRejectRequest\x12\x17\n" +
"\aauth_id\x18\x01 \x01(\tR\x06authId\"\x14\n" +
"\x12AuthRejectResponseB)Z'github.com/juanfont/headscale/gen/go/v1b\x06proto3"
var (
file_headscale_v1_auth_proto_rawDescOnce sync.Once
file_headscale_v1_auth_proto_rawDescData []byte
)
func file_headscale_v1_auth_proto_rawDescGZIP() []byte {
file_headscale_v1_auth_proto_rawDescOnce.Do(func() {
file_headscale_v1_auth_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_headscale_v1_auth_proto_rawDesc), len(file_headscale_v1_auth_proto_rawDesc)))
})
return file_headscale_v1_auth_proto_rawDescData
}
var file_headscale_v1_auth_proto_msgTypes = make([]protoimpl.MessageInfo, 6)
var file_headscale_v1_auth_proto_goTypes = []any{
(*AuthRegisterRequest)(nil), // 0: headscale.v1.AuthRegisterRequest
(*AuthRegisterResponse)(nil), // 1: headscale.v1.AuthRegisterResponse
(*AuthApproveRequest)(nil), // 2: headscale.v1.AuthApproveRequest
(*AuthApproveResponse)(nil), // 3: headscale.v1.AuthApproveResponse
(*AuthRejectRequest)(nil), // 4: headscale.v1.AuthRejectRequest
(*AuthRejectResponse)(nil), // 5: headscale.v1.AuthRejectResponse
(*Node)(nil), // 6: headscale.v1.Node
}
var file_headscale_v1_auth_proto_depIdxs = []int32{
6, // 0: headscale.v1.AuthRegisterResponse.node:type_name -> headscale.v1.Node
1, // [1:1] is the sub-list for method output_type
1, // [1:1] is the sub-list for method input_type
1, // [1:1] is the sub-list for extension type_name
1, // [1:1] is the sub-list for extension extendee
0, // [0:1] is the sub-list for field type_name
}
func init() { file_headscale_v1_auth_proto_init() }
func file_headscale_v1_auth_proto_init() {
if File_headscale_v1_auth_proto != nil {
return
}
file_headscale_v1_node_proto_init()
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_headscale_v1_auth_proto_rawDesc), len(file_headscale_v1_auth_proto_rawDesc)),
NumEnums: 0,
NumMessages: 6,
NumExtensions: 0,
NumServices: 0,
},
GoTypes: file_headscale_v1_auth_proto_goTypes,
DependencyIndexes: file_headscale_v1_auth_proto_depIdxs,
MessageInfos: file_headscale_v1_auth_proto_msgTypes,
}.Build()
File_headscale_v1_auth_proto = out.File
file_headscale_v1_auth_proto_goTypes = nil
file_headscale_v1_auth_proto_depIdxs = nil
}

View file

@ -1,890 +0,0 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc (unknown)
// source: headscale/v1/device.proto
package v1
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
timestamppb "google.golang.org/protobuf/types/known/timestamppb"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type Latency struct {
state protoimpl.MessageState `protogen:"open.v1"`
LatencyMs float32 `protobuf:"fixed32,1,opt,name=latency_ms,json=latencyMs,proto3" json:"latency_ms,omitempty"`
Preferred bool `protobuf:"varint,2,opt,name=preferred,proto3" json:"preferred,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Latency) Reset() {
*x = Latency{}
mi := &file_headscale_v1_device_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *Latency) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*Latency) ProtoMessage() {}
func (x *Latency) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_device_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use Latency.ProtoReflect.Descriptor instead.
func (*Latency) Descriptor() ([]byte, []int) {
return file_headscale_v1_device_proto_rawDescGZIP(), []int{0}
}
func (x *Latency) GetLatencyMs() float32 {
if x != nil {
return x.LatencyMs
}
return 0
}
func (x *Latency) GetPreferred() bool {
if x != nil {
return x.Preferred
}
return false
}
type ClientSupports struct {
state protoimpl.MessageState `protogen:"open.v1"`
HairPinning bool `protobuf:"varint,1,opt,name=hair_pinning,json=hairPinning,proto3" json:"hair_pinning,omitempty"`
Ipv6 bool `protobuf:"varint,2,opt,name=ipv6,proto3" json:"ipv6,omitempty"`
Pcp bool `protobuf:"varint,3,opt,name=pcp,proto3" json:"pcp,omitempty"`
Pmp bool `protobuf:"varint,4,opt,name=pmp,proto3" json:"pmp,omitempty"`
Udp bool `protobuf:"varint,5,opt,name=udp,proto3" json:"udp,omitempty"`
Upnp bool `protobuf:"varint,6,opt,name=upnp,proto3" json:"upnp,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ClientSupports) Reset() {
*x = ClientSupports{}
mi := &file_headscale_v1_device_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ClientSupports) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ClientSupports) ProtoMessage() {}
func (x *ClientSupports) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_device_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ClientSupports.ProtoReflect.Descriptor instead.
func (*ClientSupports) Descriptor() ([]byte, []int) {
return file_headscale_v1_device_proto_rawDescGZIP(), []int{1}
}
func (x *ClientSupports) GetHairPinning() bool {
if x != nil {
return x.HairPinning
}
return false
}
func (x *ClientSupports) GetIpv6() bool {
if x != nil {
return x.Ipv6
}
return false
}
func (x *ClientSupports) GetPcp() bool {
if x != nil {
return x.Pcp
}
return false
}
func (x *ClientSupports) GetPmp() bool {
if x != nil {
return x.Pmp
}
return false
}
func (x *ClientSupports) GetUdp() bool {
if x != nil {
return x.Udp
}
return false
}
func (x *ClientSupports) GetUpnp() bool {
if x != nil {
return x.Upnp
}
return false
}
type ClientConnectivity struct {
state protoimpl.MessageState `protogen:"open.v1"`
Endpoints []string `protobuf:"bytes,1,rep,name=endpoints,proto3" json:"endpoints,omitempty"`
Derp string `protobuf:"bytes,2,opt,name=derp,proto3" json:"derp,omitempty"`
MappingVariesByDestIp bool `protobuf:"varint,3,opt,name=mapping_varies_by_dest_ip,json=mappingVariesByDestIp,proto3" json:"mapping_varies_by_dest_ip,omitempty"`
Latency map[string]*Latency `protobuf:"bytes,4,rep,name=latency,proto3" json:"latency,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
ClientSupports *ClientSupports `protobuf:"bytes,5,opt,name=client_supports,json=clientSupports,proto3" json:"client_supports,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ClientConnectivity) Reset() {
*x = ClientConnectivity{}
mi := &file_headscale_v1_device_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ClientConnectivity) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ClientConnectivity) ProtoMessage() {}
func (x *ClientConnectivity) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_device_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ClientConnectivity.ProtoReflect.Descriptor instead.
func (*ClientConnectivity) Descriptor() ([]byte, []int) {
return file_headscale_v1_device_proto_rawDescGZIP(), []int{2}
}
func (x *ClientConnectivity) GetEndpoints() []string {
if x != nil {
return x.Endpoints
}
return nil
}
func (x *ClientConnectivity) GetDerp() string {
if x != nil {
return x.Derp
}
return ""
}
func (x *ClientConnectivity) GetMappingVariesByDestIp() bool {
if x != nil {
return x.MappingVariesByDestIp
}
return false
}
func (x *ClientConnectivity) GetLatency() map[string]*Latency {
if x != nil {
return x.Latency
}
return nil
}
func (x *ClientConnectivity) GetClientSupports() *ClientSupports {
if x != nil {
return x.ClientSupports
}
return nil
}
type GetDeviceRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetDeviceRequest) Reset() {
*x = GetDeviceRequest{}
mi := &file_headscale_v1_device_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetDeviceRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetDeviceRequest) ProtoMessage() {}
func (x *GetDeviceRequest) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_device_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetDeviceRequest.ProtoReflect.Descriptor instead.
func (*GetDeviceRequest) Descriptor() ([]byte, []int) {
return file_headscale_v1_device_proto_rawDescGZIP(), []int{3}
}
func (x *GetDeviceRequest) GetId() string {
if x != nil {
return x.Id
}
return ""
}
type GetDeviceResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Addresses []string `protobuf:"bytes,1,rep,name=addresses,proto3" json:"addresses,omitempty"`
Id string `protobuf:"bytes,2,opt,name=id,proto3" json:"id,omitempty"`
User string `protobuf:"bytes,3,opt,name=user,proto3" json:"user,omitempty"`
Name string `protobuf:"bytes,4,opt,name=name,proto3" json:"name,omitempty"`
Hostname string `protobuf:"bytes,5,opt,name=hostname,proto3" json:"hostname,omitempty"`
ClientVersion string `protobuf:"bytes,6,opt,name=client_version,json=clientVersion,proto3" json:"client_version,omitempty"`
UpdateAvailable bool `protobuf:"varint,7,opt,name=update_available,json=updateAvailable,proto3" json:"update_available,omitempty"`
Os string `protobuf:"bytes,8,opt,name=os,proto3" json:"os,omitempty"`
Created *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=created,proto3" json:"created,omitempty"`
LastSeen *timestamppb.Timestamp `protobuf:"bytes,10,opt,name=last_seen,json=lastSeen,proto3" json:"last_seen,omitempty"`
KeyExpiryDisabled bool `protobuf:"varint,11,opt,name=key_expiry_disabled,json=keyExpiryDisabled,proto3" json:"key_expiry_disabled,omitempty"`
Expires *timestamppb.Timestamp `protobuf:"bytes,12,opt,name=expires,proto3" json:"expires,omitempty"`
Authorized bool `protobuf:"varint,13,opt,name=authorized,proto3" json:"authorized,omitempty"`
IsExternal bool `protobuf:"varint,14,opt,name=is_external,json=isExternal,proto3" json:"is_external,omitempty"`
MachineKey string `protobuf:"bytes,15,opt,name=machine_key,json=machineKey,proto3" json:"machine_key,omitempty"`
NodeKey string `protobuf:"bytes,16,opt,name=node_key,json=nodeKey,proto3" json:"node_key,omitempty"`
BlocksIncomingConnections bool `protobuf:"varint,17,opt,name=blocks_incoming_connections,json=blocksIncomingConnections,proto3" json:"blocks_incoming_connections,omitempty"`
EnabledRoutes []string `protobuf:"bytes,18,rep,name=enabled_routes,json=enabledRoutes,proto3" json:"enabled_routes,omitempty"`
AdvertisedRoutes []string `protobuf:"bytes,19,rep,name=advertised_routes,json=advertisedRoutes,proto3" json:"advertised_routes,omitempty"`
ClientConnectivity *ClientConnectivity `protobuf:"bytes,20,opt,name=client_connectivity,json=clientConnectivity,proto3" json:"client_connectivity,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetDeviceResponse) Reset() {
*x = GetDeviceResponse{}
mi := &file_headscale_v1_device_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetDeviceResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetDeviceResponse) ProtoMessage() {}
func (x *GetDeviceResponse) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_device_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetDeviceResponse.ProtoReflect.Descriptor instead.
func (*GetDeviceResponse) Descriptor() ([]byte, []int) {
return file_headscale_v1_device_proto_rawDescGZIP(), []int{4}
}
func (x *GetDeviceResponse) GetAddresses() []string {
if x != nil {
return x.Addresses
}
return nil
}
func (x *GetDeviceResponse) GetId() string {
if x != nil {
return x.Id
}
return ""
}
func (x *GetDeviceResponse) GetUser() string {
if x != nil {
return x.User
}
return ""
}
func (x *GetDeviceResponse) GetName() string {
if x != nil {
return x.Name
}
return ""
}
func (x *GetDeviceResponse) GetHostname() string {
if x != nil {
return x.Hostname
}
return ""
}
func (x *GetDeviceResponse) GetClientVersion() string {
if x != nil {
return x.ClientVersion
}
return ""
}
func (x *GetDeviceResponse) GetUpdateAvailable() bool {
if x != nil {
return x.UpdateAvailable
}
return false
}
func (x *GetDeviceResponse) GetOs() string {
if x != nil {
return x.Os
}
return ""
}
func (x *GetDeviceResponse) GetCreated() *timestamppb.Timestamp {
if x != nil {
return x.Created
}
return nil
}
func (x *GetDeviceResponse) GetLastSeen() *timestamppb.Timestamp {
if x != nil {
return x.LastSeen
}
return nil
}
func (x *GetDeviceResponse) GetKeyExpiryDisabled() bool {
if x != nil {
return x.KeyExpiryDisabled
}
return false
}
func (x *GetDeviceResponse) GetExpires() *timestamppb.Timestamp {
if x != nil {
return x.Expires
}
return nil
}
func (x *GetDeviceResponse) GetAuthorized() bool {
if x != nil {
return x.Authorized
}
return false
}
func (x *GetDeviceResponse) GetIsExternal() bool {
if x != nil {
return x.IsExternal
}
return false
}
func (x *GetDeviceResponse) GetMachineKey() string {
if x != nil {
return x.MachineKey
}
return ""
}
func (x *GetDeviceResponse) GetNodeKey() string {
if x != nil {
return x.NodeKey
}
return ""
}
func (x *GetDeviceResponse) GetBlocksIncomingConnections() bool {
if x != nil {
return x.BlocksIncomingConnections
}
return false
}
func (x *GetDeviceResponse) GetEnabledRoutes() []string {
if x != nil {
return x.EnabledRoutes
}
return nil
}
func (x *GetDeviceResponse) GetAdvertisedRoutes() []string {
if x != nil {
return x.AdvertisedRoutes
}
return nil
}
func (x *GetDeviceResponse) GetClientConnectivity() *ClientConnectivity {
if x != nil {
return x.ClientConnectivity
}
return nil
}
type DeleteDeviceRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DeleteDeviceRequest) Reset() {
*x = DeleteDeviceRequest{}
mi := &file_headscale_v1_device_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DeleteDeviceRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DeleteDeviceRequest) ProtoMessage() {}
func (x *DeleteDeviceRequest) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_device_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use DeleteDeviceRequest.ProtoReflect.Descriptor instead.
func (*DeleteDeviceRequest) Descriptor() ([]byte, []int) {
return file_headscale_v1_device_proto_rawDescGZIP(), []int{5}
}
func (x *DeleteDeviceRequest) GetId() string {
if x != nil {
return x.Id
}
return ""
}
type DeleteDeviceResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DeleteDeviceResponse) Reset() {
*x = DeleteDeviceResponse{}
mi := &file_headscale_v1_device_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DeleteDeviceResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DeleteDeviceResponse) ProtoMessage() {}
func (x *DeleteDeviceResponse) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_device_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use DeleteDeviceResponse.ProtoReflect.Descriptor instead.
func (*DeleteDeviceResponse) Descriptor() ([]byte, []int) {
return file_headscale_v1_device_proto_rawDescGZIP(), []int{6}
}
type GetDeviceRoutesRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetDeviceRoutesRequest) Reset() {
*x = GetDeviceRoutesRequest{}
mi := &file_headscale_v1_device_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetDeviceRoutesRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetDeviceRoutesRequest) ProtoMessage() {}
func (x *GetDeviceRoutesRequest) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_device_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetDeviceRoutesRequest.ProtoReflect.Descriptor instead.
func (*GetDeviceRoutesRequest) Descriptor() ([]byte, []int) {
return file_headscale_v1_device_proto_rawDescGZIP(), []int{7}
}
func (x *GetDeviceRoutesRequest) GetId() string {
if x != nil {
return x.Id
}
return ""
}
type GetDeviceRoutesResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
EnabledRoutes []string `protobuf:"bytes,1,rep,name=enabled_routes,json=enabledRoutes,proto3" json:"enabled_routes,omitempty"`
AdvertisedRoutes []string `protobuf:"bytes,2,rep,name=advertised_routes,json=advertisedRoutes,proto3" json:"advertised_routes,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetDeviceRoutesResponse) Reset() {
*x = GetDeviceRoutesResponse{}
mi := &file_headscale_v1_device_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetDeviceRoutesResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetDeviceRoutesResponse) ProtoMessage() {}
func (x *GetDeviceRoutesResponse) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_device_proto_msgTypes[8]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetDeviceRoutesResponse.ProtoReflect.Descriptor instead.
func (*GetDeviceRoutesResponse) Descriptor() ([]byte, []int) {
return file_headscale_v1_device_proto_rawDescGZIP(), []int{8}
}
func (x *GetDeviceRoutesResponse) GetEnabledRoutes() []string {
if x != nil {
return x.EnabledRoutes
}
return nil
}
func (x *GetDeviceRoutesResponse) GetAdvertisedRoutes() []string {
if x != nil {
return x.AdvertisedRoutes
}
return nil
}
type EnableDeviceRoutesRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
Routes []string `protobuf:"bytes,2,rep,name=routes,proto3" json:"routes,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *EnableDeviceRoutesRequest) Reset() {
*x = EnableDeviceRoutesRequest{}
mi := &file_headscale_v1_device_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *EnableDeviceRoutesRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*EnableDeviceRoutesRequest) ProtoMessage() {}
func (x *EnableDeviceRoutesRequest) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_device_proto_msgTypes[9]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use EnableDeviceRoutesRequest.ProtoReflect.Descriptor instead.
func (*EnableDeviceRoutesRequest) Descriptor() ([]byte, []int) {
return file_headscale_v1_device_proto_rawDescGZIP(), []int{9}
}
func (x *EnableDeviceRoutesRequest) GetId() string {
if x != nil {
return x.Id
}
return ""
}
func (x *EnableDeviceRoutesRequest) GetRoutes() []string {
if x != nil {
return x.Routes
}
return nil
}
type EnableDeviceRoutesResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
EnabledRoutes []string `protobuf:"bytes,1,rep,name=enabled_routes,json=enabledRoutes,proto3" json:"enabled_routes,omitempty"`
AdvertisedRoutes []string `protobuf:"bytes,2,rep,name=advertised_routes,json=advertisedRoutes,proto3" json:"advertised_routes,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *EnableDeviceRoutesResponse) Reset() {
*x = EnableDeviceRoutesResponse{}
mi := &file_headscale_v1_device_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *EnableDeviceRoutesResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*EnableDeviceRoutesResponse) ProtoMessage() {}
func (x *EnableDeviceRoutesResponse) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_device_proto_msgTypes[10]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use EnableDeviceRoutesResponse.ProtoReflect.Descriptor instead.
func (*EnableDeviceRoutesResponse) Descriptor() ([]byte, []int) {
return file_headscale_v1_device_proto_rawDescGZIP(), []int{10}
}
func (x *EnableDeviceRoutesResponse) GetEnabledRoutes() []string {
if x != nil {
return x.EnabledRoutes
}
return nil
}
func (x *EnableDeviceRoutesResponse) GetAdvertisedRoutes() []string {
if x != nil {
return x.AdvertisedRoutes
}
return nil
}
var File_headscale_v1_device_proto protoreflect.FileDescriptor
const file_headscale_v1_device_proto_rawDesc = "" +
"\n" +
"\x19headscale/v1/device.proto\x12\fheadscale.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"F\n" +
"\aLatency\x12\x1d\n" +
"\n" +
"latency_ms\x18\x01 \x01(\x02R\tlatencyMs\x12\x1c\n" +
"\tpreferred\x18\x02 \x01(\bR\tpreferred\"\x91\x01\n" +
"\x0eClientSupports\x12!\n" +
"\fhair_pinning\x18\x01 \x01(\bR\vhairPinning\x12\x12\n" +
"\x04ipv6\x18\x02 \x01(\bR\x04ipv6\x12\x10\n" +
"\x03pcp\x18\x03 \x01(\bR\x03pcp\x12\x10\n" +
"\x03pmp\x18\x04 \x01(\bR\x03pmp\x12\x10\n" +
"\x03udp\x18\x05 \x01(\bR\x03udp\x12\x12\n" +
"\x04upnp\x18\x06 \x01(\bR\x04upnp\"\xe3\x02\n" +
"\x12ClientConnectivity\x12\x1c\n" +
"\tendpoints\x18\x01 \x03(\tR\tendpoints\x12\x12\n" +
"\x04derp\x18\x02 \x01(\tR\x04derp\x128\n" +
"\x19mapping_varies_by_dest_ip\x18\x03 \x01(\bR\x15mappingVariesByDestIp\x12G\n" +
"\alatency\x18\x04 \x03(\v2-.headscale.v1.ClientConnectivity.LatencyEntryR\alatency\x12E\n" +
"\x0fclient_supports\x18\x05 \x01(\v2\x1c.headscale.v1.ClientSupportsR\x0eclientSupports\x1aQ\n" +
"\fLatencyEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12+\n" +
"\x05value\x18\x02 \x01(\v2\x15.headscale.v1.LatencyR\x05value:\x028\x01\"\"\n" +
"\x10GetDeviceRequest\x12\x0e\n" +
"\x02id\x18\x01 \x01(\tR\x02id\"\xa0\x06\n" +
"\x11GetDeviceResponse\x12\x1c\n" +
"\taddresses\x18\x01 \x03(\tR\taddresses\x12\x0e\n" +
"\x02id\x18\x02 \x01(\tR\x02id\x12\x12\n" +
"\x04user\x18\x03 \x01(\tR\x04user\x12\x12\n" +
"\x04name\x18\x04 \x01(\tR\x04name\x12\x1a\n" +
"\bhostname\x18\x05 \x01(\tR\bhostname\x12%\n" +
"\x0eclient_version\x18\x06 \x01(\tR\rclientVersion\x12)\n" +
"\x10update_available\x18\a \x01(\bR\x0fupdateAvailable\x12\x0e\n" +
"\x02os\x18\b \x01(\tR\x02os\x124\n" +
"\acreated\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\acreated\x127\n" +
"\tlast_seen\x18\n" +
" \x01(\v2\x1a.google.protobuf.TimestampR\blastSeen\x12.\n" +
"\x13key_expiry_disabled\x18\v \x01(\bR\x11keyExpiryDisabled\x124\n" +
"\aexpires\x18\f \x01(\v2\x1a.google.protobuf.TimestampR\aexpires\x12\x1e\n" +
"\n" +
"authorized\x18\r \x01(\bR\n" +
"authorized\x12\x1f\n" +
"\vis_external\x18\x0e \x01(\bR\n" +
"isExternal\x12\x1f\n" +
"\vmachine_key\x18\x0f \x01(\tR\n" +
"machineKey\x12\x19\n" +
"\bnode_key\x18\x10 \x01(\tR\anodeKey\x12>\n" +
"\x1bblocks_incoming_connections\x18\x11 \x01(\bR\x19blocksIncomingConnections\x12%\n" +
"\x0eenabled_routes\x18\x12 \x03(\tR\renabledRoutes\x12+\n" +
"\x11advertised_routes\x18\x13 \x03(\tR\x10advertisedRoutes\x12Q\n" +
"\x13client_connectivity\x18\x14 \x01(\v2 .headscale.v1.ClientConnectivityR\x12clientConnectivity\"%\n" +
"\x13DeleteDeviceRequest\x12\x0e\n" +
"\x02id\x18\x01 \x01(\tR\x02id\"\x16\n" +
"\x14DeleteDeviceResponse\"(\n" +
"\x16GetDeviceRoutesRequest\x12\x0e\n" +
"\x02id\x18\x01 \x01(\tR\x02id\"m\n" +
"\x17GetDeviceRoutesResponse\x12%\n" +
"\x0eenabled_routes\x18\x01 \x03(\tR\renabledRoutes\x12+\n" +
"\x11advertised_routes\x18\x02 \x03(\tR\x10advertisedRoutes\"C\n" +
"\x19EnableDeviceRoutesRequest\x12\x0e\n" +
"\x02id\x18\x01 \x01(\tR\x02id\x12\x16\n" +
"\x06routes\x18\x02 \x03(\tR\x06routes\"p\n" +
"\x1aEnableDeviceRoutesResponse\x12%\n" +
"\x0eenabled_routes\x18\x01 \x03(\tR\renabledRoutes\x12+\n" +
"\x11advertised_routes\x18\x02 \x03(\tR\x10advertisedRoutesB)Z'github.com/juanfont/headscale/gen/go/v1b\x06proto3"
var (
file_headscale_v1_device_proto_rawDescOnce sync.Once
file_headscale_v1_device_proto_rawDescData []byte
)
func file_headscale_v1_device_proto_rawDescGZIP() []byte {
file_headscale_v1_device_proto_rawDescOnce.Do(func() {
file_headscale_v1_device_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_headscale_v1_device_proto_rawDesc), len(file_headscale_v1_device_proto_rawDesc)))
})
return file_headscale_v1_device_proto_rawDescData
}
var file_headscale_v1_device_proto_msgTypes = make([]protoimpl.MessageInfo, 12)
var file_headscale_v1_device_proto_goTypes = []any{
(*Latency)(nil), // 0: headscale.v1.Latency
(*ClientSupports)(nil), // 1: headscale.v1.ClientSupports
(*ClientConnectivity)(nil), // 2: headscale.v1.ClientConnectivity
(*GetDeviceRequest)(nil), // 3: headscale.v1.GetDeviceRequest
(*GetDeviceResponse)(nil), // 4: headscale.v1.GetDeviceResponse
(*DeleteDeviceRequest)(nil), // 5: headscale.v1.DeleteDeviceRequest
(*DeleteDeviceResponse)(nil), // 6: headscale.v1.DeleteDeviceResponse
(*GetDeviceRoutesRequest)(nil), // 7: headscale.v1.GetDeviceRoutesRequest
(*GetDeviceRoutesResponse)(nil), // 8: headscale.v1.GetDeviceRoutesResponse
(*EnableDeviceRoutesRequest)(nil), // 9: headscale.v1.EnableDeviceRoutesRequest
(*EnableDeviceRoutesResponse)(nil), // 10: headscale.v1.EnableDeviceRoutesResponse
nil, // 11: headscale.v1.ClientConnectivity.LatencyEntry
(*timestamppb.Timestamp)(nil), // 12: google.protobuf.Timestamp
}
var file_headscale_v1_device_proto_depIdxs = []int32{
11, // 0: headscale.v1.ClientConnectivity.latency:type_name -> headscale.v1.ClientConnectivity.LatencyEntry
1, // 1: headscale.v1.ClientConnectivity.client_supports:type_name -> headscale.v1.ClientSupports
12, // 2: headscale.v1.GetDeviceResponse.created:type_name -> google.protobuf.Timestamp
12, // 3: headscale.v1.GetDeviceResponse.last_seen:type_name -> google.protobuf.Timestamp
12, // 4: headscale.v1.GetDeviceResponse.expires:type_name -> google.protobuf.Timestamp
2, // 5: headscale.v1.GetDeviceResponse.client_connectivity:type_name -> headscale.v1.ClientConnectivity
0, // 6: headscale.v1.ClientConnectivity.LatencyEntry.value:type_name -> headscale.v1.Latency
7, // [7:7] is the sub-list for method output_type
7, // [7:7] is the sub-list for method input_type
7, // [7:7] is the sub-list for extension type_name
7, // [7:7] is the sub-list for extension extendee
0, // [0:7] is the sub-list for field type_name
}
func init() { file_headscale_v1_device_proto_init() }
func file_headscale_v1_device_proto_init() {
if File_headscale_v1_device_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_headscale_v1_device_proto_rawDesc), len(file_headscale_v1_device_proto_rawDesc)),
NumEnums: 0,
NumMessages: 12,
NumExtensions: 0,
NumServices: 0,
},
GoTypes: file_headscale_v1_device_proto_goTypes,
DependencyIndexes: file_headscale_v1_device_proto_depIdxs,
MessageInfos: file_headscale_v1_device_proto_msgTypes,
}.Build()
File_headscale_v1_device_proto = out.File
file_headscale_v1_device_proto_goTypes = nil
file_headscale_v1_device_proto_depIdxs = nil
}

View file

@ -1,318 +0,0 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc (unknown)
// source: headscale/v1/headscale.proto
package v1
import (
_ "google.golang.org/genproto/googleapis/api/annotations"
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type HealthRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *HealthRequest) Reset() {
*x = HealthRequest{}
mi := &file_headscale_v1_headscale_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *HealthRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*HealthRequest) ProtoMessage() {}
func (x *HealthRequest) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_headscale_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use HealthRequest.ProtoReflect.Descriptor instead.
func (*HealthRequest) Descriptor() ([]byte, []int) {
return file_headscale_v1_headscale_proto_rawDescGZIP(), []int{0}
}
type HealthResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
DatabaseConnectivity bool `protobuf:"varint,1,opt,name=database_connectivity,json=databaseConnectivity,proto3" json:"database_connectivity,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *HealthResponse) Reset() {
*x = HealthResponse{}
mi := &file_headscale_v1_headscale_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *HealthResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*HealthResponse) ProtoMessage() {}
func (x *HealthResponse) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_headscale_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use HealthResponse.ProtoReflect.Descriptor instead.
func (*HealthResponse) Descriptor() ([]byte, []int) {
return file_headscale_v1_headscale_proto_rawDescGZIP(), []int{1}
}
func (x *HealthResponse) GetDatabaseConnectivity() bool {
if x != nil {
return x.DatabaseConnectivity
}
return false
}
var File_headscale_v1_headscale_proto protoreflect.FileDescriptor
const file_headscale_v1_headscale_proto_rawDesc = "" +
"\n" +
"\x1cheadscale/v1/headscale.proto\x12\fheadscale.v1\x1a\x1cgoogle/api/annotations.proto\x1a\x17headscale/v1/user.proto\x1a\x1dheadscale/v1/preauthkey.proto\x1a\x17headscale/v1/node.proto\x1a\x19headscale/v1/apikey.proto\x1a\x17headscale/v1/auth.proto\x1a\x19headscale/v1/policy.proto\"\x0f\n" +
"\rHealthRequest\"E\n" +
"\x0eHealthResponse\x123\n" +
"\x15database_connectivity\x18\x01 \x01(\bR\x14databaseConnectivity2\xe0\x1a\n" +
"\x10HeadscaleService\x12h\n" +
"\n" +
"CreateUser\x12\x1f.headscale.v1.CreateUserRequest\x1a .headscale.v1.CreateUserResponse\"\x17\x82\xd3\xe4\x93\x02\x11:\x01*\"\f/api/v1/user\x12\x80\x01\n" +
"\n" +
"RenameUser\x12\x1f.headscale.v1.RenameUserRequest\x1a .headscale.v1.RenameUserResponse\"/\x82\xd3\xe4\x93\x02)\"'/api/v1/user/{old_id}/rename/{new_name}\x12j\n" +
"\n" +
"DeleteUser\x12\x1f.headscale.v1.DeleteUserRequest\x1a .headscale.v1.DeleteUserResponse\"\x19\x82\xd3\xe4\x93\x02\x13*\x11/api/v1/user/{id}\x12b\n" +
"\tListUsers\x12\x1e.headscale.v1.ListUsersRequest\x1a\x1f.headscale.v1.ListUsersResponse\"\x14\x82\xd3\xe4\x93\x02\x0e\x12\f/api/v1/user\x12\x80\x01\n" +
"\x10CreatePreAuthKey\x12%.headscale.v1.CreatePreAuthKeyRequest\x1a&.headscale.v1.CreatePreAuthKeyResponse\"\x1d\x82\xd3\xe4\x93\x02\x17:\x01*\"\x12/api/v1/preauthkey\x12\x87\x01\n" +
"\x10ExpirePreAuthKey\x12%.headscale.v1.ExpirePreAuthKeyRequest\x1a&.headscale.v1.ExpirePreAuthKeyResponse\"$\x82\xd3\xe4\x93\x02\x1e:\x01*\"\x19/api/v1/preauthkey/expire\x12}\n" +
"\x10DeletePreAuthKey\x12%.headscale.v1.DeletePreAuthKeyRequest\x1a&.headscale.v1.DeletePreAuthKeyResponse\"\x1a\x82\xd3\xe4\x93\x02\x14*\x12/api/v1/preauthkey\x12z\n" +
"\x0fListPreAuthKeys\x12$.headscale.v1.ListPreAuthKeysRequest\x1a%.headscale.v1.ListPreAuthKeysResponse\"\x1a\x82\xd3\xe4\x93\x02\x14\x12\x12/api/v1/preauthkey\x12}\n" +
"\x0fDebugCreateNode\x12$.headscale.v1.DebugCreateNodeRequest\x1a%.headscale.v1.DebugCreateNodeResponse\"\x1d\x82\xd3\xe4\x93\x02\x17:\x01*\"\x12/api/v1/debug/node\x12f\n" +
"\aGetNode\x12\x1c.headscale.v1.GetNodeRequest\x1a\x1d.headscale.v1.GetNodeResponse\"\x1e\x82\xd3\xe4\x93\x02\x18\x12\x16/api/v1/node/{node_id}\x12n\n" +
"\aSetTags\x12\x1c.headscale.v1.SetTagsRequest\x1a\x1d.headscale.v1.SetTagsResponse\"&\x82\xd3\xe4\x93\x02 :\x01*\"\x1b/api/v1/node/{node_id}/tags\x12\x96\x01\n" +
"\x11SetApprovedRoutes\x12&.headscale.v1.SetApprovedRoutesRequest\x1a'.headscale.v1.SetApprovedRoutesResponse\"0\x82\xd3\xe4\x93\x02*:\x01*\"%/api/v1/node/{node_id}/approve_routes\x12t\n" +
"\fRegisterNode\x12!.headscale.v1.RegisterNodeRequest\x1a\".headscale.v1.RegisterNodeResponse\"\x1d\x82\xd3\xe4\x93\x02\x17\"\x15/api/v1/node/register\x12o\n" +
"\n" +
"DeleteNode\x12\x1f.headscale.v1.DeleteNodeRequest\x1a .headscale.v1.DeleteNodeResponse\"\x1e\x82\xd3\xe4\x93\x02\x18*\x16/api/v1/node/{node_id}\x12v\n" +
"\n" +
"ExpireNode\x12\x1f.headscale.v1.ExpireNodeRequest\x1a .headscale.v1.ExpireNodeResponse\"%\x82\xd3\xe4\x93\x02\x1f\"\x1d/api/v1/node/{node_id}/expire\x12\x81\x01\n" +
"\n" +
"RenameNode\x12\x1f.headscale.v1.RenameNodeRequest\x1a .headscale.v1.RenameNodeResponse\"0\x82\xd3\xe4\x93\x02*\"(/api/v1/node/{node_id}/rename/{new_name}\x12b\n" +
"\tListNodes\x12\x1e.headscale.v1.ListNodesRequest\x1a\x1f.headscale.v1.ListNodesResponse\"\x14\x82\xd3\xe4\x93\x02\x0e\x12\f/api/v1/node\x12\x80\x01\n" +
"\x0fBackfillNodeIPs\x12$.headscale.v1.BackfillNodeIPsRequest\x1a%.headscale.v1.BackfillNodeIPsResponse\" \x82\xd3\xe4\x93\x02\x1a\"\x18/api/v1/node/backfillips\x12w\n" +
"\fAuthRegister\x12!.headscale.v1.AuthRegisterRequest\x1a\".headscale.v1.AuthRegisterResponse\" \x82\xd3\xe4\x93\x02\x1a:\x01*\"\x15/api/v1/auth/register\x12s\n" +
"\vAuthApprove\x12 .headscale.v1.AuthApproveRequest\x1a!.headscale.v1.AuthApproveResponse\"\x1f\x82\xd3\xe4\x93\x02\x19:\x01*\"\x14/api/v1/auth/approve\x12o\n" +
"\n" +
"AuthReject\x12\x1f.headscale.v1.AuthRejectRequest\x1a .headscale.v1.AuthRejectResponse\"\x1e\x82\xd3\xe4\x93\x02\x18:\x01*\"\x13/api/v1/auth/reject\x12p\n" +
"\fCreateApiKey\x12!.headscale.v1.CreateApiKeyRequest\x1a\".headscale.v1.CreateApiKeyResponse\"\x19\x82\xd3\xe4\x93\x02\x13:\x01*\"\x0e/api/v1/apikey\x12w\n" +
"\fExpireApiKey\x12!.headscale.v1.ExpireApiKeyRequest\x1a\".headscale.v1.ExpireApiKeyResponse\" \x82\xd3\xe4\x93\x02\x1a:\x01*\"\x15/api/v1/apikey/expire\x12j\n" +
"\vListApiKeys\x12 .headscale.v1.ListApiKeysRequest\x1a!.headscale.v1.ListApiKeysResponse\"\x16\x82\xd3\xe4\x93\x02\x10\x12\x0e/api/v1/apikey\x12v\n" +
"\fDeleteApiKey\x12!.headscale.v1.DeleteApiKeyRequest\x1a\".headscale.v1.DeleteApiKeyResponse\"\x1f\x82\xd3\xe4\x93\x02\x19*\x17/api/v1/apikey/{prefix}\x12d\n" +
"\tGetPolicy\x12\x1e.headscale.v1.GetPolicyRequest\x1a\x1f.headscale.v1.GetPolicyResponse\"\x16\x82\xd3\xe4\x93\x02\x10\x12\x0e/api/v1/policy\x12g\n" +
"\tSetPolicy\x12\x1e.headscale.v1.SetPolicyRequest\x1a\x1f.headscale.v1.SetPolicyResponse\"\x19\x82\xd3\xe4\x93\x02\x13:\x01*\x1a\x0e/api/v1/policy\x12s\n" +
"\vCheckPolicy\x12 .headscale.v1.CheckPolicyRequest\x1a!.headscale.v1.CheckPolicyResponse\"\x1f\x82\xd3\xe4\x93\x02\x19:\x01*\"\x14/api/v1/policy/check\x12[\n" +
"\x06Health\x12\x1b.headscale.v1.HealthRequest\x1a\x1c.headscale.v1.HealthResponse\"\x16\x82\xd3\xe4\x93\x02\x10\x12\x0e/api/v1/healthB)Z'github.com/juanfont/headscale/gen/go/v1b\x06proto3"
var (
file_headscale_v1_headscale_proto_rawDescOnce sync.Once
file_headscale_v1_headscale_proto_rawDescData []byte
)
func file_headscale_v1_headscale_proto_rawDescGZIP() []byte {
file_headscale_v1_headscale_proto_rawDescOnce.Do(func() {
file_headscale_v1_headscale_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_headscale_v1_headscale_proto_rawDesc), len(file_headscale_v1_headscale_proto_rawDesc)))
})
return file_headscale_v1_headscale_proto_rawDescData
}
var file_headscale_v1_headscale_proto_msgTypes = make([]protoimpl.MessageInfo, 2)
var file_headscale_v1_headscale_proto_goTypes = []any{
(*HealthRequest)(nil), // 0: headscale.v1.HealthRequest
(*HealthResponse)(nil), // 1: headscale.v1.HealthResponse
(*CreateUserRequest)(nil), // 2: headscale.v1.CreateUserRequest
(*RenameUserRequest)(nil), // 3: headscale.v1.RenameUserRequest
(*DeleteUserRequest)(nil), // 4: headscale.v1.DeleteUserRequest
(*ListUsersRequest)(nil), // 5: headscale.v1.ListUsersRequest
(*CreatePreAuthKeyRequest)(nil), // 6: headscale.v1.CreatePreAuthKeyRequest
(*ExpirePreAuthKeyRequest)(nil), // 7: headscale.v1.ExpirePreAuthKeyRequest
(*DeletePreAuthKeyRequest)(nil), // 8: headscale.v1.DeletePreAuthKeyRequest
(*ListPreAuthKeysRequest)(nil), // 9: headscale.v1.ListPreAuthKeysRequest
(*DebugCreateNodeRequest)(nil), // 10: headscale.v1.DebugCreateNodeRequest
(*GetNodeRequest)(nil), // 11: headscale.v1.GetNodeRequest
(*SetTagsRequest)(nil), // 12: headscale.v1.SetTagsRequest
(*SetApprovedRoutesRequest)(nil), // 13: headscale.v1.SetApprovedRoutesRequest
(*RegisterNodeRequest)(nil), // 14: headscale.v1.RegisterNodeRequest
(*DeleteNodeRequest)(nil), // 15: headscale.v1.DeleteNodeRequest
(*ExpireNodeRequest)(nil), // 16: headscale.v1.ExpireNodeRequest
(*RenameNodeRequest)(nil), // 17: headscale.v1.RenameNodeRequest
(*ListNodesRequest)(nil), // 18: headscale.v1.ListNodesRequest
(*BackfillNodeIPsRequest)(nil), // 19: headscale.v1.BackfillNodeIPsRequest
(*AuthRegisterRequest)(nil), // 20: headscale.v1.AuthRegisterRequest
(*AuthApproveRequest)(nil), // 21: headscale.v1.AuthApproveRequest
(*AuthRejectRequest)(nil), // 22: headscale.v1.AuthRejectRequest
(*CreateApiKeyRequest)(nil), // 23: headscale.v1.CreateApiKeyRequest
(*ExpireApiKeyRequest)(nil), // 24: headscale.v1.ExpireApiKeyRequest
(*ListApiKeysRequest)(nil), // 25: headscale.v1.ListApiKeysRequest
(*DeleteApiKeyRequest)(nil), // 26: headscale.v1.DeleteApiKeyRequest
(*GetPolicyRequest)(nil), // 27: headscale.v1.GetPolicyRequest
(*SetPolicyRequest)(nil), // 28: headscale.v1.SetPolicyRequest
(*CheckPolicyRequest)(nil), // 29: headscale.v1.CheckPolicyRequest
(*CreateUserResponse)(nil), // 30: headscale.v1.CreateUserResponse
(*RenameUserResponse)(nil), // 31: headscale.v1.RenameUserResponse
(*DeleteUserResponse)(nil), // 32: headscale.v1.DeleteUserResponse
(*ListUsersResponse)(nil), // 33: headscale.v1.ListUsersResponse
(*CreatePreAuthKeyResponse)(nil), // 34: headscale.v1.CreatePreAuthKeyResponse
(*ExpirePreAuthKeyResponse)(nil), // 35: headscale.v1.ExpirePreAuthKeyResponse
(*DeletePreAuthKeyResponse)(nil), // 36: headscale.v1.DeletePreAuthKeyResponse
(*ListPreAuthKeysResponse)(nil), // 37: headscale.v1.ListPreAuthKeysResponse
(*DebugCreateNodeResponse)(nil), // 38: headscale.v1.DebugCreateNodeResponse
(*GetNodeResponse)(nil), // 39: headscale.v1.GetNodeResponse
(*SetTagsResponse)(nil), // 40: headscale.v1.SetTagsResponse
(*SetApprovedRoutesResponse)(nil), // 41: headscale.v1.SetApprovedRoutesResponse
(*RegisterNodeResponse)(nil), // 42: headscale.v1.RegisterNodeResponse
(*DeleteNodeResponse)(nil), // 43: headscale.v1.DeleteNodeResponse
(*ExpireNodeResponse)(nil), // 44: headscale.v1.ExpireNodeResponse
(*RenameNodeResponse)(nil), // 45: headscale.v1.RenameNodeResponse
(*ListNodesResponse)(nil), // 46: headscale.v1.ListNodesResponse
(*BackfillNodeIPsResponse)(nil), // 47: headscale.v1.BackfillNodeIPsResponse
(*AuthRegisterResponse)(nil), // 48: headscale.v1.AuthRegisterResponse
(*AuthApproveResponse)(nil), // 49: headscale.v1.AuthApproveResponse
(*AuthRejectResponse)(nil), // 50: headscale.v1.AuthRejectResponse
(*CreateApiKeyResponse)(nil), // 51: headscale.v1.CreateApiKeyResponse
(*ExpireApiKeyResponse)(nil), // 52: headscale.v1.ExpireApiKeyResponse
(*ListApiKeysResponse)(nil), // 53: headscale.v1.ListApiKeysResponse
(*DeleteApiKeyResponse)(nil), // 54: headscale.v1.DeleteApiKeyResponse
(*GetPolicyResponse)(nil), // 55: headscale.v1.GetPolicyResponse
(*SetPolicyResponse)(nil), // 56: headscale.v1.SetPolicyResponse
(*CheckPolicyResponse)(nil), // 57: headscale.v1.CheckPolicyResponse
}
var file_headscale_v1_headscale_proto_depIdxs = []int32{
2, // 0: headscale.v1.HeadscaleService.CreateUser:input_type -> headscale.v1.CreateUserRequest
3, // 1: headscale.v1.HeadscaleService.RenameUser:input_type -> headscale.v1.RenameUserRequest
4, // 2: headscale.v1.HeadscaleService.DeleteUser:input_type -> headscale.v1.DeleteUserRequest
5, // 3: headscale.v1.HeadscaleService.ListUsers:input_type -> headscale.v1.ListUsersRequest
6, // 4: headscale.v1.HeadscaleService.CreatePreAuthKey:input_type -> headscale.v1.CreatePreAuthKeyRequest
7, // 5: headscale.v1.HeadscaleService.ExpirePreAuthKey:input_type -> headscale.v1.ExpirePreAuthKeyRequest
8, // 6: headscale.v1.HeadscaleService.DeletePreAuthKey:input_type -> headscale.v1.DeletePreAuthKeyRequest
9, // 7: headscale.v1.HeadscaleService.ListPreAuthKeys:input_type -> headscale.v1.ListPreAuthKeysRequest
10, // 8: headscale.v1.HeadscaleService.DebugCreateNode:input_type -> headscale.v1.DebugCreateNodeRequest
11, // 9: headscale.v1.HeadscaleService.GetNode:input_type -> headscale.v1.GetNodeRequest
12, // 10: headscale.v1.HeadscaleService.SetTags:input_type -> headscale.v1.SetTagsRequest
13, // 11: headscale.v1.HeadscaleService.SetApprovedRoutes:input_type -> headscale.v1.SetApprovedRoutesRequest
14, // 12: headscale.v1.HeadscaleService.RegisterNode:input_type -> headscale.v1.RegisterNodeRequest
15, // 13: headscale.v1.HeadscaleService.DeleteNode:input_type -> headscale.v1.DeleteNodeRequest
16, // 14: headscale.v1.HeadscaleService.ExpireNode:input_type -> headscale.v1.ExpireNodeRequest
17, // 15: headscale.v1.HeadscaleService.RenameNode:input_type -> headscale.v1.RenameNodeRequest
18, // 16: headscale.v1.HeadscaleService.ListNodes:input_type -> headscale.v1.ListNodesRequest
19, // 17: headscale.v1.HeadscaleService.BackfillNodeIPs:input_type -> headscale.v1.BackfillNodeIPsRequest
20, // 18: headscale.v1.HeadscaleService.AuthRegister:input_type -> headscale.v1.AuthRegisterRequest
21, // 19: headscale.v1.HeadscaleService.AuthApprove:input_type -> headscale.v1.AuthApproveRequest
22, // 20: headscale.v1.HeadscaleService.AuthReject:input_type -> headscale.v1.AuthRejectRequest
23, // 21: headscale.v1.HeadscaleService.CreateApiKey:input_type -> headscale.v1.CreateApiKeyRequest
24, // 22: headscale.v1.HeadscaleService.ExpireApiKey:input_type -> headscale.v1.ExpireApiKeyRequest
25, // 23: headscale.v1.HeadscaleService.ListApiKeys:input_type -> headscale.v1.ListApiKeysRequest
26, // 24: headscale.v1.HeadscaleService.DeleteApiKey:input_type -> headscale.v1.DeleteApiKeyRequest
27, // 25: headscale.v1.HeadscaleService.GetPolicy:input_type -> headscale.v1.GetPolicyRequest
28, // 26: headscale.v1.HeadscaleService.SetPolicy:input_type -> headscale.v1.SetPolicyRequest
29, // 27: headscale.v1.HeadscaleService.CheckPolicy:input_type -> headscale.v1.CheckPolicyRequest
0, // 28: headscale.v1.HeadscaleService.Health:input_type -> headscale.v1.HealthRequest
30, // 29: headscale.v1.HeadscaleService.CreateUser:output_type -> headscale.v1.CreateUserResponse
31, // 30: headscale.v1.HeadscaleService.RenameUser:output_type -> headscale.v1.RenameUserResponse
32, // 31: headscale.v1.HeadscaleService.DeleteUser:output_type -> headscale.v1.DeleteUserResponse
33, // 32: headscale.v1.HeadscaleService.ListUsers:output_type -> headscale.v1.ListUsersResponse
34, // 33: headscale.v1.HeadscaleService.CreatePreAuthKey:output_type -> headscale.v1.CreatePreAuthKeyResponse
35, // 34: headscale.v1.HeadscaleService.ExpirePreAuthKey:output_type -> headscale.v1.ExpirePreAuthKeyResponse
36, // 35: headscale.v1.HeadscaleService.DeletePreAuthKey:output_type -> headscale.v1.DeletePreAuthKeyResponse
37, // 36: headscale.v1.HeadscaleService.ListPreAuthKeys:output_type -> headscale.v1.ListPreAuthKeysResponse
38, // 37: headscale.v1.HeadscaleService.DebugCreateNode:output_type -> headscale.v1.DebugCreateNodeResponse
39, // 38: headscale.v1.HeadscaleService.GetNode:output_type -> headscale.v1.GetNodeResponse
40, // 39: headscale.v1.HeadscaleService.SetTags:output_type -> headscale.v1.SetTagsResponse
41, // 40: headscale.v1.HeadscaleService.SetApprovedRoutes:output_type -> headscale.v1.SetApprovedRoutesResponse
42, // 41: headscale.v1.HeadscaleService.RegisterNode:output_type -> headscale.v1.RegisterNodeResponse
43, // 42: headscale.v1.HeadscaleService.DeleteNode:output_type -> headscale.v1.DeleteNodeResponse
44, // 43: headscale.v1.HeadscaleService.ExpireNode:output_type -> headscale.v1.ExpireNodeResponse
45, // 44: headscale.v1.HeadscaleService.RenameNode:output_type -> headscale.v1.RenameNodeResponse
46, // 45: headscale.v1.HeadscaleService.ListNodes:output_type -> headscale.v1.ListNodesResponse
47, // 46: headscale.v1.HeadscaleService.BackfillNodeIPs:output_type -> headscale.v1.BackfillNodeIPsResponse
48, // 47: headscale.v1.HeadscaleService.AuthRegister:output_type -> headscale.v1.AuthRegisterResponse
49, // 48: headscale.v1.HeadscaleService.AuthApprove:output_type -> headscale.v1.AuthApproveResponse
50, // 49: headscale.v1.HeadscaleService.AuthReject:output_type -> headscale.v1.AuthRejectResponse
51, // 50: headscale.v1.HeadscaleService.CreateApiKey:output_type -> headscale.v1.CreateApiKeyResponse
52, // 51: headscale.v1.HeadscaleService.ExpireApiKey:output_type -> headscale.v1.ExpireApiKeyResponse
53, // 52: headscale.v1.HeadscaleService.ListApiKeys:output_type -> headscale.v1.ListApiKeysResponse
54, // 53: headscale.v1.HeadscaleService.DeleteApiKey:output_type -> headscale.v1.DeleteApiKeyResponse
55, // 54: headscale.v1.HeadscaleService.GetPolicy:output_type -> headscale.v1.GetPolicyResponse
56, // 55: headscale.v1.HeadscaleService.SetPolicy:output_type -> headscale.v1.SetPolicyResponse
57, // 56: headscale.v1.HeadscaleService.CheckPolicy:output_type -> headscale.v1.CheckPolicyResponse
1, // 57: headscale.v1.HeadscaleService.Health:output_type -> headscale.v1.HealthResponse
29, // [29:58] is the sub-list for method output_type
0, // [0:29] is the sub-list for method input_type
0, // [0:0] is the sub-list for extension type_name
0, // [0:0] is the sub-list for extension extendee
0, // [0:0] is the sub-list for field type_name
}
func init() { file_headscale_v1_headscale_proto_init() }
func file_headscale_v1_headscale_proto_init() {
if File_headscale_v1_headscale_proto != nil {
return
}
file_headscale_v1_user_proto_init()
file_headscale_v1_preauthkey_proto_init()
file_headscale_v1_node_proto_init()
file_headscale_v1_apikey_proto_init()
file_headscale_v1_auth_proto_init()
file_headscale_v1_policy_proto_init()
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_headscale_v1_headscale_proto_rawDesc), len(file_headscale_v1_headscale_proto_rawDesc)),
NumEnums: 0,
NumMessages: 2,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_headscale_v1_headscale_proto_goTypes,
DependencyIndexes: file_headscale_v1_headscale_proto_depIdxs,
MessageInfos: file_headscale_v1_headscale_proto_msgTypes,
}.Build()
File_headscale_v1_headscale_proto = out.File
file_headscale_v1_headscale_proto_goTypes = nil
file_headscale_v1_headscale_proto_depIdxs = nil
}

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

View file

@ -1,363 +0,0 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc (unknown)
// source: headscale/v1/policy.proto
package v1
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
timestamppb "google.golang.org/protobuf/types/known/timestamppb"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type SetPolicyRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Policy string `protobuf:"bytes,1,opt,name=policy,proto3" json:"policy,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *SetPolicyRequest) Reset() {
*x = SetPolicyRequest{}
mi := &file_headscale_v1_policy_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *SetPolicyRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*SetPolicyRequest) ProtoMessage() {}
func (x *SetPolicyRequest) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_policy_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use SetPolicyRequest.ProtoReflect.Descriptor instead.
func (*SetPolicyRequest) Descriptor() ([]byte, []int) {
return file_headscale_v1_policy_proto_rawDescGZIP(), []int{0}
}
func (x *SetPolicyRequest) GetPolicy() string {
if x != nil {
return x.Policy
}
return ""
}
type SetPolicyResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Policy string `protobuf:"bytes,1,opt,name=policy,proto3" json:"policy,omitempty"`
UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *SetPolicyResponse) Reset() {
*x = SetPolicyResponse{}
mi := &file_headscale_v1_policy_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *SetPolicyResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*SetPolicyResponse) ProtoMessage() {}
func (x *SetPolicyResponse) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_policy_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use SetPolicyResponse.ProtoReflect.Descriptor instead.
func (*SetPolicyResponse) Descriptor() ([]byte, []int) {
return file_headscale_v1_policy_proto_rawDescGZIP(), []int{1}
}
func (x *SetPolicyResponse) GetPolicy() string {
if x != nil {
return x.Policy
}
return ""
}
func (x *SetPolicyResponse) GetUpdatedAt() *timestamppb.Timestamp {
if x != nil {
return x.UpdatedAt
}
return nil
}
type GetPolicyRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetPolicyRequest) Reset() {
*x = GetPolicyRequest{}
mi := &file_headscale_v1_policy_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetPolicyRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetPolicyRequest) ProtoMessage() {}
func (x *GetPolicyRequest) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_policy_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetPolicyRequest.ProtoReflect.Descriptor instead.
func (*GetPolicyRequest) Descriptor() ([]byte, []int) {
return file_headscale_v1_policy_proto_rawDescGZIP(), []int{2}
}
type GetPolicyResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Policy string `protobuf:"bytes,1,opt,name=policy,proto3" json:"policy,omitempty"`
UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetPolicyResponse) Reset() {
*x = GetPolicyResponse{}
mi := &file_headscale_v1_policy_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetPolicyResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetPolicyResponse) ProtoMessage() {}
func (x *GetPolicyResponse) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_policy_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetPolicyResponse.ProtoReflect.Descriptor instead.
func (*GetPolicyResponse) Descriptor() ([]byte, []int) {
return file_headscale_v1_policy_proto_rawDescGZIP(), []int{3}
}
func (x *GetPolicyResponse) GetPolicy() string {
if x != nil {
return x.Policy
}
return ""
}
func (x *GetPolicyResponse) GetUpdatedAt() *timestamppb.Timestamp {
if x != nil {
return x.UpdatedAt
}
return nil
}
type CheckPolicyRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Policy string `protobuf:"bytes,1,opt,name=policy,proto3" json:"policy,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CheckPolicyRequest) Reset() {
*x = CheckPolicyRequest{}
mi := &file_headscale_v1_policy_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CheckPolicyRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CheckPolicyRequest) ProtoMessage() {}
func (x *CheckPolicyRequest) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_policy_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CheckPolicyRequest.ProtoReflect.Descriptor instead.
func (*CheckPolicyRequest) Descriptor() ([]byte, []int) {
return file_headscale_v1_policy_proto_rawDescGZIP(), []int{4}
}
func (x *CheckPolicyRequest) GetPolicy() string {
if x != nil {
return x.Policy
}
return ""
}
type CheckPolicyResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CheckPolicyResponse) Reset() {
*x = CheckPolicyResponse{}
mi := &file_headscale_v1_policy_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CheckPolicyResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CheckPolicyResponse) ProtoMessage() {}
func (x *CheckPolicyResponse) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_policy_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CheckPolicyResponse.ProtoReflect.Descriptor instead.
func (*CheckPolicyResponse) Descriptor() ([]byte, []int) {
return file_headscale_v1_policy_proto_rawDescGZIP(), []int{5}
}
var File_headscale_v1_policy_proto protoreflect.FileDescriptor
const file_headscale_v1_policy_proto_rawDesc = "" +
"\n" +
"\x19headscale/v1/policy.proto\x12\fheadscale.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"*\n" +
"\x10SetPolicyRequest\x12\x16\n" +
"\x06policy\x18\x01 \x01(\tR\x06policy\"f\n" +
"\x11SetPolicyResponse\x12\x16\n" +
"\x06policy\x18\x01 \x01(\tR\x06policy\x129\n" +
"\n" +
"updated_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\"\x12\n" +
"\x10GetPolicyRequest\"f\n" +
"\x11GetPolicyResponse\x12\x16\n" +
"\x06policy\x18\x01 \x01(\tR\x06policy\x129\n" +
"\n" +
"updated_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\",\n" +
"\x12CheckPolicyRequest\x12\x16\n" +
"\x06policy\x18\x01 \x01(\tR\x06policy\"\x15\n" +
"\x13CheckPolicyResponseB)Z'github.com/juanfont/headscale/gen/go/v1b\x06proto3"
var (
file_headscale_v1_policy_proto_rawDescOnce sync.Once
file_headscale_v1_policy_proto_rawDescData []byte
)
func file_headscale_v1_policy_proto_rawDescGZIP() []byte {
file_headscale_v1_policy_proto_rawDescOnce.Do(func() {
file_headscale_v1_policy_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_headscale_v1_policy_proto_rawDesc), len(file_headscale_v1_policy_proto_rawDesc)))
})
return file_headscale_v1_policy_proto_rawDescData
}
var file_headscale_v1_policy_proto_msgTypes = make([]protoimpl.MessageInfo, 6)
var file_headscale_v1_policy_proto_goTypes = []any{
(*SetPolicyRequest)(nil), // 0: headscale.v1.SetPolicyRequest
(*SetPolicyResponse)(nil), // 1: headscale.v1.SetPolicyResponse
(*GetPolicyRequest)(nil), // 2: headscale.v1.GetPolicyRequest
(*GetPolicyResponse)(nil), // 3: headscale.v1.GetPolicyResponse
(*CheckPolicyRequest)(nil), // 4: headscale.v1.CheckPolicyRequest
(*CheckPolicyResponse)(nil), // 5: headscale.v1.CheckPolicyResponse
(*timestamppb.Timestamp)(nil), // 6: google.protobuf.Timestamp
}
var file_headscale_v1_policy_proto_depIdxs = []int32{
6, // 0: headscale.v1.SetPolicyResponse.updated_at:type_name -> google.protobuf.Timestamp
6, // 1: headscale.v1.GetPolicyResponse.updated_at:type_name -> google.protobuf.Timestamp
2, // [2:2] is the sub-list for method output_type
2, // [2:2] is the sub-list for method input_type
2, // [2:2] is the sub-list for extension type_name
2, // [2:2] is the sub-list for extension extendee
0, // [0:2] is the sub-list for field type_name
}
func init() { file_headscale_v1_policy_proto_init() }
func file_headscale_v1_policy_proto_init() {
if File_headscale_v1_policy_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_headscale_v1_policy_proto_rawDesc), len(file_headscale_v1_policy_proto_rawDesc)),
NumEnums: 0,
NumMessages: 6,
NumExtensions: 0,
NumServices: 0,
},
GoTypes: file_headscale_v1_policy_proto_goTypes,
DependencyIndexes: file_headscale_v1_policy_proto_depIdxs,
MessageInfos: file_headscale_v1_policy_proto_msgTypes,
}.Build()
File_headscale_v1_policy_proto = out.File
file_headscale_v1_policy_proto_goTypes = nil
file_headscale_v1_policy_proto_depIdxs = nil
}

View file

@ -1,596 +0,0 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc (unknown)
// source: headscale/v1/preauthkey.proto
package v1
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
timestamppb "google.golang.org/protobuf/types/known/timestamppb"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type PreAuthKey struct {
state protoimpl.MessageState `protogen:"open.v1"`
User *User `protobuf:"bytes,1,opt,name=user,proto3" json:"user,omitempty"`
Id uint64 `protobuf:"varint,2,opt,name=id,proto3" json:"id,omitempty"`
Key string `protobuf:"bytes,3,opt,name=key,proto3" json:"key,omitempty"`
Reusable bool `protobuf:"varint,4,opt,name=reusable,proto3" json:"reusable,omitempty"`
Ephemeral bool `protobuf:"varint,5,opt,name=ephemeral,proto3" json:"ephemeral,omitempty"`
Used bool `protobuf:"varint,6,opt,name=used,proto3" json:"used,omitempty"`
Expiration *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=expiration,proto3" json:"expiration,omitempty"`
CreatedAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
AclTags []string `protobuf:"bytes,9,rep,name=acl_tags,json=aclTags,proto3" json:"acl_tags,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *PreAuthKey) Reset() {
*x = PreAuthKey{}
mi := &file_headscale_v1_preauthkey_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *PreAuthKey) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*PreAuthKey) ProtoMessage() {}
func (x *PreAuthKey) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_preauthkey_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use PreAuthKey.ProtoReflect.Descriptor instead.
func (*PreAuthKey) Descriptor() ([]byte, []int) {
return file_headscale_v1_preauthkey_proto_rawDescGZIP(), []int{0}
}
func (x *PreAuthKey) GetUser() *User {
if x != nil {
return x.User
}
return nil
}
func (x *PreAuthKey) GetId() uint64 {
if x != nil {
return x.Id
}
return 0
}
func (x *PreAuthKey) GetKey() string {
if x != nil {
return x.Key
}
return ""
}
func (x *PreAuthKey) GetReusable() bool {
if x != nil {
return x.Reusable
}
return false
}
func (x *PreAuthKey) GetEphemeral() bool {
if x != nil {
return x.Ephemeral
}
return false
}
func (x *PreAuthKey) GetUsed() bool {
if x != nil {
return x.Used
}
return false
}
func (x *PreAuthKey) GetExpiration() *timestamppb.Timestamp {
if x != nil {
return x.Expiration
}
return nil
}
func (x *PreAuthKey) GetCreatedAt() *timestamppb.Timestamp {
if x != nil {
return x.CreatedAt
}
return nil
}
func (x *PreAuthKey) GetAclTags() []string {
if x != nil {
return x.AclTags
}
return nil
}
type CreatePreAuthKeyRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
User uint64 `protobuf:"varint,1,opt,name=user,proto3" json:"user,omitempty"`
Reusable bool `protobuf:"varint,2,opt,name=reusable,proto3" json:"reusable,omitempty"`
Ephemeral bool `protobuf:"varint,3,opt,name=ephemeral,proto3" json:"ephemeral,omitempty"`
Expiration *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=expiration,proto3" json:"expiration,omitempty"`
AclTags []string `protobuf:"bytes,5,rep,name=acl_tags,json=aclTags,proto3" json:"acl_tags,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CreatePreAuthKeyRequest) Reset() {
*x = CreatePreAuthKeyRequest{}
mi := &file_headscale_v1_preauthkey_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CreatePreAuthKeyRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CreatePreAuthKeyRequest) ProtoMessage() {}
func (x *CreatePreAuthKeyRequest) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_preauthkey_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CreatePreAuthKeyRequest.ProtoReflect.Descriptor instead.
func (*CreatePreAuthKeyRequest) Descriptor() ([]byte, []int) {
return file_headscale_v1_preauthkey_proto_rawDescGZIP(), []int{1}
}
func (x *CreatePreAuthKeyRequest) GetUser() uint64 {
if x != nil {
return x.User
}
return 0
}
func (x *CreatePreAuthKeyRequest) GetReusable() bool {
if x != nil {
return x.Reusable
}
return false
}
func (x *CreatePreAuthKeyRequest) GetEphemeral() bool {
if x != nil {
return x.Ephemeral
}
return false
}
func (x *CreatePreAuthKeyRequest) GetExpiration() *timestamppb.Timestamp {
if x != nil {
return x.Expiration
}
return nil
}
func (x *CreatePreAuthKeyRequest) GetAclTags() []string {
if x != nil {
return x.AclTags
}
return nil
}
type CreatePreAuthKeyResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
PreAuthKey *PreAuthKey `protobuf:"bytes,1,opt,name=pre_auth_key,json=preAuthKey,proto3" json:"pre_auth_key,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CreatePreAuthKeyResponse) Reset() {
*x = CreatePreAuthKeyResponse{}
mi := &file_headscale_v1_preauthkey_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CreatePreAuthKeyResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CreatePreAuthKeyResponse) ProtoMessage() {}
func (x *CreatePreAuthKeyResponse) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_preauthkey_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CreatePreAuthKeyResponse.ProtoReflect.Descriptor instead.
func (*CreatePreAuthKeyResponse) Descriptor() ([]byte, []int) {
return file_headscale_v1_preauthkey_proto_rawDescGZIP(), []int{2}
}
func (x *CreatePreAuthKeyResponse) GetPreAuthKey() *PreAuthKey {
if x != nil {
return x.PreAuthKey
}
return nil
}
type ExpirePreAuthKeyRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id uint64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ExpirePreAuthKeyRequest) Reset() {
*x = ExpirePreAuthKeyRequest{}
mi := &file_headscale_v1_preauthkey_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ExpirePreAuthKeyRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ExpirePreAuthKeyRequest) ProtoMessage() {}
func (x *ExpirePreAuthKeyRequest) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_preauthkey_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ExpirePreAuthKeyRequest.ProtoReflect.Descriptor instead.
func (*ExpirePreAuthKeyRequest) Descriptor() ([]byte, []int) {
return file_headscale_v1_preauthkey_proto_rawDescGZIP(), []int{3}
}
func (x *ExpirePreAuthKeyRequest) GetId() uint64 {
if x != nil {
return x.Id
}
return 0
}
type ExpirePreAuthKeyResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ExpirePreAuthKeyResponse) Reset() {
*x = ExpirePreAuthKeyResponse{}
mi := &file_headscale_v1_preauthkey_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ExpirePreAuthKeyResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ExpirePreAuthKeyResponse) ProtoMessage() {}
func (x *ExpirePreAuthKeyResponse) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_preauthkey_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ExpirePreAuthKeyResponse.ProtoReflect.Descriptor instead.
func (*ExpirePreAuthKeyResponse) Descriptor() ([]byte, []int) {
return file_headscale_v1_preauthkey_proto_rawDescGZIP(), []int{4}
}
type DeletePreAuthKeyRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id uint64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DeletePreAuthKeyRequest) Reset() {
*x = DeletePreAuthKeyRequest{}
mi := &file_headscale_v1_preauthkey_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DeletePreAuthKeyRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DeletePreAuthKeyRequest) ProtoMessage() {}
func (x *DeletePreAuthKeyRequest) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_preauthkey_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use DeletePreAuthKeyRequest.ProtoReflect.Descriptor instead.
func (*DeletePreAuthKeyRequest) Descriptor() ([]byte, []int) {
return file_headscale_v1_preauthkey_proto_rawDescGZIP(), []int{5}
}
func (x *DeletePreAuthKeyRequest) GetId() uint64 {
if x != nil {
return x.Id
}
return 0
}
type DeletePreAuthKeyResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DeletePreAuthKeyResponse) Reset() {
*x = DeletePreAuthKeyResponse{}
mi := &file_headscale_v1_preauthkey_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DeletePreAuthKeyResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DeletePreAuthKeyResponse) ProtoMessage() {}
func (x *DeletePreAuthKeyResponse) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_preauthkey_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use DeletePreAuthKeyResponse.ProtoReflect.Descriptor instead.
func (*DeletePreAuthKeyResponse) Descriptor() ([]byte, []int) {
return file_headscale_v1_preauthkey_proto_rawDescGZIP(), []int{6}
}
type ListPreAuthKeysRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ListPreAuthKeysRequest) Reset() {
*x = ListPreAuthKeysRequest{}
mi := &file_headscale_v1_preauthkey_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ListPreAuthKeysRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ListPreAuthKeysRequest) ProtoMessage() {}
func (x *ListPreAuthKeysRequest) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_preauthkey_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ListPreAuthKeysRequest.ProtoReflect.Descriptor instead.
func (*ListPreAuthKeysRequest) Descriptor() ([]byte, []int) {
return file_headscale_v1_preauthkey_proto_rawDescGZIP(), []int{7}
}
type ListPreAuthKeysResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
PreAuthKeys []*PreAuthKey `protobuf:"bytes,1,rep,name=pre_auth_keys,json=preAuthKeys,proto3" json:"pre_auth_keys,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ListPreAuthKeysResponse) Reset() {
*x = ListPreAuthKeysResponse{}
mi := &file_headscale_v1_preauthkey_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ListPreAuthKeysResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ListPreAuthKeysResponse) ProtoMessage() {}
func (x *ListPreAuthKeysResponse) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_preauthkey_proto_msgTypes[8]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ListPreAuthKeysResponse.ProtoReflect.Descriptor instead.
func (*ListPreAuthKeysResponse) Descriptor() ([]byte, []int) {
return file_headscale_v1_preauthkey_proto_rawDescGZIP(), []int{8}
}
func (x *ListPreAuthKeysResponse) GetPreAuthKeys() []*PreAuthKey {
if x != nil {
return x.PreAuthKeys
}
return nil
}
var File_headscale_v1_preauthkey_proto protoreflect.FileDescriptor
const file_headscale_v1_preauthkey_proto_rawDesc = "" +
"\n" +
"\x1dheadscale/v1/preauthkey.proto\x12\fheadscale.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x17headscale/v1/user.proto\"\xb6\x02\n" +
"\n" +
"PreAuthKey\x12&\n" +
"\x04user\x18\x01 \x01(\v2\x12.headscale.v1.UserR\x04user\x12\x0e\n" +
"\x02id\x18\x02 \x01(\x04R\x02id\x12\x10\n" +
"\x03key\x18\x03 \x01(\tR\x03key\x12\x1a\n" +
"\breusable\x18\x04 \x01(\bR\breusable\x12\x1c\n" +
"\tephemeral\x18\x05 \x01(\bR\tephemeral\x12\x12\n" +
"\x04used\x18\x06 \x01(\bR\x04used\x12:\n" +
"\n" +
"expiration\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\n" +
"expiration\x129\n" +
"\n" +
"created_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12\x19\n" +
"\bacl_tags\x18\t \x03(\tR\aaclTags\"\xbe\x01\n" +
"\x17CreatePreAuthKeyRequest\x12\x12\n" +
"\x04user\x18\x01 \x01(\x04R\x04user\x12\x1a\n" +
"\breusable\x18\x02 \x01(\bR\breusable\x12\x1c\n" +
"\tephemeral\x18\x03 \x01(\bR\tephemeral\x12:\n" +
"\n" +
"expiration\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\n" +
"expiration\x12\x19\n" +
"\bacl_tags\x18\x05 \x03(\tR\aaclTags\"V\n" +
"\x18CreatePreAuthKeyResponse\x12:\n" +
"\fpre_auth_key\x18\x01 \x01(\v2\x18.headscale.v1.PreAuthKeyR\n" +
"preAuthKey\")\n" +
"\x17ExpirePreAuthKeyRequest\x12\x0e\n" +
"\x02id\x18\x01 \x01(\x04R\x02id\"\x1a\n" +
"\x18ExpirePreAuthKeyResponse\")\n" +
"\x17DeletePreAuthKeyRequest\x12\x0e\n" +
"\x02id\x18\x01 \x01(\x04R\x02id\"\x1a\n" +
"\x18DeletePreAuthKeyResponse\"\x18\n" +
"\x16ListPreAuthKeysRequest\"W\n" +
"\x17ListPreAuthKeysResponse\x12<\n" +
"\rpre_auth_keys\x18\x01 \x03(\v2\x18.headscale.v1.PreAuthKeyR\vpreAuthKeysB)Z'github.com/juanfont/headscale/gen/go/v1b\x06proto3"
var (
file_headscale_v1_preauthkey_proto_rawDescOnce sync.Once
file_headscale_v1_preauthkey_proto_rawDescData []byte
)
func file_headscale_v1_preauthkey_proto_rawDescGZIP() []byte {
file_headscale_v1_preauthkey_proto_rawDescOnce.Do(func() {
file_headscale_v1_preauthkey_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_headscale_v1_preauthkey_proto_rawDesc), len(file_headscale_v1_preauthkey_proto_rawDesc)))
})
return file_headscale_v1_preauthkey_proto_rawDescData
}
var file_headscale_v1_preauthkey_proto_msgTypes = make([]protoimpl.MessageInfo, 9)
var file_headscale_v1_preauthkey_proto_goTypes = []any{
(*PreAuthKey)(nil), // 0: headscale.v1.PreAuthKey
(*CreatePreAuthKeyRequest)(nil), // 1: headscale.v1.CreatePreAuthKeyRequest
(*CreatePreAuthKeyResponse)(nil), // 2: headscale.v1.CreatePreAuthKeyResponse
(*ExpirePreAuthKeyRequest)(nil), // 3: headscale.v1.ExpirePreAuthKeyRequest
(*ExpirePreAuthKeyResponse)(nil), // 4: headscale.v1.ExpirePreAuthKeyResponse
(*DeletePreAuthKeyRequest)(nil), // 5: headscale.v1.DeletePreAuthKeyRequest
(*DeletePreAuthKeyResponse)(nil), // 6: headscale.v1.DeletePreAuthKeyResponse
(*ListPreAuthKeysRequest)(nil), // 7: headscale.v1.ListPreAuthKeysRequest
(*ListPreAuthKeysResponse)(nil), // 8: headscale.v1.ListPreAuthKeysResponse
(*User)(nil), // 9: headscale.v1.User
(*timestamppb.Timestamp)(nil), // 10: google.protobuf.Timestamp
}
var file_headscale_v1_preauthkey_proto_depIdxs = []int32{
9, // 0: headscale.v1.PreAuthKey.user:type_name -> headscale.v1.User
10, // 1: headscale.v1.PreAuthKey.expiration:type_name -> google.protobuf.Timestamp
10, // 2: headscale.v1.PreAuthKey.created_at:type_name -> google.protobuf.Timestamp
10, // 3: headscale.v1.CreatePreAuthKeyRequest.expiration:type_name -> google.protobuf.Timestamp
0, // 4: headscale.v1.CreatePreAuthKeyResponse.pre_auth_key:type_name -> headscale.v1.PreAuthKey
0, // 5: headscale.v1.ListPreAuthKeysResponse.pre_auth_keys:type_name -> headscale.v1.PreAuthKey
6, // [6:6] is the sub-list for method output_type
6, // [6:6] is the sub-list for method input_type
6, // [6:6] is the sub-list for extension type_name
6, // [6:6] is the sub-list for extension extendee
0, // [0:6] is the sub-list for field type_name
}
func init() { file_headscale_v1_preauthkey_proto_init() }
func file_headscale_v1_preauthkey_proto_init() {
if File_headscale_v1_preauthkey_proto != nil {
return
}
file_headscale_v1_user_proto_init()
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_headscale_v1_preauthkey_proto_rawDesc), len(file_headscale_v1_preauthkey_proto_rawDesc)),
NumEnums: 0,
NumMessages: 9,
NumExtensions: 0,
NumServices: 0,
},
GoTypes: file_headscale_v1_preauthkey_proto_goTypes,
DependencyIndexes: file_headscale_v1_preauthkey_proto_depIdxs,
MessageInfos: file_headscale_v1_preauthkey_proto_msgTypes,
}.Build()
File_headscale_v1_preauthkey_proto = out.File
file_headscale_v1_preauthkey_proto_goTypes = nil
file_headscale_v1_preauthkey_proto_depIdxs = nil
}

View file

@ -1,615 +0,0 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc (unknown)
// source: headscale/v1/user.proto
package v1
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
timestamppb "google.golang.org/protobuf/types/known/timestamppb"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type User struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id uint64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
CreatedAt *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
DisplayName string `protobuf:"bytes,4,opt,name=display_name,json=displayName,proto3" json:"display_name,omitempty"`
Email string `protobuf:"bytes,5,opt,name=email,proto3" json:"email,omitempty"`
ProviderId string `protobuf:"bytes,6,opt,name=provider_id,json=providerId,proto3" json:"provider_id,omitempty"`
Provider string `protobuf:"bytes,7,opt,name=provider,proto3" json:"provider,omitempty"`
ProfilePicUrl string `protobuf:"bytes,8,opt,name=profile_pic_url,json=profilePicUrl,proto3" json:"profile_pic_url,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *User) Reset() {
*x = User{}
mi := &file_headscale_v1_user_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *User) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*User) ProtoMessage() {}
func (x *User) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_user_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use User.ProtoReflect.Descriptor instead.
func (*User) Descriptor() ([]byte, []int) {
return file_headscale_v1_user_proto_rawDescGZIP(), []int{0}
}
func (x *User) GetId() uint64 {
if x != nil {
return x.Id
}
return 0
}
func (x *User) GetName() string {
if x != nil {
return x.Name
}
return ""
}
func (x *User) GetCreatedAt() *timestamppb.Timestamp {
if x != nil {
return x.CreatedAt
}
return nil
}
func (x *User) GetDisplayName() string {
if x != nil {
return x.DisplayName
}
return ""
}
func (x *User) GetEmail() string {
if x != nil {
return x.Email
}
return ""
}
func (x *User) GetProviderId() string {
if x != nil {
return x.ProviderId
}
return ""
}
func (x *User) GetProvider() string {
if x != nil {
return x.Provider
}
return ""
}
func (x *User) GetProfilePicUrl() string {
if x != nil {
return x.ProfilePicUrl
}
return ""
}
type CreateUserRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
DisplayName string `protobuf:"bytes,2,opt,name=display_name,json=displayName,proto3" json:"display_name,omitempty"`
Email string `protobuf:"bytes,3,opt,name=email,proto3" json:"email,omitempty"`
PictureUrl string `protobuf:"bytes,4,opt,name=picture_url,json=pictureUrl,proto3" json:"picture_url,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CreateUserRequest) Reset() {
*x = CreateUserRequest{}
mi := &file_headscale_v1_user_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CreateUserRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CreateUserRequest) ProtoMessage() {}
func (x *CreateUserRequest) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_user_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CreateUserRequest.ProtoReflect.Descriptor instead.
func (*CreateUserRequest) Descriptor() ([]byte, []int) {
return file_headscale_v1_user_proto_rawDescGZIP(), []int{1}
}
func (x *CreateUserRequest) GetName() string {
if x != nil {
return x.Name
}
return ""
}
func (x *CreateUserRequest) GetDisplayName() string {
if x != nil {
return x.DisplayName
}
return ""
}
func (x *CreateUserRequest) GetEmail() string {
if x != nil {
return x.Email
}
return ""
}
func (x *CreateUserRequest) GetPictureUrl() string {
if x != nil {
return x.PictureUrl
}
return ""
}
type CreateUserResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
User *User `protobuf:"bytes,1,opt,name=user,proto3" json:"user,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CreateUserResponse) Reset() {
*x = CreateUserResponse{}
mi := &file_headscale_v1_user_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CreateUserResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CreateUserResponse) ProtoMessage() {}
func (x *CreateUserResponse) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_user_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CreateUserResponse.ProtoReflect.Descriptor instead.
func (*CreateUserResponse) Descriptor() ([]byte, []int) {
return file_headscale_v1_user_proto_rawDescGZIP(), []int{2}
}
func (x *CreateUserResponse) GetUser() *User {
if x != nil {
return x.User
}
return nil
}
type RenameUserRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
OldId uint64 `protobuf:"varint,1,opt,name=old_id,json=oldId,proto3" json:"old_id,omitempty"`
NewName string `protobuf:"bytes,2,opt,name=new_name,json=newName,proto3" json:"new_name,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *RenameUserRequest) Reset() {
*x = RenameUserRequest{}
mi := &file_headscale_v1_user_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RenameUserRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*RenameUserRequest) ProtoMessage() {}
func (x *RenameUserRequest) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_user_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use RenameUserRequest.ProtoReflect.Descriptor instead.
func (*RenameUserRequest) Descriptor() ([]byte, []int) {
return file_headscale_v1_user_proto_rawDescGZIP(), []int{3}
}
func (x *RenameUserRequest) GetOldId() uint64 {
if x != nil {
return x.OldId
}
return 0
}
func (x *RenameUserRequest) GetNewName() string {
if x != nil {
return x.NewName
}
return ""
}
type RenameUserResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
User *User `protobuf:"bytes,1,opt,name=user,proto3" json:"user,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *RenameUserResponse) Reset() {
*x = RenameUserResponse{}
mi := &file_headscale_v1_user_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RenameUserResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*RenameUserResponse) ProtoMessage() {}
func (x *RenameUserResponse) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_user_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use RenameUserResponse.ProtoReflect.Descriptor instead.
func (*RenameUserResponse) Descriptor() ([]byte, []int) {
return file_headscale_v1_user_proto_rawDescGZIP(), []int{4}
}
func (x *RenameUserResponse) GetUser() *User {
if x != nil {
return x.User
}
return nil
}
type DeleteUserRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id uint64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DeleteUserRequest) Reset() {
*x = DeleteUserRequest{}
mi := &file_headscale_v1_user_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DeleteUserRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DeleteUserRequest) ProtoMessage() {}
func (x *DeleteUserRequest) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_user_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use DeleteUserRequest.ProtoReflect.Descriptor instead.
func (*DeleteUserRequest) Descriptor() ([]byte, []int) {
return file_headscale_v1_user_proto_rawDescGZIP(), []int{5}
}
func (x *DeleteUserRequest) GetId() uint64 {
if x != nil {
return x.Id
}
return 0
}
type DeleteUserResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DeleteUserResponse) Reset() {
*x = DeleteUserResponse{}
mi := &file_headscale_v1_user_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DeleteUserResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DeleteUserResponse) ProtoMessage() {}
func (x *DeleteUserResponse) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_user_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use DeleteUserResponse.ProtoReflect.Descriptor instead.
func (*DeleteUserResponse) Descriptor() ([]byte, []int) {
return file_headscale_v1_user_proto_rawDescGZIP(), []int{6}
}
type ListUsersRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id uint64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
Email string `protobuf:"bytes,3,opt,name=email,proto3" json:"email,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ListUsersRequest) Reset() {
*x = ListUsersRequest{}
mi := &file_headscale_v1_user_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ListUsersRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ListUsersRequest) ProtoMessage() {}
func (x *ListUsersRequest) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_user_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ListUsersRequest.ProtoReflect.Descriptor instead.
func (*ListUsersRequest) Descriptor() ([]byte, []int) {
return file_headscale_v1_user_proto_rawDescGZIP(), []int{7}
}
func (x *ListUsersRequest) GetId() uint64 {
if x != nil {
return x.Id
}
return 0
}
func (x *ListUsersRequest) GetName() string {
if x != nil {
return x.Name
}
return ""
}
func (x *ListUsersRequest) GetEmail() string {
if x != nil {
return x.Email
}
return ""
}
type ListUsersResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Users []*User `protobuf:"bytes,1,rep,name=users,proto3" json:"users,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ListUsersResponse) Reset() {
*x = ListUsersResponse{}
mi := &file_headscale_v1_user_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ListUsersResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ListUsersResponse) ProtoMessage() {}
func (x *ListUsersResponse) ProtoReflect() protoreflect.Message {
mi := &file_headscale_v1_user_proto_msgTypes[8]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ListUsersResponse.ProtoReflect.Descriptor instead.
func (*ListUsersResponse) Descriptor() ([]byte, []int) {
return file_headscale_v1_user_proto_rawDescGZIP(), []int{8}
}
func (x *ListUsersResponse) GetUsers() []*User {
if x != nil {
return x.Users
}
return nil
}
var File_headscale_v1_user_proto protoreflect.FileDescriptor
const file_headscale_v1_user_proto_rawDesc = "" +
"\n" +
"\x17headscale/v1/user.proto\x12\fheadscale.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\x83\x02\n" +
"\x04User\x12\x0e\n" +
"\x02id\x18\x01 \x01(\x04R\x02id\x12\x12\n" +
"\x04name\x18\x02 \x01(\tR\x04name\x129\n" +
"\n" +
"created_at\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12!\n" +
"\fdisplay_name\x18\x04 \x01(\tR\vdisplayName\x12\x14\n" +
"\x05email\x18\x05 \x01(\tR\x05email\x12\x1f\n" +
"\vprovider_id\x18\x06 \x01(\tR\n" +
"providerId\x12\x1a\n" +
"\bprovider\x18\a \x01(\tR\bprovider\x12&\n" +
"\x0fprofile_pic_url\x18\b \x01(\tR\rprofilePicUrl\"\x81\x01\n" +
"\x11CreateUserRequest\x12\x12\n" +
"\x04name\x18\x01 \x01(\tR\x04name\x12!\n" +
"\fdisplay_name\x18\x02 \x01(\tR\vdisplayName\x12\x14\n" +
"\x05email\x18\x03 \x01(\tR\x05email\x12\x1f\n" +
"\vpicture_url\x18\x04 \x01(\tR\n" +
"pictureUrl\"<\n" +
"\x12CreateUserResponse\x12&\n" +
"\x04user\x18\x01 \x01(\v2\x12.headscale.v1.UserR\x04user\"E\n" +
"\x11RenameUserRequest\x12\x15\n" +
"\x06old_id\x18\x01 \x01(\x04R\x05oldId\x12\x19\n" +
"\bnew_name\x18\x02 \x01(\tR\anewName\"<\n" +
"\x12RenameUserResponse\x12&\n" +
"\x04user\x18\x01 \x01(\v2\x12.headscale.v1.UserR\x04user\"#\n" +
"\x11DeleteUserRequest\x12\x0e\n" +
"\x02id\x18\x01 \x01(\x04R\x02id\"\x14\n" +
"\x12DeleteUserResponse\"L\n" +
"\x10ListUsersRequest\x12\x0e\n" +
"\x02id\x18\x01 \x01(\x04R\x02id\x12\x12\n" +
"\x04name\x18\x02 \x01(\tR\x04name\x12\x14\n" +
"\x05email\x18\x03 \x01(\tR\x05email\"=\n" +
"\x11ListUsersResponse\x12(\n" +
"\x05users\x18\x01 \x03(\v2\x12.headscale.v1.UserR\x05usersB)Z'github.com/juanfont/headscale/gen/go/v1b\x06proto3"
var (
file_headscale_v1_user_proto_rawDescOnce sync.Once
file_headscale_v1_user_proto_rawDescData []byte
)
func file_headscale_v1_user_proto_rawDescGZIP() []byte {
file_headscale_v1_user_proto_rawDescOnce.Do(func() {
file_headscale_v1_user_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_headscale_v1_user_proto_rawDesc), len(file_headscale_v1_user_proto_rawDesc)))
})
return file_headscale_v1_user_proto_rawDescData
}
var file_headscale_v1_user_proto_msgTypes = make([]protoimpl.MessageInfo, 9)
var file_headscale_v1_user_proto_goTypes = []any{
(*User)(nil), // 0: headscale.v1.User
(*CreateUserRequest)(nil), // 1: headscale.v1.CreateUserRequest
(*CreateUserResponse)(nil), // 2: headscale.v1.CreateUserResponse
(*RenameUserRequest)(nil), // 3: headscale.v1.RenameUserRequest
(*RenameUserResponse)(nil), // 4: headscale.v1.RenameUserResponse
(*DeleteUserRequest)(nil), // 5: headscale.v1.DeleteUserRequest
(*DeleteUserResponse)(nil), // 6: headscale.v1.DeleteUserResponse
(*ListUsersRequest)(nil), // 7: headscale.v1.ListUsersRequest
(*ListUsersResponse)(nil), // 8: headscale.v1.ListUsersResponse
(*timestamppb.Timestamp)(nil), // 9: google.protobuf.Timestamp
}
var file_headscale_v1_user_proto_depIdxs = []int32{
9, // 0: headscale.v1.User.created_at:type_name -> google.protobuf.Timestamp
0, // 1: headscale.v1.CreateUserResponse.user:type_name -> headscale.v1.User
0, // 2: headscale.v1.RenameUserResponse.user:type_name -> headscale.v1.User
0, // 3: headscale.v1.ListUsersResponse.users:type_name -> headscale.v1.User
4, // [4:4] is the sub-list for method output_type
4, // [4:4] is the sub-list for method input_type
4, // [4:4] is the sub-list for extension type_name
4, // [4:4] is the sub-list for extension extendee
0, // [0:4] is the sub-list for field type_name
}
func init() { file_headscale_v1_user_proto_init() }
func file_headscale_v1_user_proto_init() {
if File_headscale_v1_user_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_headscale_v1_user_proto_rawDesc), len(file_headscale_v1_user_proto_rawDesc)),
NumEnums: 0,
NumMessages: 9,
NumExtensions: 0,
NumServices: 0,
},
GoTypes: file_headscale_v1_user_proto_goTypes,
DependencyIndexes: file_headscale_v1_user_proto_depIdxs,
MessageInfos: file_headscale_v1_user_proto_msgTypes,
}.Build()
File_headscale_v1_user_proto = out.File
file_headscale_v1_user_proto_goTypes = nil
file_headscale_v1_user_proto_depIdxs = nil
}

View file

@ -1,44 +0,0 @@
{
"swagger": "2.0",
"info": {
"title": "headscale/v1/apikey.proto",
"version": "version not set"
},
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"paths": {},
"definitions": {
"protobufAny": {
"type": "object",
"properties": {
"@type": {
"type": "string"
}
},
"additionalProperties": {}
},
"rpcStatus": {
"type": "object",
"properties": {
"code": {
"type": "integer",
"format": "int32"
},
"message": {
"type": "string"
},
"details": {
"type": "array",
"items": {
"type": "object",
"$ref": "#/definitions/protobufAny"
}
}
}
}
}
}

View file

@ -1,44 +0,0 @@
{
"swagger": "2.0",
"info": {
"title": "headscale/v1/auth.proto",
"version": "version not set"
},
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"paths": {},
"definitions": {
"protobufAny": {
"type": "object",
"properties": {
"@type": {
"type": "string"
}
},
"additionalProperties": {}
},
"rpcStatus": {
"type": "object",
"properties": {
"code": {
"type": "integer",
"format": "int32"
},
"message": {
"type": "string"
},
"details": {
"type": "array",
"items": {
"type": "object",
"$ref": "#/definitions/protobufAny"
}
}
}
}
}
}

View file

@ -1,44 +0,0 @@
{
"swagger": "2.0",
"info": {
"title": "headscale/v1/device.proto",
"version": "version not set"
},
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"paths": {},
"definitions": {
"protobufAny": {
"type": "object",
"properties": {
"@type": {
"type": "string"
}
},
"additionalProperties": {}
},
"rpcStatus": {
"type": "object",
"properties": {
"code": {
"type": "integer",
"format": "int32"
},
"message": {
"type": "string"
},
"details": {
"type": "array",
"items": {
"type": "object",
"$ref": "#/definitions/protobufAny"
}
}
}
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,44 +0,0 @@
{
"swagger": "2.0",
"info": {
"title": "headscale/v1/node.proto",
"version": "version not set"
},
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"paths": {},
"definitions": {
"protobufAny": {
"type": "object",
"properties": {
"@type": {
"type": "string"
}
},
"additionalProperties": {}
},
"rpcStatus": {
"type": "object",
"properties": {
"code": {
"type": "integer",
"format": "int32"
},
"message": {
"type": "string"
},
"details": {
"type": "array",
"items": {
"type": "object",
"$ref": "#/definitions/protobufAny"
}
}
}
}
}
}

View file

@ -1,44 +0,0 @@
{
"swagger": "2.0",
"info": {
"title": "headscale/v1/policy.proto",
"version": "version not set"
},
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"paths": {},
"definitions": {
"protobufAny": {
"type": "object",
"properties": {
"@type": {
"type": "string"
}
},
"additionalProperties": {}
},
"rpcStatus": {
"type": "object",
"properties": {
"code": {
"type": "integer",
"format": "int32"
},
"message": {
"type": "string"
},
"details": {
"type": "array",
"items": {
"type": "object",
"$ref": "#/definitions/protobufAny"
}
}
}
}
}
}

View file

@ -1,44 +0,0 @@
{
"swagger": "2.0",
"info": {
"title": "headscale/v1/preauthkey.proto",
"version": "version not set"
},
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"paths": {},
"definitions": {
"protobufAny": {
"type": "object",
"properties": {
"@type": {
"type": "string"
}
},
"additionalProperties": {}
},
"rpcStatus": {
"type": "object",
"properties": {
"code": {
"type": "integer",
"format": "int32"
},
"message": {
"type": "string"
},
"details": {
"type": "array",
"items": {
"type": "object",
"$ref": "#/definitions/protobufAny"
}
}
}
}
}
}

View file

@ -1,44 +0,0 @@
{
"swagger": "2.0",
"info": {
"title": "headscale/v1/user.proto",
"version": "version not set"
},
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"paths": {},
"definitions": {
"protobufAny": {
"type": "object",
"properties": {
"@type": {
"type": "string"
}
},
"additionalProperties": {}
},
"rpcStatus": {
"type": "object",
"properties": {
"code": {
"type": "integer",
"format": "int32"
},
"message": {
"type": "string"
},
"details": {
"type": "array",
"items": {
"type": "object",
"$ref": "#/definitions/protobufAny"
}
}
}
}
}
}

16
go.mod
View file

@ -10,6 +10,7 @@ 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
@ -20,12 +21,11 @@ require (
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/grpc-ecosystem/grpc-gateway/v2 v2.29.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/ory/dockertest/v3 v3.12.0
github.com/philip-bui/grpc-zerolog v1.0.1
github.com/pkg/profile v1.7.0
github.com/prometheus/client_golang v1.23.2
github.com/prometheus/common v0.68.1
@ -48,14 +48,12 @@ require (
golang.org/x/net v0.56.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sync v0.21.0
google.golang.org/genproto/googleapis/api v0.0.0-20260610212136-7ab31c22f7ad
google.golang.org/grpc v1.81.1
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/postgres v1.6.0
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
)
@ -104,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
@ -141,7 +140,7 @@ require (
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/glebarez/go-sqlite v1.22.0 // indirect
github.com/go-jose/go-jose/v3 v3.0.5 // indirect
@ -154,7 +153,6 @@ require (
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/go-github v17.0.0+incompatible // indirect
github.com/google/go-querystring v1.2.0 // indirect
@ -163,6 +161,7 @@ require (
github.com/google/uuid v1.6.0 // indirect
github.com/gookit/color v1.6.1 // indirect
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
github.com/hashicorp/go-version v1.9.0 // indirect
github.com/hdevalence/ed25519consensus v0.2.0 // indirect
github.com/huin/goupnp v1.3.0 // indirect
@ -246,7 +245,10 @@ require (
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
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

26
go.sum
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=
@ -167,8 +173,8 @@ 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/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I=
@ -217,8 +223,6 @@ github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF0
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
@ -288,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=
@ -353,6 +358,10 @@ 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/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
@ -371,8 +380,6 @@ github.com/peterbourgon/ff/v3 v3.4.0/go.mod h1:zjJVUhx+twciwfDl0zBcFzl4dW8axCRyX
github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM=
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/philip-bui/grpc-zerolog v1.0.1 h1:EMacvLRUd2O1K0eWod27ZP5CY1iTNkhBDLSN+Q4JEvA=
github.com/philip-bui/grpc-zerolog v1.0.1/go.mod h1:qXbiq/2X4ZUMMshsqlWyTHOcw7ns+GZmlqZZN05ZHcQ=
github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pires/go-proxyproto v0.9.2 h1:H1UdHn695zUVVmB0lQ354lOWHOy6TZSpzBl3tgN0s1U=
@ -432,10 +439,9 @@ 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/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@ -601,8 +607,6 @@ golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeu
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE=
golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260610212136-7ab31c22f7ad h1:3iLyITS/sySRwbUKoC7ogfj2Yr1Cjs0pfaRKj5U5HEw=
google.golang.org/genproto/googleapis/api v0.0.0-20260610212136-7ab31c22f7ad/go.mod h1:KdNqO+rCIWgFumrNBSEDlDNrkrQnpkax7Tv1WxNY8V4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
@ -670,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
hscontrol/api/v1/api.go Normal file
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)
}
}

245
hscontrol/api/v1/apikeys.go Normal file
View file

@ -0,0 +1,245 @@
package apiv1
import (
"cmp"
"context"
"net/http"
"slices"
"strconv"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/juanfont/headscale/hscontrol/types"
)
func init() {
registrations = append(registrations, registerApiKeys)
}
// 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"`
}
// CreateApiKeyRequestBody is the v1.CreateApiKeyRequest body.
type CreateApiKeyRequestBody struct {
Expiration *time.Time `json:"expiration,omitempty"`
}
// ExpireApiKeyRequestBody is the v1.ExpireApiKeyRequest body.
type ExpireApiKeyRequestBody struct {
Prefix string `json:"prefix,omitempty"`
ID string `format:"uint64" json:"id,omitempty"`
}
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, huma.Error400BadRequest("provide either id or prefix, not both")
case hasID:
key, err := b.State.GetAPIKeyByID(id)
if err != nil {
return nil, mapError("getting api key", err)
}
return key, nil
case hasPrefix:
key, err := b.State.GetAPIKey(prefix)
if err != nil {
return nil, mapError("getting api key", err)
}
return key, nil
default:
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 + "***"
}

163
hscontrol/api/v1/auth.go Normal file
View file

@ -0,0 +1,163 @@
package apiv1
import (
"context"
"errors"
"net/http"
"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")
// AuthRegisterRequestBody is the v1.AuthRegisterRequest body.
type AuthRegisterRequestBody struct {
User string `json:"user,omitempty"`
AuthID string `json:"authId,omitempty"`
}
// AuthApproveRequestBody is the v1.AuthApproveRequest body.
type AuthApproveRequestBody struct {
AuthID string `json:"authId,omitempty"`
}
// AuthRejectRequestBody is the v1.AuthRejectRequest body.
type AuthRejectRequestBody struct {
AuthID string `json:"authId,omitempty"`
}
type (
authRegisterInput struct {
Body AuthRegisterRequestBody
}
authRegisterOutput struct {
Body struct {
Node Node `json:"node"`
}
}
)
type (
authApproveInput struct {
Body AuthApproveRequestBody
}
authApproveOutput struct {
Body struct{}
}
)
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, huma.Error400BadRequest("invalid auth_id", err)
}
authReq, ok := b.State.GetAuthCacheEntry(authID)
if !ok {
return nil, huma.Error404NotFound("no pending auth session for auth_id " + authID.String())
}
return authReq, nil
}

View file

@ -0,0 +1,49 @@
package apiv1
import (
"errors"
"github.com/danielgtaylor/huma/v2"
"github.com/juanfont/headscale/hscontrol/db"
"github.com/juanfont/headscale/hscontrol/state"
"gorm.io/gorm"
)
// 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
}
switch {
case errors.Is(err, gorm.ErrRecordNotFound),
errors.Is(err, state.ErrNodeNotFound),
errors.Is(err, state.ErrNodeNotInNodeStore),
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 huma.Error500InternalServerError(msg, err)
}
}

View file

@ -0,0 +1,41 @@
package apiv1
import (
"context"
"net/http"
"github.com/danielgtaylor/huma/v2"
)
// 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
})
}

702
hscontrol/api/v1/nodes.go Normal file
View file

@ -0,0 +1,702 @@
package apiv1
import (
"context"
"errors"
"net/http"
"net/netip"
"slices"
"strconv"
"time"
"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"
)
func init() {
registrations = append(registrations, registerNodes)
}
// errBackfillNotConfirmed guards BackfillNodeIPs behind explicit confirmed=true.
var errBackfillNotConfirmed = errors.New("not confirmed, aborting")
// 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",
}
// 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(),
)
}
return types.NodeID(id), nil
}

191
hscontrol/api/v1/policy.go Normal file
View file

@ -0,0 +1,191 @@
package apiv1
import (
"context"
"fmt"
"io"
"net/http"
"os"
"time"
"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"
)
func init() {
registrations = append(registrations, registerPolicy)
}
// 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
}
setPolicyInput struct {
Body PolicyRequestBody
}
setPolicyOutput struct {
Body PolicyResponseBody
}
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
}
return nil, huma.Error500InternalServerError(fmt.Sprintf(
"no supported policy mode found in configuration, policy.mode: %q",
b.Cfg.Policy.Mode,
), nil)
})
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,
)
}
p := in.Body.Policy
// 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
})
}

View file

@ -0,0 +1,318 @@
package apiv1
import (
"cmp"
"context"
"net/http"
"slices"
"strconv"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/juanfont/headscale/hscontrol/types"
)
func init() {
registrations = append(registrations, registerPreAuthKeys)
}
// 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"`
}
}
)
type (
expirePreAuthKeyInput struct {
Body ExpirePreAuthKeyRequestBody
}
expirePreAuthKeyOutput struct {
Body struct{}
}
)
type (
deletePreAuthKeyInput struct {
ID string `format:"uint64" query:"id"`
}
deletePreAuthKeyOutput struct {
Body struct{}
}
)
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
}

29
hscontrol/api/v1/tags.go Normal file
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
hscontrol/api/v1/types.go Normal file
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(),
}
}

233
hscontrol/api/v1/users.go Normal file
View file

@ -0,0 +1,233 @@
package apiv1
import (
"cmp"
"context"
"net/http"
"slices"
"strconv"
"github.com/danielgtaylor/huma/v2"
"github.com/juanfont/headscale/hscontrol/types"
"gorm.io/gorm"
)
func init() {
registrations = append(registrations, registerUsers)
}
// 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 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:
return b.State.ListAllUsers()
}
}
func parseUserID(s string) (types.UserID, error) {
id, err := strconv.ParseUint(s, 10, 64)
if err != nil {
return 0, huma.Error400BadRequest("invalid user id", err)
}
return types.UserID(id), nil
}

124
hscontrol/api/v2/README.md Normal file
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
hscontrol/api/v2/acl.go Normal file
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
hscontrol/api/v2/api.go Normal file
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
hscontrol/api/v2/devices.go Normal file
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 ""
}

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)
}
}

View file

@ -0,0 +1,58 @@
package apiv2
import (
"testing"
"github.com/danielgtaylor/huma/v2"
"github.com/go-chi/chi/v5"
"github.com/juanfont/headscale/hscontrol/scope"
)
// selfEnforcedKeyOps are the authenticated operations that intentionally declare
// NO static scope because they multiplex on keyType and authorize inside the
// handler via requireKeyScope (see keys.go). Every other authenticated operation
// must declare a scope.
var selfEnforcedKeyOps = map[string]bool{
"POST /api/v2/tailnet/{tailnet}/keys": true,
"GET /api/v2/tailnet/{tailnet}/keys": true,
"GET /api/v2/tailnet/{tailnet}/keys/{keyId}": true,
"DELETE /api/v2/tailnet/{tailnet}/keys/{keyId}": true,
}
// TestEveryAuthenticatedOperationDeclaresScope is the structural guarantee that no
// v2 operation ships unprotected: any operation that requires authentication
// (non-empty Security) must either declare a required scope via requireScope, or
// be one of the keyType-multiplexed keys operations that self-enforce. A new
// operation added without scope protection fails this test.
func TestEveryAuthenticatedOperationDeclaresScope(t *testing.T) {
api := NewAPI(chi.NewMux(), Backend{})
for path, item := range api.OpenAPI().Paths {
for method, op := range humaOperations(item) {
if op == nil || len(op.Security) == 0 {
continue // unregistered method or a public operation
}
key := method + " " + path
if selfEnforcedKeyOps[key] {
continue
}
if _, ok := op.Metadata[scopeMetaKey].(scope.Scope); !ok {
t.Errorf("operation %q is authenticated but declares no required scope; "+
"wrap it in requireScope, or add it to selfEnforcedKeyOps if it "+
"authorizes inside the handler", key)
}
}
}
}
func humaOperations(item *huma.PathItem) map[string]*huma.Operation {
return map[string]*huma.Operation{
"GET": item.Get,
"POST": item.Post,
"PUT": item.Put,
"DELETE": item.Delete,
"PATCH": item.Patch,
}
}

View file

@ -0,0 +1,69 @@
package apiv2
import (
"math"
"strconv"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/util"
)
// emptyIfNil ensures a slice serializes as [] rather than null, which the
// Tailscale clients expect for empty list fields.
func emptyIfNil(s []string) []string {
if s == nil {
return []string{}
}
return s
}
// parseID parses a decimal entity id from a path segment. A non-numeric id is
// simply an unknown entity, surfaced as a 404 naming subject (e.g. "auth key"),
// so the Tailscale SDK's IsNotFound behaves.
func parseID(rawID, subject string) (uint64, error) {
id, err := strconv.ParseUint(rawID, util.Base10, util.BitSize64)
if err != nil {
return 0, huma.Error404NotFound(subject + " not found")
}
return id, nil
}
// parseNodeID parses a device id path segment into a [types.NodeID].
func parseNodeID(rawID string) (types.NodeID, error) {
id, err := parseID(rawID, "device")
if err != nil {
return 0, err
}
return types.NodeID(id), nil
}
// timeOrZero dereferences a time pointer, returning the zero time for nil.
func timeOrZero(t *time.Time) time.Time {
if t == nil {
return time.Time{}
}
return *t
}
// expirySeconds reports the lifetime between created and expires in whole
// seconds, the unit the Tailscale spec documents for the response field. The
// lifetime is rounded because the stored expiration is stamped a hair before
// CreatedAt, so an 86400s request would otherwise read back as 86399.
func expirySeconds(created, expires *time.Time) int64 {
if created == nil || expires == nil {
return 0
}
secs := int64(math.Round(expires.Sub(*created).Seconds()))
if secs < 0 {
return 0
}
return secs
}

593
hscontrol/api/v2/keys.go Normal file
View file

@ -0,0 +1,593 @@
package apiv2
import (
"context"
"net/http"
"strconv"
"time"
"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, registerKeys)
}
// defaultExpiry is the auth-key lifetime when the request omits expirySeconds:
// 90 days, matching Tailscale and the Terraform provider default.
const defaultExpiry = 90 * 24 * time.Hour
const (
// keyTypeAuth is a machine auth key (Headscale pre-auth key); the default.
keyTypeAuth = "auth"
// keyTypeClient is an OAuth client (client-credentials). Multiplexed onto the
// keys resource exactly as Tailscale does.
keyTypeClient = "client"
)
// KeyCapabilities maps a resource to the actions a key permits. Headscale
// populates only devices.create (auth keys); the named types (vs Tailscale's
// anonymous nesting) give Huma stable schema names.
type KeyCapabilities struct {
Devices KeyDeviceCapabilities `json:"devices"`
}
type KeyDeviceCapabilities struct {
Create KeyDeviceCreateCapabilities `json:"create"`
}
type KeyDeviceCreateCapabilities struct {
Reusable bool `json:"reusable"`
Ephemeral bool `json:"ephemeral"`
Preauthorized bool `json:"preauthorized"`
// Tags is not nullable:"false": the Tailscale clients send "tags":null for
// an untagged key, which must be accepted. The response always emits [] via
// emptyIfNil.
Tags []string `json:"tags"`
}
// CreateKeyRequest is the POST body. It is multiplexed by keyType: "auth"
// (default) creates a machine auth key from Capabilities; "client" creates an
// OAuth client from the top-level Scopes and Tags. expirySeconds is plain
// seconds (unlike the response, see Key).
type CreateKeyRequest struct {
KeyType string `doc:"Key kind: \"auth\" (default) or \"client\" (OAuth client)." json:"keyType,omitempty"`
// Capabilities is optional: an auth key carries device-create capabilities,
// but an OAuth client (keyType:"client") has none and the Tailscale clients
// omit the field entirely, so it must not be required.
Capabilities *KeyCapabilities `json:"capabilities,omitempty"`
ExpirySeconds int64 `doc:"Lifetime in seconds; defaults to 90 days. Auth keys only." json:"expirySeconds,omitempty"`
Description string `json:"description,omitempty" maxLength:"50"`
// Scopes and Tags are top-level and apply only to keyType "client" (an OAuth
// client). Auth-key tags live under Capabilities.Devices.Create.Tags.
Scopes []string `doc:"OAuth scopes granted to the client. keyType=client only." json:"scopes,omitempty"`
Tags []string `doc:"Tags the client may assign. keyType=client only." json:"tags,omitempty"`
}
// Key is the Tailscale key response, shared by auth keys and OAuth clients.
// expirySeconds is emitted in seconds to match the Tailscale spec; the secret
// key is present only at creation.
type Key struct {
ID string `json:"id"`
KeyType string `json:"keyType"`
Key string `json:"key,omitempty"`
Description string `json:"description,omitempty"`
ExpirySeconds int64 `json:"expirySeconds,omitempty"`
Created time.Time `json:"created"`
Expires *time.Time `json:"expires,omitempty"`
Revoked *time.Time `json:"revoked,omitempty"`
Invalid bool `json:"invalid"`
Capabilities KeyCapabilities `json:"capabilities"`
Scopes []string `json:"scopes,omitempty"`
Tags []string `json:"tags,omitempty"`
UserID string `json:"userId,omitempty"`
}
type (
createKeyInput struct {
Tailnet string `doc:"Tailnet; must be \"-\" (the single Headscale tailnet)." path:"tailnet"`
Body CreateKeyRequest
}
listKeysInput struct {
Tailnet string `path:"tailnet"`
All bool `doc:"Accepted for compatibility; Headscale returns all keys." query:"all"`
}
keyByIDInput struct {
Tailnet string `path:"tailnet"`
KeyID string `path:"keyId"`
}
keyOutput struct {
Body Key
}
listKeysOutput struct {
Body struct {
Keys []Key `json:"keys" nullable:"false"`
}
}
deleteKeyOutput struct {
Body struct{}
}
)
// The keys resource is multiplexed by keyType (auth key vs OAuth client), so the
// scope an operation requires depends on the request rather than being fixed.
// requireScope (which the middleware enforces statically) is therefore omitted
// here; each handler authorizes via requireKeyScope once the kind is known.
func registerKeys(api huma.API, b Backend) {
keysTags := []string{"Keys", "Tailscale compat"}
huma.Register(api, huma.Operation{
OperationID: "createKey",
Method: http.MethodPost,
Path: "/api/v2/tailnet/{tailnet}/keys",
Summary: "Create an auth key or OAuth client",
Description: "Requires the `auth_keys` scope for an auth key, or `oauth_keys` for an OAuth client (an admin API key is all-access).",
Tags: keysTags,
Security: security,
Errors: []int{
http.StatusBadRequest,
http.StatusUnauthorized,
http.StatusForbidden,
http.StatusNotFound,
},
}, func(ctx context.Context, in *createKeyInput) (*keyOutput, error) {
err := requireDefaultTailnet(in.Tailnet)
if err != nil {
return nil, err
}
if in.Body.KeyType == keyTypeClient {
return createOAuthClient(ctx, b, in.Body)
}
return createAuthKey(ctx, b, in.Body)
})
huma.Register(api, huma.Operation{
OperationID: "listKeys",
Method: http.MethodGet,
Path: "/api/v2/tailnet/{tailnet}/keys",
Summary: "List auth keys and OAuth clients",
Description: "A token sees the kinds it can read: `auth_keys:read` for auth keys, `oauth_keys:read` for OAuth clients (an admin API key sees all).",
Tags: keysTags,
Security: security,
Errors: []int{
http.StatusUnauthorized,
http.StatusForbidden,
http.StatusNotFound,
},
}, func(ctx context.Context, in *listKeysInput) (*listKeysOutput, error) {
err := requireDefaultTailnet(in.Tailnet)
if err != nil {
return nil, err
}
scopes, isOAuth := principalScopes(ctx)
out := &listKeysOutput{}
out.Body.Keys = []Key{}
// A token sees the key kinds it has read scope for; an admin key sees all.
if !isOAuth || scope.Grants(scope.Parse(scopes), scope.AuthKeysRead) {
keys, err := b.State.ListPreAuthKeys()
if err != nil {
return nil, huma.Error500InternalServerError("listing auth keys", err)
}
for i := range keys {
out.Body.Keys = append(out.Body.Keys, keyFromStored(&keys[i]))
}
}
if !isOAuth || scope.Grants(scope.Parse(scopes), scope.OAuthKeysRead) {
clients, err := b.State.ListOAuthClients()
if err != nil {
return nil, huma.Error500InternalServerError("listing oauth clients", err)
}
for i := range clients {
out.Body.Keys = append(out.Body.Keys, oauthClientToKey(&clients[i], ""))
}
}
return out, nil
})
huma.Register(api, huma.Operation{
OperationID: "getKey",
Method: http.MethodGet,
Path: "/api/v2/tailnet/{tailnet}/keys/{keyId}",
Summary: "Get an auth key or OAuth client",
Description: "Requires `auth_keys:read` for an auth key, or `oauth_keys:read` for an OAuth client (an admin API key is all-access).",
Tags: keysTags,
Security: security,
Errors: []int{
http.StatusUnauthorized,
http.StatusForbidden,
http.StatusNotFound,
},
}, func(ctx context.Context, in *keyByIDInput) (*keyOutput, error) {
err := requireDefaultTailnet(in.Tailnet)
if err != nil {
return nil, err
}
// An OAuth client id is a hex string distinct from a numeric auth-key id,
// so a client lookup that hits is authoritative; otherwise fall through to
// the auth-key path. The lookup is gated on the caller actually holding
// oauth_keys:read so a token without it cannot tell a real client id (403)
// from an unknown key (404) — i.e. no client-existence oracle.
if requireKeyScope(ctx, scope.OAuthKeysRead) == nil {
client, err := b.State.GetOAuthClientByClientID(in.KeyID)
if err == nil {
return &keyOutput{Body: oauthClientToKey(client, "")}, nil
}
}
err = requireKeyScope(ctx, scope.AuthKeysRead)
if err != nil {
return nil, err
}
key, err := findKeyByID(b, in.KeyID)
if err != nil {
return nil, err
}
return &keyOutput{Body: keyFromStored(key)}, nil
})
huma.Register(api, huma.Operation{
OperationID: "deleteKey",
Method: http.MethodDelete,
Path: "/api/v2/tailnet/{tailnet}/keys/{keyId}",
Summary: "Delete an auth key or OAuth client",
Description: "Requires the `auth_keys` scope for an auth key, or `oauth_keys` for an OAuth client (an admin API key is all-access).",
Tags: keysTags,
Security: security,
DefaultStatus: http.StatusOK,
Errors: []int{
http.StatusUnauthorized,
http.StatusForbidden,
http.StatusNotFound,
},
}, func(ctx context.Context, in *keyByIDInput) (*deleteKeyOutput, error) {
err := requireDefaultTailnet(in.Tailnet)
if err != nil {
return nil, err
}
// Gated on oauth_keys (write) for the same no-existence-oracle reason as
// getKey: a token without it must not learn that an id is an OAuth client.
if requireKeyScope(ctx, scope.OAuthKeys) == nil {
_, err = b.State.GetOAuthClientByClientID(in.KeyID)
if err == nil {
err = b.State.RevokeOAuthClient(in.KeyID)
if err != nil {
return nil, mapError("deleting oauth client", err)
}
return &deleteKeyOutput{}, nil
}
}
err = requireKeyScope(ctx, scope.AuthKeys)
if err != nil {
return nil, err
}
id, err := parseID(in.KeyID, "auth key")
if err != nil {
return nil, err
}
// Tailscale's DELETE revokes the key but keeps it retrievable (invalid)
// rather than destroying it; the collector reaps it after the retention
// window.
err = b.State.RevokePreAuthKey(id)
if err != nil {
return nil, mapError("revoking auth key", err)
}
return &deleteKeyOutput{}, nil
})
}
// requireKeyScope authorizes a keys operation for an OAuth access token. An admin
// API key carries no OAuth scopes and is all-access, so it always passes.
func requireKeyScope(ctx context.Context, need scope.Scope) error {
scopes, isOAuth := principalScopes(ctx)
if !isOAuth {
return nil
}
if !scope.Grants(scope.Parse(scopes), need) {
return huma.Error403Forbidden("token is missing the required scope " + string(need))
}
return nil
}
// createAuthKey creates a machine auth key (pre-auth key). Ownership: tags -> a
// tagged key; no tags -> owned by the API key's user. An OAuth access token must
// mint tagged keys, and each tag must be within the token's grant.
func createAuthKey(ctx context.Context, b Backend, body CreateKeyRequest) (*keyOutput, error) {
err := requireKeyScope(ctx, scope.AuthKeys)
if err != nil {
return nil, err
}
var create KeyDeviceCreateCapabilities
if body.Capabilities != nil {
create = body.Capabilities.Devices.Create
}
tokenTags, isOAuth := principalTags(ctx)
var userID *types.UserID
switch {
case len(create.Tags) > 0:
// A key minted by an OAuth token may only carry tags within the token's
// grant (held directly, or owned by a held tag) and defined in policy,
// matching SetNodeTags. An admin key keeps the historical behaviour of
// validating only tag syntax (db.validateACLTags).
if isOAuth {
for _, tag := range create.Tags {
if !b.State.TagExists(tag) {
return nil, huma.Error400BadRequest("tag " + tag + " is not defined in policy")
}
if !b.State.TagOwnedByTags(tag, tokenTags) {
return nil, huma.Error403Forbidden(
"token may not assign tag " + tag,
)
}
}
}
case isOAuth:
// OAuth-minted keys are tailnet/tag-owned; an untagged (user-owned) key
// cannot be created from a token.
return nil, huma.Error403Forbidden("an OAuth client must create tagged auth keys")
default:
uid, ok := ownerUser(ctx)
if !ok {
return nil, huma.Error400BadRequest(
"an auth key without tags must be created with a user-owned API key",
)
}
userID = &uid
}
expiration := time.Now().Add(expiryDuration(body.ExpirySeconds))
pak, err := b.State.CreatePreAuthKey(
userID,
create.Reusable,
create.Ephemeral,
&expiration,
create.Tags,
)
if err != nil {
return nil, mapError("creating auth key", err)
}
if body.Description != "" {
err := b.State.SetPreAuthKeyDescription(pak.ID, body.Description)
if err != nil {
return nil, huma.Error500InternalServerError("setting auth key description", err)
}
}
return &keyOutput{Body: keyFromNew(pak, body)}, nil
}
// createOAuthClient creates an OAuth client (keyType:"client"). The client secret
// is returned once, here.
func createOAuthClient(ctx context.Context, b Backend, body CreateKeyRequest) (*keyOutput, error) {
err := requireKeyScope(ctx, scope.OAuthKeys)
if err != nil {
return nil, err
}
if len(body.Scopes) == 0 {
return nil, huma.Error400BadRequest("an OAuth client must declare at least one scope")
}
// Tailscale: tags are mandatory when the scopes include devices:core or
// auth_keys, because such a client mints tagged, tailnet-owned credentials.
if scope.RequiresTags(scope.Parse(body.Scopes)) && len(body.Tags) == 0 {
return nil, huma.Error400BadRequest(
"tags are required when scopes include devices:core or auth_keys",
)
}
// A client created by an OAuth token may not be granted authority the token
// lacks: its scopes must each be within the token's grant, and its tags within
// the token's tags and defined in policy (matching SetNodeTags). Otherwise an
// oauth_keys token could mint an all-access client and escalate. An admin API
// key (not an OAuth token) is unrestricted and keeps the historical tag
// behaviour (syntax-only validation).
if tokenScopes, isOAuth := principalScopes(ctx); isOAuth {
for _, s := range body.Scopes {
if !scope.Grants(scope.Parse(tokenScopes), scope.Scope(s)) {
return nil, huma.Error403Forbidden(
"client may not be granted scope " + s + " beyond the creating token",
)
}
}
tokenTags, _ := principalTags(ctx)
for _, tag := range body.Tags {
if !b.State.TagExists(tag) {
return nil, huma.Error400BadRequest("tag " + tag + " is not defined in policy")
}
if !b.State.TagOwnedByTags(tag, tokenTags) {
return nil, huma.Error403Forbidden(
"client may not be granted tag " + tag + " beyond the creating token",
)
}
}
}
var creator *uint
if uid, ok := ownerUser(ctx); ok {
u := uint(uid)
creator = &u
}
secret, client, err := b.State.CreateOAuthClient(body.Scopes, body.Tags, body.Description, creator)
if err != nil {
return nil, mapError("creating oauth client", err)
}
return &keyOutput{Body: oauthClientToKey(client, secret)}, nil
}
// findKeyByID looks up a stored pre-auth key by its (stringified) id with a
// direct by-id query; an unknown id surfaces as gorm.ErrRecordNotFound, which
// mapError turns into a 404.
func findKeyByID(b Backend, rawID string) (*types.PreAuthKey, error) {
id, err := parseID(rawID, "auth key")
if err != nil {
return nil, err
}
pak, err := b.State.GetPreAuthKeyByID(id)
if err != nil {
return nil, mapError("looking up auth key", err)
}
return pak, nil
}
// keyFromNew builds the create response from the freshly created key. The
// plaintext secret is returned only here. preauthorized is reported true to
// match keyFromStored: Headscale always authorizes pre-auth-key nodes, so the
// create and read paths must agree or the Terraform provider sees a diff.
func keyFromNew(pak *types.PreAuthKeyNew, req CreateKeyRequest) Key {
var create KeyDeviceCreateCapabilities
if req.Capabilities != nil {
create = req.Capabilities.Devices.Create
}
key := Key{
ID: pak.StringID(),
KeyType: keyTypeAuth,
Key: pak.Key,
Description: req.Description,
Created: timeOrZero(pak.CreatedAt),
Capabilities: capabilities(
create.Reusable,
create.Ephemeral,
true,
pak.Tags,
),
Tags: pak.Tags,
}
if pak.Expiration != nil {
key.Expires = pak.Expiration
key.ExpirySeconds = expirySeconds(pak.CreatedAt, pak.Expiration)
}
// A tagged key presents no owner; a user-owned key reports its user id.
if len(pak.Tags) == 0 && pak.User != nil {
key.UserID = pak.User.StringID()
}
return key
}
// keyFromStored builds the get/list response from a stored key. The secret is
// never returned here. preauthorized is reported true: Headscale has no separate
// device-approval step, so every pre-auth key authorizes its nodes. Reporting it
// stably keeps the Terraform provider from seeing a forced-replacement diff.
func keyFromStored(pak *types.PreAuthKey) Key {
key := Key{
ID: pak.StringID(),
KeyType: keyTypeAuth,
Description: pak.Description,
Created: timeOrZero(pak.CreatedAt),
Invalid: pak.Validate() != nil,
Capabilities: capabilities(pak.Reusable, pak.Ephemeral, true, pak.Tags),
Tags: pak.Tags,
}
if pak.Expiration != nil {
key.Expires = pak.Expiration
key.ExpirySeconds = expirySeconds(pak.CreatedAt, pak.Expiration)
}
if pak.Revoked != nil {
key.Revoked = pak.Revoked
}
if len(pak.Tags) == 0 && pak.User != nil {
key.UserID = pak.User.StringID()
}
return key
}
// oauthClientToKey builds the keys response for an OAuth client. secret is the
// plaintext client secret, set only on the create response and empty on get/list
// (the secret is never re-exposed).
func oauthClientToKey(client *types.OAuthClient, secret string) Key {
key := Key{
ID: client.ClientID,
KeyType: keyTypeClient,
Key: secret,
Description: client.Description,
Created: timeOrZero(client.CreatedAt),
Scopes: emptyIfNil(client.Scopes),
Tags: emptyIfNil(client.Tags),
}
if client.Revoked != nil {
key.Revoked = client.Revoked
key.Invalid = true
}
if client.UserID != nil {
key.UserID = strconv.FormatUint(uint64(*client.UserID), util.Base10)
}
return key
}
func capabilities(
reusable, ephemeral, preauthorized bool,
tags []string,
) KeyCapabilities {
return KeyCapabilities{
Devices: KeyDeviceCapabilities{Create: KeyDeviceCreateCapabilities{
Reusable: reusable,
Ephemeral: ephemeral,
Preauthorized: preauthorized,
Tags: emptyIfNil(tags),
}},
}
}
func expiryDuration(seconds int64) time.Duration {
if seconds <= 0 {
return defaultExpiry
}
return time.Duration(seconds) * time.Second
}

178
hscontrol/api/v2/oauth.go Normal file
View file

@ -0,0 +1,178 @@
package apiv2
import (
"encoding/json"
"net/http"
"slices"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/juanfont/headscale/hscontrol/scope"
"github.com/juanfont/headscale/hscontrol/types"
)
// accessTokenTTL is the fixed lifetime of a minted access token, matching
// Tailscale's non-configurable one hour.
const accessTokenTTL = time.Hour
// registerOAuthToken mounts POST /api/v2/oauth/token on the router. It is a plain
// handler, not a Huma operation: it consumes application/x-www-form-urlencoded
// and emits RFC 6749 OAuth2 error bodies ({"error","error_description"}), neither
// of which fits Huma's JSON-in / Tailscale-error-out machinery.
// ponytail: one bespoke OAuth endpoint isn't worth bending Huma around.
func registerOAuthToken(router chi.Router, b Backend) {
router.Post("/api/v2/oauth/token", oauthTokenHandler(b))
}
// tokenResponse is the OAuth 2.0 client-credentials success body.
type tokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
Scope string `json:"scope,omitempty"`
}
// oauthTokenHandler implements the client-credentials grant (RFC 6749 §4.4):
// authenticate the client, optionally narrow the granted scopes/tags, and mint a
// short-lived bearer token.
func oauthTokenHandler(b Backend) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
if err != nil {
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "could not parse request body")
return
}
// grant_type defaults to client_credentials: Tailscale's documented curl
// omits it, and the x/oauth2 client always sends it.
if gt := r.PostForm.Get("grant_type"); gt != "" && gt != "client_credentials" {
writeOAuthError(w, http.StatusBadRequest, "unsupported_grant_type",
"only the client_credentials grant is supported")
return
}
// Credentials may arrive in the body or as HTTP Basic (the x/oauth2
// auto-detect probes Basic first). The secret embeds the client id, so a
// separate client_id is not required.
secret := r.PostForm.Get("client_secret")
if secret == "" {
if _, pass, ok := r.BasicAuth(); ok {
secret = pass
}
}
if secret == "" {
writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "missing client credentials")
return
}
client, err := b.State.AuthenticateOAuthClient(secret)
if err != nil {
writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "invalid client credentials")
return
}
// Optional space-delimited scope/tags narrow the token to a subset of the
// client's grant.
scopes, badScope, ok := narrowScopes(client.Scopes, strings.Fields(r.PostForm.Get("scope")))
if !ok {
writeOAuthError(w, http.StatusBadRequest, "invalid_scope",
"scope "+badScope+" is not granted to this client")
return
}
tags, badTag, ok := narrowTags(client, strings.Fields(r.PostForm.Get("tags")))
if !ok {
writeOAuthError(w, http.StatusBadRequest, "invalid_target",
"tag "+badTag+" is not granted to this client")
return
}
expiry := time.Now().Add(accessTokenTTL)
tokenStr, _, err := b.State.MintAccessToken(client.ClientID, scopes, tags, &expiry)
if err != nil {
writeOAuthError(w, http.StatusInternalServerError, "server_error", "could not mint access token")
return
}
writeJSON(w, http.StatusOK, tokenResponse{
AccessToken: tokenStr,
TokenType: "Bearer",
ExpiresIn: int(accessTokenTTL.Seconds()),
Scope: strings.Join(scopes, " "),
})
}
}
// narrowScopes returns the requested scopes if each is granted by the client (an
// empty request means "the client's full grant"), otherwise the offending scope
// and false. A client holding a broad scope (e.g. "all") may mint a token limited
// to a narrower one.
func narrowScopes(granted, requested []string) ([]string, string, bool) {
if len(requested) == 0 {
return granted, "", true
}
for _, req := range requested {
if !scope.Grants(scope.Parse(granted), scope.Scope(req)) {
return nil, req, false
}
}
return requested, "", true
}
// narrowTags returns the requested tags if each is within the client's grant (an
// empty request means "the client's full tag set"), otherwise the offending tag
// and false. A client with the "all" scope may assign any tag, matching Tailscale.
func narrowTags(client *types.OAuthClient, requested []string) ([]string, string, bool) {
if len(requested) == 0 {
return client.Tags, "", true
}
allScope := slices.Contains(client.Scopes, string(scope.All))
for _, req := range requested {
// Reject malformed tags at the trust boundary. A client's own tags are
// validated at creation, so this only matters for an "all"-scope client,
// whose tags would otherwise skip the membership check below and flow
// unvalidated into auth-key creation.
if !strings.HasPrefix(req, "tag:") {
return nil, req, false
}
if !allScope && !slices.Contains(client.Tags, req) {
return nil, req, false
}
}
return requested, "", true
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
// Token responses carry bearer credentials; RFC 6749 §5.1 forbids caching
// them. writeJSON serves only the token endpoint, so set it unconditionally.
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Pragma", "no-cache")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v) //nolint:errchkjson // best-effort response write of a known-safe value
}
// writeOAuthError emits an RFC 6749 §5.2 error body, which the x/oauth2 client
// parses into its RetrieveError.
func writeOAuthError(w http.ResponseWriter, status int, code, desc string) {
writeJSON(w, status, map[string]string{
"error": code,
"error_description": desc,
})
}

View file

@ -0,0 +1,31 @@
package apiv2
import (
"testing"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/stretchr/testify/assert"
)
// TestNarrowTagsRejectsMalformed asserts the token endpoint validates tag
// format at the trust boundary. An "all"-scope client may assign any tag, but a
// malformed tag (missing the "tag:" prefix) must still be rejected rather than
// flowing unvalidated into auth-key creation.
func TestNarrowTagsRejectsMalformed(t *testing.T) {
all := &types.OAuthClient{Scopes: []string{"all"}}
_, bad, ok := narrowTags(all, []string{"not-a-tag"})
assert.False(t, ok, "malformed tag must be rejected even with all scope")
assert.Equal(t, "not-a-tag", bad)
got, _, ok := narrowTags(all, []string{"tag:anything"})
assert.True(t, ok, "all scope may assign any well-formed tag")
assert.Equal(t, []string{"tag:anything"}, got)
// A scoped client may only assign its own well-formed tags.
scoped := &types.OAuthClient{Scopes: []string{"auth_keys"}, Tags: []string{"tag:ci"}}
_, bad, ok = narrowTags(scoped, []string{"tag:other"})
assert.False(t, ok)
assert.Equal(t, "tag:other", bad)
}

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