Files
headscale/hscontrol/api/v2
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
..

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.

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 the API key as HTTP Basic (key as username — what the SDK sends) or Bearer. See authMiddleware.
  • Each operation declares the Tailscale scope it would require (auth_keys, devices:core, devices:routes, policy_file, feature_settings, each with a :read subset). Nothing is enforced yet — every key is all-access — pending OAuth tokens; see the TODO(scopes) in api.go.
  • 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.

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