15 KiB
Plan: Headscale v1 API — gRPC/Protobuf → ogen OpenAPI 3.0
This is the implementation plan for converting the Headscale v1 API from a
Protobuf gRPC service with a grpc-gateway REST facade to an OpenAPI 3.0 spec
driving ogen-generated server stubs and a Go client, backed unchanged by the
hscontrol/state layer. It is written so coding agents can execute the
remaining work autonomously; tests are the gate.
See CHANGES.md for every deliberate behaviour deviation.
Locked decisions
- Integer IDs are native JSON numbers (
type: integer, format: uint64), not protojson strings. - Errors are RFC 7807 problem documents (
application/problem+json). - Local CLI talks HTTP over the existing unix socket (no API key; socket permissions are the trust boundary). Remote stays HTTPS + bearer key.
- Preserved by default: HTTP verbs/paths, camelCase JSON keys,
SCREAMING_SNAKEenums, RFC3339 timestamps, response envelopes, operation set and state side effects.
Package layout (v1 self-contained; v2 drops in as a sibling)
| Concern | Path | Package |
|---|---|---|
| Spec (source of truth) | openapi/v1/headscale.yaml |
— |
| Generated server+client | gen/api/v1/ |
apiv1 (imported as oas) |
| Handlers / state adapter | hscontrol/api/v1/ |
apiv1 |
| HTTP-parity tests + harness | hscontrol/servertest/ |
servertest / servertest_test |
v1 and v2 must not import each other or share generated types — they converge
only on hscontrol/state. Codegen: go generate ./gen/api/v1/ (also run by
make generate via go generate ./...); CI freshness via
.github/workflows/check-generated.yml. Never hand-edit generated code; fix the
spec, never reach for x-ogen without recording in CHANGES.md why no spec
change works.
Architecture
- Routing. chi is the top mux. The ogen server (
http.Handler) is mounted at/api/v1/*inapp.gocreateRouter; chi leavesr.URL.Pathintact so ogen's router matches the full path. ogen owns/api/v1wholesale: un-migrated operations inheritoas.UnimplementedHandler(501) until their unit lands. Parallel endpoint agents therefore touch only their handler file + tests — neverapp.go. - Auth. Spec declares
bearerAuth(http/bearer) global security.Server.HandleBearerAuth→state.ValidateAPIKey; any failure → 401. (Unix-socket auth bypass is added in the CLI phase, when the socket serves HTTP.) - Errors.
Server.NewError(ogen's convenient-error hook for security/decoding) anderrorHandler(WithErrorHandler, for plain handler errors) both route throughclassifyinhscontrol/api/v1/errors.go: typed*oas.ErrorStatusCodepass through; ogen framework errors use theirCode()(security→401, decode→400); state errors viamapStateError(not-found sentinels→404,ErrPolicyUpdateIsDisabled→400, else 500). Handlers build expected errors withapiError/notFound/badRequest/mapStateError. - Side effects. Handlers replicate the exact
state.*call sequence andchange(...)notifications fromhscontrol/grpcv1.go(policy reload on SetPolicy, route auto-approval on RegisterNode, change broadcasts on node mutations, etc.). The adapter holdsstate *state.State,cfg *types.Config, andchange func(...change.Change)(wired toHeadscale.Change); it does not import the parenthscontrolpackage (no import cycle). - Transitional gRPC. The gRPC unix-socket and TCP servers keep running through the endpoint phase so the existing CLI works; they (and proto/gateway code) are removed in cutover.
Operation inventory (backbone) → state mapping
All paths keep the current google.api.http templates. Source of truth for
behaviour: hscontrol/grpcv1.go.
User — hscontrol/api/v1/users.go
POST /api/v1/userCreateUser →state.CreateUser;change(policyChanged).GET /api/v1/userListUsers(id,name,emailquery) →ListUsersWithFilter(by name|email|id) elseListAllUsers; 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/preauthkeyCreatePreAuthKey → validate eachaclTags(validateTag), optionalGetUserByID,CreatePreAuthKey(userID, reusable, ephemeral, &expiration, aclTags).GET /api/v1/preauthkeyListPreAuthKeys →ListPreAuthKeys; sort by id.POST /api/v1/preauthkey/expireExpirePreAuthKey →ExpirePreAuthKey(id).DELETE /api/v1/preauthkeyDeletePreAuthKey(idquery) →DeletePreAuthKey(id).
Node — nodes.go
GET /api/v1/nodeListNodes(userquery) →GetUserByName+ListNodesByUser|ListNodes;nodesToProto(tagged→TaggedDevicesuser;subnetRoutes= primary+exit routes); sort by id.POST /api/v1/node/registerRegisterNode(user,keyquery) →AuthIDFromString,GetUserByName,HandleNodeFromAuthPath(...,RegisterMethodCLI),AutoApproveRoutes,change(nodeChange,routeChange).POST /api/v1/node/backfillipsBackfillNodeIPs(confirmedquery) → 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}/tagsSetTags → non-empty tags else 400;validateTageach;GetNodeByID(404);SetNodeTags(invalid→400);change.POST /api/v1/node/{node_id}/approve_routesSetApprovedRoutes → parse prefixes; exit-route expansion (AllIPv4/AllIPv6 → add both); sort+compact;SetApprovedRoutes(invalid→400);change; setsubnetRoutes=GetNodePrimaryRoutes.POST /api/v1/node/{node_id}/expireExpireNode(expiry,disable_expiryquery) → both set → 400; disable →SetNodeExpiry(nil); elseSetNodeExpiry(&expiry)(default now);change.POST /api/v1/node/{node_id}/rename/{new_name}RenameNode →RenameNode,change.POST /api/v1/debug/nodeDebugCreateNode →GetUserByName,StringToIPPrefix,AuthIDFromString,SetAuthCacheEntry; echo synthetic node.
Auth — auth.go
POST /api/v1/auth/registerAuthRegister → delegates to RegisterNode(key=authId,user).POST /api/v1/auth/approveAuthApprove →AuthIDFromString(400),GetAuthCacheEntry(404),FinishAuth({}).POST /api/v1/auth/rejectAuthReject →AuthIDFromString(400),GetAuthCacheEntry(404),FinishAuth({Err}).
ApiKey — apikeys.go
POST /api/v1/apikeyCreateApiKey →CreateAPIKey(&expiration); returns rawapiKeystring.GET /api/v1/apikeyListApiKeys →ListAPIKeys; sort by id.POST /api/v1/apikey/expireExpireApiKey →getAPIKey(id xor prefix; neither/both→400),ExpireAPIKey.DELETE /api/v1/apikey/{prefix}DeleteApiKey(idquery) →getAPIKey,DestroyAPIKey.
Policy — policy.go
GET /api/v1/policyGetPolicy → DB mode:GetPolicy; file mode: readcfg.Policy.Path.PUT /api/v1/policySetPolicy → DB mode only (ErrPolicyUpdateIsDisabled→400);ListNodes,SetPolicy,SSHPolicy(node0),SetPolicyInDB,ReloadPolicy,change(cs...).POST /api/v1/policy/checkCheckPolicy →ListAllUsers,ListNodes,NewPolicyManager+SetPolicy(no persist; invalid→400).
Health — health.go (done) — PingDB; ok→200, fail→500.
Handler authoring recipe (for endpoint agents)
For each resource group, in this order, then /commit (Go style: api: <imperative>):
- Tests first. Add
hscontrol/servertest/apiv1_<resource>_test.go(packageservertest_test). Usesrv := servertest.NewServer(t),client := srv.APIClient(t, srv.CreateAPIKey(t)), seed state viasrv.State()/ existingservertesthelpers. Assert: success payloads (status, envelope, field values), every error path (404/400/401 with*apiv1.ErrorStatusCodestatus), and state side effects (re-read viasrv.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. - Implement the group's handler methods on
*apiv1.Serverinhscontrol/api/v1/<resource>.go, mirroring thegrpcv1.gologic and side effects exactly. Convert between ogen types (*XxxReq,XxxParams,*XxxOK,oas.User/oas.Node/…) and state types. Buildoas.OptXxxfor optional fields; map state results to the response envelope. Errors:notFound/badRequest/mapStateError/apiError. - CLI tests for the group's commands belong to the CLI phase (
integration/); reference them in the unit's acceptance. - CHANGES.md: add an entry for any deviation; if a test had to encode a non-preserved behaviour, it must correspond to an entry.
- Run
goimports -w,go test ./hscontrol/servertest/ -run TestAPIv1_<Resource>, thenmake fmt/make linton touched files. Commit.
A reusable proto→ogen conversion (oas.User from types.User, etc.) should live in hscontrol/api/v1/convert.go; the first agent to need a converter adds it there and others reuse it. Do not duplicate.
Work units & dependency map
F1 spec → F2 codegen+CI → F3 server/adapter/auth/errors → F4 harness [DONE]
│
┌────────────────────────────────────────────────────┐
E1 User E2 PreAuthKey E3 Node E4 Auth E5 ApiKey E6 Policy E7 Health(done)
└────────────────────────────────────────────────────┘ (parallel; touch only own handler+tests)
F2 → C0 CLI client adapter → C1..C8 per-command (each needs its E-unit) (parallel)
all E* + all C* → X1 flip/auth → X2 remove proto/gRPC/gateway → X3 docs+served spec → X4 reconcile
- E1–E7: each owns its handler file +
apiv1_<resource>_test.go. Acceptance: that group's HTTP-parity tests green; behaviour matchesgrpcv1.gomodulo CHANGES.md. - C0: rework
cmd/headscale/cli/utils.godial point to buildapiv1.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 eachcmd/headscale/cli/*.gooff the gRPC client; gate withintegration/CLI tests. Acceptance: nov1.HeadscaleServiceClientusage remains. - X1: ogen is already the sole
/api/v1handler; ensure no chi auth duplication, remove any dead gateway wiring. X2: deleteproto/headscale/v1,gen/go,gen/openapiv2,hscontrol/grpcv1.go(+test), gRPC servers,buf.*; drop grpc/grpc-gateway deps (verify Noise is independent). X3: serveopenapi/v1/headscale.yamlat/swagger; updateswagger.goembed +docs/. X4: reconcile PLAN.md/CHANGES.md to the built state; DoD checklist green.
Status
- F1 spec —
openapi/v1/headscale.yaml(all 27 ops, native int64, RFC7807Problem, bearerAuth). ogen generates cleanly. - F2 codegen —
gen/api/v1/generate.go(go generate), ogen tool dep ingo.mod, CI freshness coversopenapi/**. - F3 server/adapter/auth/errors —
hscontrol/api/v1/{server,errors,auth,health}.go; mounted at/api/v1; gateway unmounted; gRPC servers retained. - F4 harness —
servertest.APIClient/CreateAPIKey; Health parity + 401 tests green. - E1–E7 endpoints — all 27 operations implemented in
hscontrol/api/v1/{users,apikeys,preauthkeys,nodes,auth,policy,health}.gowith HTTP-parity tests inhscontrol/servertest/apiv1_*_test.go(30 tests, all green). - C0–C8 CLI migration — server serves the API over the unix socket (auth bypassed via
apiv1.WithSocketAuth); everycmd/headscale/cli/*.gocommand runs on the generatedapiv1.Client; nov1.HeadscaleServiceClientusage remains. - X3 served spec —
/swaggerserves the embeddedopenapi/v1/headscale.yaml(3.0). - X1/X2 proto/gRPC code removal — see "Remaining cutover" below.
Remaining cutover (peelable follow-up)
The new stack is fully functional: ogen serves every v1 operation, the CLI runs
on the generated client, and 30 server-level parity tests pass. What remains is
deleting the now-unused proto/gRPC code, kept deliberately as the final,
isolatable step (per the brief, cutover/removal lands last so it can be peeled
into a follow-up PR). convert.go still bridges via the proto .Proto()
builders, which is why the proto message types are retained for now. Concretely:
hscontrol/api/v1/convert.go— rewrite to read the state types directly (NodeView.AsStruct(),*types.User,*types.PreAuthKey/PreAuthKeyNew,*types.APIKey), replicating each.Proto()method's logic (masked key prefix,Username()fallback,RegisterMethodstring→enum, online/route handling). The 30 parity tests gate this rewrite.hscontrol/state/state.go— replace the twopak.Proto().GetAclTags()uses withpak.Tags.- Remove
hscontrol/grpcv1.go(+grpcv1_test.go), the TCP gRPC server andgrpcAuthenticationInterceptor/checkBearerToken/httpAuthenticationMiddlewareinapp.go, and the.Proto()/RegisterMethodToV1Enummethods inhscontrol/types/{users,node,preauth_key,api_key}.go. - Delete
proto/,gen/go/,gen/openapiv2/,buf.gen.yaml,proto/buf.yaml; dropgo-grpc/grpc-gateway/openapiv2from codegen and the grpc / grpc-gateway deps fromgo.mod(verify Noise/control protocol is independent). - Update the
integration/tests that unmarshal CLI/HTTP output via the v1 proto types (cli_test.go,api_auth_test.go,auth_*_test.go,general_test.go,tags_test.go,route_test.go,acl_test.go) andhscontrol/types/node_test.goto the new wire format (native int64, RFC 7807,apiv1types). These run only under Docker (go run ./cmd/hi), so they are validated in that follow-up.
Definition of done
Every v1 op served by ogen over the state layer; HTTP + CLI tests cover every
endpoint and pass; CLI on the generated client (no gRPC client); proto/gRPC +
gateway + generated code removed; generated code matches committed spec
(CI green); docs + /swagger point at the 3.0 spec; v1 self-contained, no
cross-version coupling; current behaviour preserved except deliberate,
test-matched, CHANGES.md-recorded deviations.