Headscale — API Surface#

API types#

Four distinct surfaces coexist in a single binary:

  1. Tailscale control-plane protocol — Noise/TS2021 wire protocol for Tailscale clients (not a public API; it mirrors the closed Tailscale backend)
  2. gRPC admin API — single HeadscaleService served on two sockets simultaneously
  3. REST/HTTP admin API — auto-generated by grpc-gateway; proxied transparently over the Unix socket
  4. 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() and hscontrol/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):

MethodPathHandlerNotes
POST/ts2021NoiseUpgradeHandlerWebSocket upgrade → Noise/TS2021 hijack
GET/robots.txtRobotsHandlerDeny all bots
GET/healthHealthHandlerLiveness probe (no auth)
GET/versionVersionHandlerBuild version (no auth)
GET/keyKeyHandlerPublic Noise key for handshake
GET/register/{auth_id}authProvider.RegisterHandlerNode registration UI or redirect
GET/auth/{auth_id}authProvider.AuthHandlerAuth completion (web or OIDC)
GET/oidc/callbackOIDCCallbackHandlerOIDC redirect URI (only when OIDC enabled)
GET/appleAppleConfigMessagemacOS/iOS configuration profile page
GET/apple/{platform}ApplePlatformConfigPer-platform Apple config
GET/windowsWindowsConfigMessageWindows Tailscale config page
GET/swaggerheadscale.SwaggerUISwagger UI
GET/swagger/v1/openapiv2.jsonheadscale.SwaggerAPIv1Generated OpenAPI v2 spec
POST/verifyVerifyHandlerKey / token verification
*/derpDERPServer.DERPHandlerEmbedded DERP relay (optional)
*/derp/probeDERPProbeHandlerDERP health probe
*/derp/latency-checkDERPProbeHandlerDERP latency check
*/bootstrap-dnsDERPBootstrapDNSHandlerDERP region bootstrap DNS
*/api/v1/*grpcMux.ServeHTTPREST bridge to gRPC (auth required)
GET/favicon.icoFaviconHandler
GET/BlankHandler

Middleware chain (in order, app.go:464-475)#

  1. metrics.Collector — Prometheus request metrics; skips OPTIONS
  2. middleware.RequestID — attaches per-request UUID
  3. middleware.RealIP — extracts real IP from X-Forwarded-For / X-Real-IP
  4. middleware.RequestLogger — zerolog structured access log
  5. middleware.Recoverer — panic recovery → 500
  6. /api sub-router only: httpAuthenticationMiddleware — validates Authorization: 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/*): httpAuthenticationMiddleware validates the API key via state.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).

MethodPathHandlerStatus
GET/metricsmetrics.Handler()Prometheus metrics
POST/machine/registerRegistrationHandlerNode registration request
POST/machine/mapPollNetMapHandlerLong-poll network map stream
GET/machine/ssh/action/from/{src}/to/{dst}SSHActionHandlerSSH check-authorization
GET/machine/whoamiNotImplementedHandlerDebug identity echo (not impl)
POST/machine/set-dnsNotImplementedHandlerACME TXT DNS record (not impl)
PATCH/machine/set-device-attrNotImplementedHandlerDevice attributes (not impl)
POST/machine/audit-logNotImplementedHandlerAudit log (not impl)
POST/machine/id-tokenNotImplementedHandlerOIDC ID token (not impl)
POST/machine/feature/queryNotImplementedHandlerFeature availability (not impl)
POST/machine/update-healthNotImplementedHandlerHealth reporting (not impl)
POST/machine/c2nNotImplementedHandlerControl-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:

  1. http.MaxBytesReader — 1 MB body limit (DoS guard; no credential check at Noise layer)
  2. metrics.Collector
  3. middleware.RequestID
  4. middleware.RealIP
  5. middleware.RequestLogger
  6. middleware.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 stubs
  • headscale.pb.gw.go — grpc-gateway REST bridge

Service: HeadscaleService#

User management (4 RPCs):

RPCREST mappingDescription
CreateUserPOST /api/v1/userCreate a new user namespace
RenameUserPOST /api/v1/user/{old_id}/rename/{new_name}Rename a user
DeleteUserDELETE /api/v1/user/{id}Delete user and disassociate nodes
ListUsersGET /api/v1/userList all users

Pre-auth key management (4 RPCs):

RPCREST mappingDescription
CreatePreAuthKeyPOST /api/v1/preauthkeyCreate one-time or reusable registration key
ExpirePreAuthKeyPOST /api/v1/preauthkey/expireManually expire a key
DeletePreAuthKeyDELETE /api/v1/preauthkeyDelete a key
ListPreAuthKeysGET /api/v1/preauthkeyList keys for a user

Node management (10 RPCs):

RPCREST mappingDescription
GetNodeGET /api/v1/node/{node_id}Get single node
ListNodesGET /api/v1/nodeList all nodes
RegisterNodePOST /api/v1/node/registerRegister a pending node by key
DeleteNodeDELETE /api/v1/node/{node_id}Remove a node from the tailnet
ExpireNodePOST /api/v1/node/{node_id}/expireForce key expiry
RenameNodePOST /api/v1/node/{node_id}/rename/{new_name}Set display name
SetTagsPOST /api/v1/node/{node_id}/tagsAssign ACL tags (tagged nodes only)
SetApprovedRoutesPOST /api/v1/node/{node_id}/approve_routesApprove advertised subnet routes
BackfillNodeIPsPOST /api/v1/node/backfillipsAdmin: fill missing IPs
DebugCreateNodePOST /api/v1/debug/nodeDebug: create synthetic node

Auth workflow (3 RPCs):

RPCREST mappingDescription
AuthRegisterPOST /api/v1/auth/registerStart registration (returns auth URL)
AuthApprovePOST /api/v1/auth/approveApprove a pending auth request
AuthRejectPOST /api/v1/auth/rejectReject a pending auth request

API key management (4 RPCs):

RPCREST mappingDescription
CreateApiKeyPOST /api/v1/apikeyCreate an admin API key
ExpireApiKeyPOST /api/v1/apikey/expireExpire a key
ListApiKeysGET /api/v1/apikeyList keys
DeleteApiKeyDELETE /api/v1/apikey/{prefix}Delete by prefix

Policy (2 RPCs):

RPCREST mappingDescription
GetPolicyGET /api/v1/policyFetch current HuJSON ACL
SetPolicyPUT /api/v1/policyReplace ACL policy

Health (1 RPC):

RPCREST mappingDescription
HealthGET /api/v1/healthReturns database_connectivity boolean

Total: 28 RPCs (24 core + 4 debug/utility)

gRPC servers (two simultaneous instances)#

SocketAuthTLSPurpose
Unix socket (cfg.UnixSocket)NoneNoneCLI + grpc-gateway (local only)
TCP (cfg.GRPCAddr)API key via grpcAuthenticationInterceptorOptional (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) — reads authorization metadata key, validates Bearer <token> via state.ValidateAPIKey()
  • gRPC reflection is registered on both servers, enabling grpcurl introspection

CLI#

Framework#

  • Library: github.com/spf13/cobra
  • gRPC connection: each admin subcommand dials cfg.UnixSocket (or cfg.GRPCListenAddr for remote), creating a fresh HeadscaleServiceClient per invocation via the grpcRunE helper
  • Output formats: --output flag: 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 scripts

Flag patterns#

  • Identifiers: --identifier / -i (uint64 node ID) or --user / -u (string user name/ID)
  • Expiration: --expiration flag accepts RFC3339 or duration string
  • Confirmation: --force skips y/N prompts via confirmAction()
  • Version check: initConfig() does a GitHub release check at startup (suppressed in machine output mode and with disable_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 AuthProvider interface has two implementations (AuthProviderWeb, AuthProviderOIDC) selected at startup by config; adding a third would require changing source code.
  • Policy file source: mode: file vs mode: db is 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.