diff --git a/hscontrol/api/v2/README.md b/hscontrol/api/v2/README.md new file mode 100644 index 00000000..0a643710 --- /dev/null +++ b/hscontrol/api/v2/README.md @@ -0,0 +1,87 @@ +# 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 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](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. diff --git a/hscontrol/api/v2/api.go b/hscontrol/api/v2/api.go new file mode 100644 index 00000000..02d9a5c6 --- /dev/null +++ b/hscontrol/api/v2/api.go @@ -0,0 +1,263 @@ +// 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/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) + + 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() +} + +type contextKey int + +const ( + localTrustKey contextKey = iota + ownerUserKey +) + +// 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 + } + + key, err := b.State.AuthenticateAPIKey(token) + if err != nil { + _ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "unauthorized") + + return + } + + // TODO(scopes): every valid key is all-access today. Operations still + // declare their required scope (requireScope) so that once OAuth tokens + // arrive, the granted set can be derived from the token and checked + // against the operation's scope here. Until then, accept everything. + + // Record the key's 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 +} + +// 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 +} + +// Scope is an OAuth capability an operation requires and a token grants. The +// names mirror Tailscale's API scopes (see the OAuth scope descriptions in the +// Tailscale OpenAPI spec); a ...Read scope is the read-only subset of its +// write scope. Nothing is enforced yet — every key is all-access — but every +// operation declares the scope it would require, so OAuth tokens can later be +// checked against it without reworking the operations. +type Scope string + +const ( + ScopeAuthKeys Scope = "auth_keys" + ScopeAuthKeysRead Scope = "auth_keys:read" + + ScopeDevicesCore Scope = "devices:core" + ScopeDevicesCoreRead Scope = "devices:core:read" + + ScopeDevicesRoutes Scope = "devices:routes" + ScopeDevicesRoutesRead Scope = "devices:routes:read" + + ScopePolicyFile Scope = "policy_file" + ScopePolicyFileRead Scope = "policy_file:read" + + ScopeFeatureSettings Scope = "feature_settings" + ScopeFeatureSettingsRead Scope = "feature_settings:read" +) + +// scopeMetaKey keys the per-operation required Scope in huma.Operation.Metadata. +const scopeMetaKey = "headscale.scope" + +// requireScope records op's required scope in its Metadata, where the auth +// middleware can read it back. It keeps the requirement next to the operation +// definition. +func requireScope(op huma.Operation, s Scope) huma.Operation { + if op.Metadata == nil { + op.Metadata = map[string]any{} + } + + op.Metadata[scopeMetaKey] = s + + return op +} diff --git a/hscontrol/api/v2/errors.go b/hscontrol/api/v2/errors.go new file mode 100644 index 00000000..78241b3d --- /dev/null +++ b/hscontrol/api/v2/errors.go @@ -0,0 +1,83 @@ +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 — decode 4xx/5xx +// responses into this shape. Huma's default RFC 9457 problem+json would reach +// them with an empty message, so every v2 error is rewritten into this shape by +// tailscaleErrorTransformer. 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, 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) + } +} diff --git a/hscontrol/api/v2/helpers.go b/hscontrol/api/v2/helpers.go new file mode 100644 index 00000000..aad0392d --- /dev/null +++ b/hscontrol/api/v2/helpers.go @@ -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 +} diff --git a/hscontrol/api/v2/keys.go b/hscontrol/api/v2/keys.go new file mode 100644 index 00000000..9ffb0a99 --- /dev/null +++ b/hscontrol/api/v2/keys.go @@ -0,0 +1,360 @@ +package apiv2 + +import ( + "context" + "net/http" + "time" + + "github.com/danielgtaylor/huma/v2" + "github.com/juanfont/headscale/hscontrol/types" +) + +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 + +// keyTypeAuth is the only key kind Headscale issues; Tailscale's OAuth-client +// and federated-identity kinds are out of scope. +const keyTypeAuth = "auth" + +// 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. expirySeconds is plain seconds (unlike the +// response, see Key). +type CreateKeyRequest struct { + Capabilities KeyCapabilities `json:"capabilities"` + ExpirySeconds int64 `doc:"Lifetime in seconds; defaults to 90 days." json:"expirySeconds,omitempty"` + Description string `json:"description,omitempty" maxLength:"50"` +} + +// Key is the Tailscale auth-key response. 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"` + 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{} + } +) + +func registerKeys(api huma.API, b Backend) { + keysTags := []string{"Keys", "Tailscale compat"} + + huma.Register(api, requireScope(huma.Operation{ + OperationID: "createKey", + Method: http.MethodPost, + Path: "/api/v2/tailnet/{tailnet}/keys", + Summary: "Create an auth key", + Tags: keysTags, + Security: security, + Errors: []int{ + http.StatusBadRequest, + http.StatusUnauthorized, + http.StatusForbidden, + http.StatusNotFound, + }, + }, ScopeAuthKeys), func(ctx context.Context, in *createKeyInput) (*keyOutput, error) { + err := requireDefaultTailnet(in.Tailnet) + if err != nil { + return nil, err + } + + create := in.Body.Capabilities.Devices.Create + + // Ownership: tags -> tagged key; no tags -> owned by the API key's user; + // no tags and an ownerless (legacy/admin) key -> 400. + var userID *types.UserID + + if len(create.Tags) == 0 { + 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(in.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 in.Body.Description != "" { + err := b.State.SetPreAuthKeyDescription( + pak.ID, + in.Body.Description, + ) + if err != nil { + return nil, huma.Error500InternalServerError( + "setting auth key description", + err, + ) + } + } + + return &keyOutput{Body: keyFromNew(pak, in.Body)}, nil + }) + + huma.Register(api, requireScope(huma.Operation{ + OperationID: "listKeys", + Method: http.MethodGet, + Path: "/api/v2/tailnet/{tailnet}/keys", + Summary: "List auth keys", + Tags: keysTags, + Security: security, + Errors: []int{ + http.StatusUnauthorized, + http.StatusForbidden, + http.StatusNotFound, + }, + }, ScopeAuthKeysRead), func(ctx context.Context, in *listKeysInput) (*listKeysOutput, error) { + err := requireDefaultTailnet(in.Tailnet) + if err != nil { + return nil, err + } + + keys, err := b.State.ListPreAuthKeys() + if err != nil { + return nil, huma.Error500InternalServerError("listing auth keys", err) + } + + out := &listKeysOutput{} + out.Body.Keys = make([]Key, 0, len(keys)) + + for i := range keys { + out.Body.Keys = append(out.Body.Keys, keyFromStored(&keys[i])) + } + + return out, nil + }) + + huma.Register(api, requireScope(huma.Operation{ + OperationID: "getKey", + Method: http.MethodGet, + Path: "/api/v2/tailnet/{tailnet}/keys/{keyId}", + Summary: "Get an auth key", + Tags: keysTags, + Security: security, + Errors: []int{ + http.StatusUnauthorized, + http.StatusForbidden, + http.StatusNotFound, + }, + }, ScopeAuthKeysRead), func(ctx context.Context, in *keyByIDInput) (*keyOutput, error) { + err := requireDefaultTailnet(in.Tailnet) + 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, requireScope(huma.Operation{ + OperationID: "deleteKey", + Method: http.MethodDelete, + Path: "/api/v2/tailnet/{tailnet}/keys/{keyId}", + Summary: "Delete an auth key", + Tags: keysTags, + Security: security, + DefaultStatus: http.StatusOK, + Errors: []int{ + http.StatusUnauthorized, + http.StatusForbidden, + http.StatusNotFound, + }, + }, ScopeAuthKeys), func(ctx context.Context, in *keyByIDInput) (*deleteKeyOutput, error) { + err := requireDefaultTailnet(in.Tailnet) + if err != nil { + return nil, err + } + + id, err := parseID(in.KeyID, "auth key") + if err != nil { + return nil, err + } + + err = b.State.DeletePreAuthKey(id) + if err != nil { + return nil, mapError("deleting auth key", err) + } + + return &deleteKeyOutput{}, 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 { + 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 len(pak.Tags) == 0 && pak.User != nil { + key.UserID = pak.User.StringID() + } + + 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 +}