mirror of
https://github.com/juanfont/headscale.git
synced 2026-09-18 22:34:54 +09:00
api/v2: add Tailscale-compatible keys API
Add the v2 foundation — Basic/Bearer auth, scope scaffolding, the tailnet check, and the Tailscale error shape — plus the auth-key endpoints mapped onto Headscale pre-auth keys.
This commit is contained in:
@@ -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.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user