docs/v1-ogen/PLAN.md
This is the implementation plan for converting the Headscale v1 API from a
Protobuf gRPC service with a grpc-gateway REST facade to an OpenAPI 3.0 spec
driving ogen-generated server stubs and a Go client, backed unchanged by the
hscontrol/state layer. It is written so coding agents can execute the
remaining work autonomously; tests are the gate.
See CHANGES.md for every deliberate behaviour deviation.
type: integer, format: uint64), not protojson strings.application/problem+json).SCREAMING_SNAKE enums, RFC3339 timestamps, response envelopes, operation set and state side effects.| Concern | Path | Package |
|---|---|---|
| Spec (source of truth) | openapi/v1/headscale.yaml | — |
| Generated server+client | gen/api/v1/ | apiv1 (imported as oas) |
| Handlers / state adapter | hscontrol/api/v1/ | apiv1 |
| HTTP-parity tests + harness | hscontrol/servertest/ | servertest / servertest_test |
v1 and v2 must not import each other or share generated types — they converge
only on hscontrol/state. Codegen: go generate ./gen/api/v1/ (also run by
make generate via go generate ./...); CI freshness via
.github/workflows/check-generated.yml. Never hand-edit generated code; fix the
spec, never reach for x-ogen without recording in CHANGES.md why no spec
change works.
http.Handler) is mounted at /api/v1/* in app.go createRouter; chi leaves r.URL.Path intact so ogen's router matches the full path. ogen owns /api/v1 wholesale: un-migrated operations inherit oas.UnimplementedHandler (501) until their unit lands. Parallel endpoint agents therefore touch only their handler file + tests — never app.go.bearerAuth (http/bearer) global security. Server.HandleBearerAuth → state.ValidateAPIKey; any failure → 401. (Unix-socket auth bypass is added in the CLI phase, when the socket serves HTTP.)Server.NewError (ogen's convenient-error hook for security/decoding) and errorHandler (WithErrorHandler, for plain handler errors) both route through classify in hscontrol/api/v1/errors.go: typed *oas.ErrorStatusCode pass through; ogen framework errors use their Code() (security→401, decode→400); state errors via mapStateError (not-found sentinels→404, ErrPolicyUpdateIsDisabled→400, else 500). Handlers build expected errors with apiError/notFound/badRequest/mapStateError.state.* call sequence and change(...) notifications from hscontrol/grpcv1.go (policy reload on SetPolicy, route auto-approval on RegisterNode, change broadcasts on node mutations, etc.). The adapter holds state *state.State, cfg *types.Config, and change func(...change.Change) (wired to Headscale.Change); it does not import the parent hscontrol package (no import cycle).All paths keep the current google.api.http templates. Source of truth for
behaviour: hscontrol/grpcv1.go.
User — hscontrol/api/v1/users.go
POST /api/v1/user CreateUser → state.CreateUser; change(policyChanged).GET /api/v1/user ListUsers(id,name,email query) → ListUsersWithFilter (by name|email|id) else ListAllUsers; sort by id.POST /api/v1/user/{old_id}/rename/{new_name} RenameUser → GetUserByID,RenameUser,change(c),GetUserByName.DELETE /api/v1/user/{id} DeleteUser → GetUserByID,DeleteUser,change.PreAuthKey — preauthkeys.go
POST /api/v1/preauthkey CreatePreAuthKey → validate each aclTags (validateTag), optional GetUserByID, CreatePreAuthKey(userID, reusable, ephemeral, &expiration, aclTags).GET /api/v1/preauthkey ListPreAuthKeys → ListPreAuthKeys; sort by id.POST /api/v1/preauthkey/expire ExpirePreAuthKey → ExpirePreAuthKey(id).DELETE /api/v1/preauthkey DeletePreAuthKey(id query) → DeletePreAuthKey(id).Node — nodes.go
GET /api/v1/node ListNodes(user query) → GetUserByName+ListNodesByUser | ListNodes; nodesToProto (tagged→TaggedDevices user; subnetRoutes = primary+exit routes); sort by id.POST /api/v1/node/register RegisterNode(user,key query) → AuthIDFromString,GetUserByName,HandleNodeFromAuthPath(...,RegisterMethodCLI),AutoApproveRoutes,change(nodeChange,routeChange).POST /api/v1/node/backfillips BackfillNodeIPs(confirmed query) → require confirmed else error; BackfillNodeIPs.GET /api/v1/node/{node_id} GetNode → GetNodeByID; not found → 404.DELETE /api/v1/node/{node_id} DeleteNode → GetNodeByID(404),DeleteNode,change.POST /api/v1/node/{node_id}/tags SetTags → non-empty tags else 400; validateTag each; GetNodeByID(404); SetNodeTags(invalid→400); change.POST /api/v1/node/{node_id}/approve_routes SetApprovedRoutes → parse prefixes; exit-route expansion (AllIPv4/AllIPv6 → add both); sort+compact; SetApprovedRoutes(invalid→400); change; set subnetRoutes = GetNodePrimaryRoutes.POST /api/v1/node/{node_id}/expire ExpireNode(expiry,disable_expiry query) → both set → 400; disable → SetNodeExpiry(nil); else SetNodeExpiry(&expiry) (default now); change.POST /api/v1/node/{node_id}/rename/{new_name} RenameNode → RenameNode,change.POST /api/v1/debug/node DebugCreateNode → GetUserByName,StringToIPPrefix,AuthIDFromString,SetAuthCacheEntry; echo synthetic node.Auth — auth.go
POST /api/v1/auth/register AuthRegister → delegates to RegisterNode(key=authId,user).POST /api/v1/auth/approve AuthApprove → AuthIDFromString(400),GetAuthCacheEntry(404),FinishAuth({}).POST /api/v1/auth/reject AuthReject → AuthIDFromString(400),GetAuthCacheEntry(404),FinishAuth({Err}).ApiKey — apikeys.go
POST /api/v1/apikey CreateApiKey → CreateAPIKey(&expiration); returns raw apiKey string.GET /api/v1/apikey ListApiKeys → ListAPIKeys; sort by id.POST /api/v1/apikey/expire ExpireApiKey → getAPIKey (id xor prefix; neither/both→400),ExpireAPIKey.DELETE /api/v1/apikey/{prefix} DeleteApiKey(id query) → getAPIKey,DestroyAPIKey.Policy — policy.go
GET /api/v1/policy GetPolicy → DB mode: GetPolicy; file mode: read cfg.Policy.Path.PUT /api/v1/policy SetPolicy → DB mode only (ErrPolicyUpdateIsDisabled→400); ListNodes,SetPolicy,SSHPolicy(node0),SetPolicyInDB,ReloadPolicy,change(cs...).POST /api/v1/policy/check CheckPolicy → ListAllUsers,ListNodes,NewPolicyManager+SetPolicy (no persist; invalid→400).Health — health.go (done) — PingDB; ok→200, fail→500.
For each resource group, in this order, then /commit (Go style: api: <imperative>):
hscontrol/servertest/apiv1_<resource>_test.go (package servertest_test). Use srv := servertest.NewServer(t), client := srv.APIClient(t, srv.CreateAPIKey(t)), seed state via srv.State() / existing servertest helpers. Assert: success payloads (status, envelope, field values), every error path (404/400/401 with *apiv1.ErrorStatusCode status), and state side effects (re-read via srv.State()). Cover real behaviour, not just happy paths. Where the new contract intentionally differs from the old, the assertion encodes the new value and there is a matching CHANGES.md entry.*apiv1.Server in hscontrol/api/v1/<resource>.go, mirroring the grpcv1.go logic and side effects exactly. Convert between ogen types (*XxxReq, XxxParams, *XxxOK, oas.User/oas.Node/…) and state types. Build oas.OptXxx for optional fields; map state results to the response envelope. Errors: notFound/badRequest/mapStateError/apiError.integration/); reference them in the unit's acceptance.goimports -w, go test ./hscontrol/servertest/ -run TestAPIv1_<Resource>, then make fmt/make lint on touched files. Commit.A reusable proto→ogen conversion (oas.User from types.User, etc.) should live in hscontrol/api/v1/convert.go; the first agent to need a converter adds it there and others reuse it. Do not duplicate.
F1 spec → F2 codegen+CI → F3 server/adapter/auth/errors → F4 harness [DONE]
│
┌────────────────────────────────────────────────────┐
E1 User E2 PreAuthKey E3 Node E4 Auth E5 ApiKey E6 Policy E7 Health(done)
└────────────────────────────────────────────────────┘ (parallel; touch only own handler+tests)
F2 → C0 CLI client adapter → C1..C8 per-command (each needs its E-unit) (parallel)
all E* + all C* → X1 flip/auth → X2 remove proto/gRPC/gateway → X3 docs+served spec → X4 reconcile
apiv1_<resource>_test.go. Acceptance: that group's HTTP-parity tests green; behaviour matches grpcv1.go modulo CHANGES.md.cmd/headscale/cli/utils.go dial point to build apiv1.Client (local HTTP-over-unix, no key; remote HTTPS+HEADSCALE_CLI_API_KEY+cli.insecure). Serve ogen over the unix socket; add socket auth-bypass. C1–C8: convert each cmd/headscale/cli/*.go off the gRPC client; gate with integration/ CLI tests. Acceptance: no v1.HeadscaleServiceClient usage remains./api/v1 handler; ensure no chi auth duplication, remove any dead gateway wiring. X2: delete proto/headscale/v1, gen/go, gen/openapiv2, hscontrol/grpcv1.go(+test), gRPC servers, buf.*; drop grpc/grpc-gateway deps (verify Noise is independent). X3: serve openapi/v1/headscale.yaml at /swagger; update swagger.go embed + docs/. X4: reconcile PLAN.md/CHANGES.md to the built state; DoD checklist green.openapi/v1/headscale.yaml (all 27 ops, native int64, RFC7807 Problem, bearerAuth). ogen generates cleanly.gen/api/v1/generate.go (go generate), ogen tool dep in go.mod, CI freshness covers openapi/**.hscontrol/api/v1/{server,errors,auth,health}.go; mounted at /api/v1; gateway unmounted; gRPC servers retained.servertest.APIClient/CreateAPIKey; Health parity + 401 tests green.hscontrol/api/v1/{users,apikeys,preauthkeys,nodes,auth,policy,health}.go with HTTP-parity tests in hscontrol/servertest/apiv1_*_test.go (30 tests, all green).apiv1.WithSocketAuth); every cmd/headscale/cli/*.go command runs on the generated apiv1.Client; no gRPC client usage remains.grpcv1.go, the TCP gRPC server + interceptor, the .Proto()/RegisterMethodToV1Enum builders, proto/, gen/go/, gen/openapiv2/, and buf.gen.yaml are deleted; convert.go reads the state views directly; integration/ decodes into apiv1 types. grep finds zero proto references in any .go file. grpc/grpc-gateway are no longer direct go.mod deps. Proto lint/format targets removed from the Makefile./swagger serves the embedded openapi/v1/headscale.yaml (3.0).The conversion is complete. The full go test ./... suite is validatable here for
everything except the Docker-based integration/ package, which is
compile-clean (go vet ./integration/...) and ported to the new wire format; its
end-to-end run is gated on Docker (go run ./cmd/hi).
Every v1 op served by ogen over the state layer; HTTP + CLI tests cover every
endpoint and pass; CLI on the generated client (no gRPC client); proto/gRPC +
gateway + generated code removed; generated code matches committed spec
(CI green); docs + /swagger point at the 3.0 spec; v1 self-contained, no
cross-version coupling; current behaviour preserved except deliberate,
test-matched, CHANGES.md-recorded deviations.