mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-08 17:10:21 +09:00
c9bbb5bdec
Reimplement the v1 API as a code-first Huma service in hscontrol/api/v1, a thin adapter over the state layer. Huma emits the OpenAPI 3.1 document (openapi/v1/headscale.yaml) from the Go operation and type definitions; cmd/gen-openapi writes it and the 3.0.3 downgrade the client is generated from. Responses reproduce the protojson wire contract (string-encoded 64-bit IDs, all fields emitted, RFC3339/null timestamps); errors map to the correct HTTP status (404/400/409, 500 for server faults) via mapError. Serve it on the chi router at /api/v1 behind the existing API-key middleware, and point /swagger at the emitted spec.
161 lines
5.3 KiB
Go
161 lines
5.3 KiB
Go
// Package apiv1 is the code-first Huma implementation of the Headscale v1 API.
|
|
// Handlers are a thin adapter over hscontrol/state; Huma emits the OpenAPI 3.1
|
|
// spec from the Go definitions (see Spec), and that spec drives the client.
|
|
//
|
|
// It depends only on the domain layer (hscontrol/state, hscontrol/types) via
|
|
// Backend, never on the hscontrol server package, so a future hscontrol/api/v2
|
|
// can sit beside it without either importing the other.
|
|
package apiv1
|
|
|
|
import (
|
|
"context"
|
|
"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 v1 API needs from the control plane:
|
|
// the state layer, the change-notification sink that distributes updates to
|
|
// connected nodes, and the config (only Policy.Mode and Policy.Path are read).
|
|
type Backend struct {
|
|
State *state.State
|
|
Change func(...change.Change)
|
|
Cfg *types.Config
|
|
}
|
|
|
|
// NewAPI builds the v1 Huma API on the given chi router and registers every
|
|
// operation. Auth is enforced by a Huma middleware driven by each operation's
|
|
// declared bearer security (see authMiddleware); locally-trusted requests
|
|
// bypass it via WithLocalTrust.
|
|
func NewAPI(router chi.Router, backend Backend) huma.API {
|
|
config := huma.DefaultConfig("Headscale API", "v1")
|
|
config.Info.Description = "Headscale control server API."
|
|
|
|
// Version the OpenAPI/docs routes under /api/v1 so a future v2 owns its own.
|
|
// These register as plain mux routes, not operations, so they never appear
|
|
// in the emitted spec or client.
|
|
config.OpenAPIPath = "/api/v1/openapi"
|
|
config.DocsPath = "/api/v1/docs"
|
|
|
|
// The v1 API does not emit "$schema".
|
|
config.SchemasPath = ""
|
|
|
|
// Drop the default schema-link create hook: it injects a "$schema" property
|
|
// and Link header into every response, which the v1 contract omits.
|
|
config.CreateHooks = nil
|
|
|
|
config.Components.SecuritySchemes = map[string]*huma.SecurityScheme{
|
|
"bearer": {
|
|
Type: "http",
|
|
Scheme: "bearer",
|
|
},
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// bearerAuth is the security requirement applied to every operation: all
|
|
// /api/v1 routes require an API key.
|
|
var bearerAuth = []map[string][]string{{"bearer": {}}}
|
|
|
|
// registrations is populated by each resource file's init(), so adding a
|
|
// resource group means adding a file rather than editing a shared point. Huma
|
|
// sorts the emitted spec, so init order does not affect output.
|
|
var registrations []func(huma.API, Backend)
|
|
|
|
// register wires up every operation contributed by the resource files.
|
|
func register(api huma.API, b Backend) {
|
|
for _, fn := range registrations {
|
|
fn(api, b)
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
|
|
// Spec30 emits the document downgraded to OpenAPI 3.0.3, needed because the
|
|
// client generator (oapi-codegen v2) cannot yet read the 3.1 spec.
|
|
func Spec30() ([]byte, error) {
|
|
api := NewAPI(chi.NewMux(), Backend{})
|
|
return api.OpenAPI().DowngradeYAML()
|
|
}
|
|
|
|
// Handler builds the v1 API on a fresh mux and returns both. Callers mount the
|
|
// mux and may use mux.Match to detect which paths this API serves.
|
|
func Handler(backend Backend) (*chi.Mux, huma.API) {
|
|
mux := chi.NewMux()
|
|
api := NewAPI(mux, backend)
|
|
|
|
return mux, api
|
|
}
|
|
|
|
// localTrustKey marks a request as arriving over a locally-trusted transport;
|
|
// the auth middleware skips authentication for such requests.
|
|
type localTrustKey struct{}
|
|
|
|
// WithLocalTrust wraps a handler so its requests bypass API-key authentication.
|
|
// The unix socket uses this — access to the socket is the trust boundary — as
|
|
// do in-process tests that exercise the mux directly.
|
|
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 is a pure gate enforcing the bearer API key for any operation
|
|
// that declares security; the v1 handlers do not read caller identity.
|
|
// Locally-trusted requests and operations without declared security pass
|
|
// through. b.State is nil only during spec emission, where no request is
|
|
// served, so it is never dereferenced there.
|
|
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 := strings.CutPrefix(ctx.Header("Authorization"), "Bearer ")
|
|
if !ok {
|
|
_ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "Unauthorized")
|
|
|
|
return
|
|
}
|
|
|
|
valid, err := b.State.ValidateAPIKey(token)
|
|
if err != nil || !valid {
|
|
_ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "Unauthorized")
|
|
|
|
return
|
|
}
|
|
|
|
next(ctx)
|
|
}
|
|
}
|