Headscale — API Surface#
API types#
Four distinct surfaces coexist in a single binary:
- Tailscale control-plane protocol — Noise/TS2021 wire protocol for Tailscale clients (not a public API; it mirrors the closed Tailscale backend)
- gRPC admin API — single
HeadscaleServiceserved on two sockets simultaneously - REST/HTTP admin API — auto-generated by grpc-gateway; proxied transparently over the Unix socket
- CLI — Cobra binary that talks to the gRPC Unix socket
There is no plugin or extension system.
REST/HTTP API#
Router#
- Router:
github.com/go-chi/chi/v5(chi) - Route registration: explicitly in
hscontrol/app.go:createRouter()andhscontrol/noise.go - Entry point:
h.Serve()→h.createRouter(grpcGatewayMux)— single call builds the entire chi tree
Public HTTP router (h.createRouter)#
Chi router at h.cfg.Addr (default :8080 or TLS :443):
| Method | Path | Handler | Notes |
|---|---|---|---|
POST | /ts2021 | NoiseUpgradeHandler | WebSocket upgrade → Noise/TS2021 hijack |
GET | /robots.txt | RobotsHandler | Deny all bots |
GET | /health | HealthHandler | Liveness probe (no auth) |
GET | /version | VersionHandler | Build version (no auth) |
GET | /key | KeyHandler | Public Noise key for handshake |
GET | /register/{auth_id} | authProvider.RegisterHandler | Node registration UI or redirect |
GET | /auth/{auth_id} | authProvider.AuthHandler | Auth completion (web or OIDC) |
GET | /oidc/callback | OIDCCallbackHandler | OIDC redirect URI (only when OIDC enabled) |
GET | /apple | AppleConfigMessage | macOS/iOS configuration profile page |
GET | /apple/{platform} | ApplePlatformConfig | Per-platform Apple config |
GET | /windows | WindowsConfigMessage | Windows Tailscale config page |
GET | /swagger | headscale.SwaggerUI | Swagger UI |
GET | /swagger/v1/openapiv2.json | headscale.SwaggerAPIv1 | Generated OpenAPI v2 spec |
POST | /verify | VerifyHandler | Key / token verification |
* | /derp | DERPServer.DERPHandler | Embedded DERP relay (optional) |
* | /derp/probe | DERPProbeHandler | DERP health probe |
* | /derp/latency-check | DERPProbeHandler | DERP latency check |
* | /bootstrap-dns | DERPBootstrapDNSHandler | DERP region bootstrap DNS |
* | /api/v1/* | grpcMux.ServeHTTP | REST bridge to gRPC (auth required) |
GET | /favicon.ico | FaviconHandler | — |
GET | / | BlankHandler | — |
Middleware chain (in order, app.go:464-475)#
metrics.Collector— Prometheus request metrics; skips OPTIONSmiddleware.RequestID— attaches per-request UUIDmiddleware.RealIP— extracts real IP fromX-Forwarded-For/X-Real-IPmiddleware.RequestLogger— zerolog structured access logmiddleware.Recoverer— panic recovery → 500/apisub-router only:httpAuthenticationMiddleware— validatesAuthorization: Bearer <api-key>
Authentication (HTTP)#
- Tailscale endpoints (
/ts2021,/key,/register/*,/auth/*): no middleware auth; identity is established at the Noise layer - Admin REST (
/api/v1/*):httpAuthenticationMiddlewarevalidates the API key viastate.ValidateAPIKey()before the grpc-gateway handles the request
Tailscale control-plane sub-API (Noise router)#
After POST /ts2021 upgrades, the connection is hijacked and served by a separate chi router over HTTP/2 on a per-connection Noise channel (noise.go:118-184). All handlers here are authenticated implicitly by the Noise handshake (machine key).
| Method | Path | Handler | Status |
|---|---|---|---|
GET | /metrics | metrics.Handler() | Prometheus metrics |
POST | /machine/register | RegistrationHandler | Node registration request |
POST | /machine/map | PollNetMapHandler | Long-poll network map stream |
GET | /machine/ssh/action/from/{src}/to/{dst} | SSHActionHandler | SSH check-authorization |
GET | /machine/whoami | NotImplementedHandler | Debug identity echo (not impl) |
POST | /machine/set-dns | NotImplementedHandler | ACME TXT DNS record (not impl) |
PATCH | /machine/set-device-attr | NotImplementedHandler | Device attributes (not impl) |
POST | /machine/audit-log | NotImplementedHandler | Audit log (not impl) |
POST | /machine/id-token | NotImplementedHandler | OIDC ID token (not impl) |
POST | /machine/feature/query | NotImplementedHandler | Feature availability (not impl) |
POST | /machine/update-health | NotImplementedHandler | Health reporting (not impl) |
POST | /machine/c2n | NotImplementedHandler | Control-to-node channel (not impl) |
The not-implemented handlers exist as stubs to avoid 404s from Tailscale clients that call these endpoints. They return 501 Not Implemented with a log entry.
Noise router middleware:
http.MaxBytesReader— 1 MB body limit (DoS guard; no credential check at Noise layer)metrics.Collectormiddleware.RequestIDmiddleware.RealIPmiddleware.RequestLoggermiddleware.Recoverer
gRPC API#
Proto files#
Location: proto/headscale/v1/
headscale.proto— service definition (imports all others)user.proto,node.proto,preauthkey.proto,apikey.proto,auth.proto,policy.proto
Generated stubs: gen/go/headscale/v1/
headscale_grpc.pb.go— gRPC service stubsheadscale.pb.gw.go— grpc-gateway REST bridge
Service: HeadscaleService#
User management (4 RPCs):
| RPC | REST mapping | Description |
|---|---|---|
CreateUser | POST /api/v1/user | Create a new user namespace |
RenameUser | POST /api/v1/user/{old_id}/rename/{new_name} | Rename a user |
DeleteUser | DELETE /api/v1/user/{id} | Delete user and disassociate nodes |
ListUsers | GET /api/v1/user | List all users |
Pre-auth key management (4 RPCs):
| RPC | REST mapping | Description |
|---|---|---|
CreatePreAuthKey | POST /api/v1/preauthkey | Create one-time or reusable registration key |
ExpirePreAuthKey | POST /api/v1/preauthkey/expire | Manually expire a key |
DeletePreAuthKey | DELETE /api/v1/preauthkey | Delete a key |
ListPreAuthKeys | GET /api/v1/preauthkey | List keys for a user |
Node management (10 RPCs):
| RPC | REST mapping | Description |
|---|---|---|
GetNode | GET /api/v1/node/{node_id} | Get single node |
ListNodes | GET /api/v1/node | List all nodes |
RegisterNode | POST /api/v1/node/register | Register a pending node by key |
DeleteNode | DELETE /api/v1/node/{node_id} | Remove a node from the tailnet |
ExpireNode | POST /api/v1/node/{node_id}/expire | Force key expiry |
RenameNode | POST /api/v1/node/{node_id}/rename/{new_name} | Set display name |
SetTags | POST /api/v1/node/{node_id}/tags | Assign ACL tags (tagged nodes only) |
SetApprovedRoutes | POST /api/v1/node/{node_id}/approve_routes | Approve advertised subnet routes |
BackfillNodeIPs | POST /api/v1/node/backfillips | Admin: fill missing IPs |
DebugCreateNode | POST /api/v1/debug/node | Debug: create synthetic node |
Auth workflow (3 RPCs):
| RPC | REST mapping | Description |
|---|---|---|
AuthRegister | POST /api/v1/auth/register | Start registration (returns auth URL) |
AuthApprove | POST /api/v1/auth/approve | Approve a pending auth request |
AuthReject | POST /api/v1/auth/reject | Reject a pending auth request |
API key management (4 RPCs):
| RPC | REST mapping | Description |
|---|---|---|
CreateApiKey | POST /api/v1/apikey | Create an admin API key |
ExpireApiKey | POST /api/v1/apikey/expire | Expire a key |
ListApiKeys | GET /api/v1/apikey | List keys |
DeleteApiKey | DELETE /api/v1/apikey/{prefix} | Delete by prefix |
Policy (2 RPCs):
| RPC | REST mapping | Description |
|---|---|---|
GetPolicy | GET /api/v1/policy | Fetch current HuJSON ACL |
SetPolicy | PUT /api/v1/policy | Replace ACL policy |
Health (1 RPC):
| RPC | REST mapping | Description |
|---|---|---|
Health | GET /api/v1/health | Returns database_connectivity boolean |
Total: 28 RPCs (24 core + 4 debug/utility)
gRPC servers (two simultaneous instances)#
| Socket | Auth | TLS | Purpose |
|---|---|---|---|
Unix socket (cfg.UnixSocket) | None | None | CLI + grpc-gateway (local only) |
TCP (cfg.GRPCAddr) | API key via grpcAuthenticationInterceptor | Optional (TLS config) | Remote administration |
The grpc-gateway connects to the Unix socket (no-auth path) and re-exposes all RPCs as REST at /api/v1/*. Auth for the REST surface is enforced by httpAuthenticationMiddleware on the chi router, not inside the gRPC handler.
gRPC interceptors (TCP server only)#
grpc.ChainUnaryInterceptor(h.grpcAuthenticationInterceptor)— readsauthorizationmetadata key, validatesBearer <token>viastate.ValidateAPIKey()- gRPC reflection is registered on both servers, enabling
grpcurlintrospection
CLI#
Framework#
- Library:
github.com/spf13/cobra - gRPC connection: each admin subcommand dials
cfg.UnixSocket(orcfg.GRPCListenAddrfor remote), creating a freshHeadscaleServiceClientper invocation via thegrpcRunEhelper - Output formats:
--outputflag: empty (human table),json,json-line,yaml - Global flags:
--config / -c,--output / -o,--force
Command tree#
headscale
├── serve # Start the headscale server daemon
├── user
│ ├── create --name
│ ├── list
│ ├── destroy --id [--force]
│ └── rename --identifier --name
├── node
│ ├── list [--user]
│ ├── list-routes --identifier
│ ├── register --user --key
│ ├── expire --identifier [--expiry|--disable]
│ ├── rename --identifier <new-name>
│ ├── delete --identifier [--force]
│ ├── tag --identifier --tags
│ ├── approve-routes --identifier --routes
│ └── backfill-ips
├── preauthkeys
│ ├── list --user
│ ├── create --user [--reusable] [--ephemeral] [--expiration] [--tags]
│ ├── expire --id
│ └── delete --id
├── apikeys
│ ├── list
│ ├── create [--expiration]
│ ├── expire --prefix
│ └── delete --prefix
├── auth
│ ├── register --registration-id --user
│ ├── approve --registration-id --user
│ └── reject --registration-id
├── policy
│ ├── get
│ ├── set --file
│ └── check --file (local file validation, no gRPC call; suppresses all logging)
├── generate
│ └── private-key
├── debug
│ └── create-node (calls DebugCreateNode RPC)
├── health
├── version
├── mockoidc # Embedded mock OIDC provider (for integration testing)
├── dump-config # Print effective config as YAML
├── configtest # Validate config file syntax
└── completion # Shell completion scriptsFlag patterns#
- Identifiers:
--identifier / -i(uint64 node ID) or--user / -u(string user name/ID) - Expiration:
--expirationflag accepts RFC3339 or duration string - Confirmation:
--forceskipsy/Nprompts viaconfirmAction() - Version check:
initConfig()does a GitHub release check at startup (suppressed in machine output mode and withdisable_check_updates: true)
Plugin / Extension system#
There is no plugin or extension system. Headscale is a closed-binary monolith. The only external extension point is:
- Auth providers: the
AuthProviderinterface has two implementations (AuthProviderWeb,AuthProviderOIDC) selected at startup by config; adding a third would require changing source code. - Policy file source:
mode: filevsmode: dbis the only runtime switch.
Notable API surface observations#
1. grpc-gateway makes REST a free by-product#
The REST API at /api/v1/* is entirely generated from the proto annotations — there is no manually written REST handler. The grpc-gateway proxies requests over the Unix socket, which means REST calls incur an extra loopback hop but reuse all gRPC validation logic automatically. The Swagger spec is auto-generated and served at /swagger/v1/openapiv2.json.
2. Authentication split by surface#
The admin API uses opaque API keys (Bearer <hex-token>) validated by state.ValidateAPIKey(). The Tailscale-facing API uses Noise cryptographic identities (machine keys); there is no password or token on that side. This is architecturally sound: the two surfaces have different trust models, and mixing them would be an anti-pattern.
3. Not-implemented stubs preserve Tailscale compatibility#
Eleven /machine/* endpoints return 501 Not Implemented rather than 404. This is deliberate: as Tailscale clients evolve, they call new endpoints; a 404 would cause hard failures in some client versions, whereas 501 degrades gracefully and is easier to discover via logs. The stubs also document what Tailscale intends each endpoint to do.
4. Dual-socket gRPC avoids authentication complexity for local callers#
The CLI connects over a Unix socket with no credentials required. This means operators can run headscale node list without ever creating an API key — the socket permissions (cfg.UnixSocketPermission) provide OS-level access control. Remote callers (e.g., Terraform provider, external scripts) use the TCP socket with an API key. The grpc-gateway also goes through the Unix socket, so REST callers pay API-key auth only once at the chi middleware layer, not again inside gRPC.
5. OpenAPI spec is committed as generated code#
gen/openapiv2/ contains the generated .json OpenAPI spec, and SwaggerAPIv1 serves it at runtime. This means the swagger doc is always in sync with the proto definition, and third-party tooling (Terraform provider, client generators) can consume it without running a live server.