From 562fcc1401070a6a3c7c74c2d834d50547687804 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 17 Jun 2026 15:12:22 +0000 Subject: [PATCH] hscontrol: serve v1 API via ogen, replace grpc-gateway facade Mount the ogen-generated server at /api/v1 with bearer-auth and RFC 7807 errors backed by the state layer; gRPC servers stay for the CLI. Add a servertest API client harness and Health parity tests. --- hscontrol/api/v1/auth.go | 27 ++++ hscontrol/api/v1/errors.go | 143 ++++++++++++++++++++++ hscontrol/api/v1/health.go | 19 +++ hscontrol/api/v1/server.go | 53 ++++++++ hscontrol/app.go | 52 ++++---- hscontrol/servertest/apiv1.go | 63 ++++++++++ hscontrol/servertest/apiv1_health_test.go | 48 ++++++++ 7 files changed, 374 insertions(+), 31 deletions(-) create mode 100644 hscontrol/api/v1/auth.go create mode 100644 hscontrol/api/v1/errors.go create mode 100644 hscontrol/api/v1/health.go create mode 100644 hscontrol/api/v1/server.go create mode 100644 hscontrol/servertest/apiv1.go create mode 100644 hscontrol/servertest/apiv1_health_test.go diff --git a/hscontrol/api/v1/auth.go b/hscontrol/api/v1/auth.go new file mode 100644 index 00000000..da0c0402 --- /dev/null +++ b/hscontrol/api/v1/auth.go @@ -0,0 +1,27 @@ +package apiv1 + +import ( + "context" + "net/http" + + oas "github.com/juanfont/headscale/gen/api/v1" +) + +// HandleBearerAuth validates the API key bearer token against the state layer. +// A missing or malformed Authorization header is reported by ogen before this +// is reached. Any validation failure — a malformed/unknown key (which +// [state.State.ValidateAPIKey] reports as an error) or an expired/invalid one — +// is a 401, matching the previous middleware which rejected every such case +// with Unauthorized. +func (s *Server) HandleBearerAuth( + ctx context.Context, + _ oas.OperationName, + t oas.BearerAuth, +) (context.Context, error) { + valid, err := s.state.ValidateAPIKey(t.Token) + if err != nil || !valid { + return ctx, apiError(http.StatusUnauthorized, "invalid API key") + } + + return ctx, nil +} diff --git a/hscontrol/api/v1/errors.go b/hscontrol/api/v1/errors.go new file mode 100644 index 00000000..9be73a94 --- /dev/null +++ b/hscontrol/api/v1/errors.go @@ -0,0 +1,143 @@ +package apiv1 + +import ( + "context" + "encoding/json" + "errors" + "net/http" + + oas "github.com/juanfont/headscale/gen/api/v1" + "github.com/juanfont/headscale/hscontrol/state" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/rs/zerolog/log" + "gorm.io/gorm" +) + +const problemContentType = "application/problem+json" + +// apiError builds an RFC 7807 problem response with the given HTTP status and +// detail. Handlers return it so expected errors render as problem documents +// with the correct status code (ogen encodes [oas.ErrorStatusCode] directly, +// without going through [errorHandler]). +func apiError(status int, detail string) *oas.ErrorStatusCode { + return &oas.ErrorStatusCode{ + StatusCode: status, + Response: oas.Problem{ + Title: oas.NewOptString(http.StatusText(status)), + //nolint:gosec // G115: status is an HTTP status code, always within int32. + Status: oas.NewOptInt32(int32(status)), + Detail: oas.NewOptString(detail), + }, + } +} + +func notFound( + detail string, +) *oas.ErrorStatusCode { + return apiError(http.StatusNotFound, detail) +} + +func badRequest( + detail string, +) *oas.ErrorStatusCode { + return apiError(http.StatusBadRequest, detail) +} + +func internalError(detail string) *oas.ErrorStatusCode { + return apiError(http.StatusInternalServerError, detail) +} + +// mapStateError classifies an error returned by the state layer into an HTTP +// problem response. Not-found sentinels become 404; everything else is a 500. +// Handlers that need a different status (e.g. validation 400) build the problem +// explicitly with [badRequest] rather than routing through here. +func mapStateError(err error) *oas.ErrorStatusCode { + switch { + case errors.Is(err, gorm.ErrRecordNotFound), + errors.Is(err, state.ErrNodeNotFound): + return notFound(err.Error()) + case errors.Is(err, types.ErrPolicyUpdateIsDisabled): + return badRequest(err.Error()) + default: + return internalError(err.Error()) + } +} + +// NewError converts an error that ogen raises outside a handler return value — +// failed authentication, request decoding — into a typed problem response. ogen +// calls it from the generated security/decoding paths; the resulting +// [oas.ErrorStatusCode] is then encoded as application/problem+json. +func (s *Server) NewError(_ context.Context, err error) *oas.ErrorStatusCode { + return classify(err) +} + +// errorHandler renders problems for the remaining ogen error path: a plain +// error returned from a handler. Expected errors are returned as +// [oas.ErrorStatusCode] (encoded directly by ogen), so this is the safety net +// for anything else. +func errorHandler( + _ context.Context, + w http.ResponseWriter, + _ *http.Request, + err error, +) { + writeProblem(w, classify(err)) +} + +// classify maps an arbitrary error to a problem response. An already-typed +// [oas.ErrorStatusCode] passes through; ogen's framework errors carry their own +// HTTP status via Code() (security -> 401, decode -> 400); everything else is +// classified by [mapStateError]. +func classify(err error) *oas.ErrorStatusCode { + var esc *oas.ErrorStatusCode + if errors.As(err, &esc) { + return esc + } + + var coder interface{ Code() int } + if errors.As(err, &coder) { + return apiError(coder.Code(), err.Error()) + } + + return mapStateError(err) +} + +func writeProblem(w http.ResponseWriter, esc *oas.ErrorStatusCode) { + w.Header().Set("Content-Type", problemContentType) + w.WriteHeader(esc.StatusCode) + + p := esc.Response + + status := esc.StatusCode + if v, ok := p.Status.Get(); ok { + status = int(v) + } + + body := problemJSON{ + Title: p.Title.Or(""), + Status: status, + Detail: p.Detail.Or(""), + } + if v, ok := p.Type.Get(); ok { + body.Type = v.String() + } + + if v, ok := p.Instance.Get(); ok { + body.Instance = v.String() + } + + err := json.NewEncoder(w).Encode(body) + if err != nil { + log.Error().Err(err).Msg("writing problem response failed") + } +} + +// problemJSON mirrors [oas.Problem] for hand-written encoding in +// [errorHandler]; ogen's own encoder is package-private. +type problemJSON struct { + Type string `json:"type,omitempty"` + Title string `json:"title,omitempty"` + Status int `json:"status,omitempty"` + Detail string `json:"detail,omitempty"` + Instance string `json:"instance,omitempty"` +} diff --git a/hscontrol/api/v1/health.go b/hscontrol/api/v1/health.go new file mode 100644 index 00000000..2e23d09b --- /dev/null +++ b/hscontrol/api/v1/health.go @@ -0,0 +1,19 @@ +package apiv1 + +import ( + "context" + + oas "github.com/juanfont/headscale/gen/api/v1" +) + +// Health reports server health, including database connectivity. A failed +// database ping is a 500; the gRPC implementation likewise returned the ping +// error (the body's databaseConnectivity flag was never observable on failure). +func (s *Server) Health(ctx context.Context) (*oas.HealthOK, error) { + err := s.state.PingDB(ctx) + if err != nil { + return nil, internalError("pinging database: " + err.Error()) + } + + return &oas.HealthOK{DatabaseConnectivity: oas.NewOptBool(true)}, nil +} diff --git a/hscontrol/api/v1/server.go b/hscontrol/api/v1/server.go new file mode 100644 index 00000000..dcecb4f6 --- /dev/null +++ b/hscontrol/api/v1/server.go @@ -0,0 +1,53 @@ +// Package apiv1 implements the Headscale v1 HTTP API: thin handlers that +// adapt the ogen-generated server interface ([oas.Handler]) onto the shared +// state layer ([state.State]). Business logic lives in the state layer; these +// handlers only translate between HTTP request/response types and state calls. +// +// The package deliberately does not import the parent hscontrol package: it +// depends only on state, types, and the generated API package, so that +// hscontrol can mount it without an import cycle. +package apiv1 + +import ( + "net/http" + + oas "github.com/juanfont/headscale/gen/api/v1" + "github.com/juanfont/headscale/hscontrol/state" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/juanfont/headscale/hscontrol/types/change" +) + +// Server implements the generated [oas.Handler] and [oas.SecurityHandler]. +// +// Operations that have not been migrated from the gRPC stack yet are inherited +// from [oas.UnimplementedHandler] and return 501; each resource group replaces +// its stubs as it is converted. +type Server struct { + oas.UnimplementedHandler + + state *state.State + cfg *types.Config + change changeFunc +} + +// changeFunc distributes state changes to connected nodes. In production this +// is [github.com/juanfont/headscale/hscontrol.Headscale.Change]; tests may pass +// a no-op or a recorder. +type changeFunc func(...change.Change) + +// NewHandler builds the v1 API as an [http.Handler] ready to mount at +// /api/v1. changeFn distributes [change.Change]s produced by mutating +// operations; it must not be nil (pass a no-op if changes are irrelevant). +func NewHandler( + st *state.State, + cfg *types.Config, + changeFn func(...change.Change), +) (http.Handler, error) { + s := &Server{ + state: st, + cfg: cfg, + change: changeFn, + } + + return oas.NewServer(s, s, oas.WithErrorHandler(errorHandler)) +} diff --git a/hscontrol/app.go b/hscontrol/app.go index 7ce63261..4862e985 100644 --- a/hscontrol/app.go +++ b/hscontrol/app.go @@ -24,9 +24,9 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/metrics" - grpcRuntime "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" "github.com/juanfont/headscale" v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + apiv1 "github.com/juanfont/headscale/hscontrol/api/v1" "github.com/juanfont/headscale/hscontrol/capver" "github.com/juanfont/headscale/hscontrol/db" "github.com/juanfont/headscale/hscontrol/derp" @@ -48,7 +48,6 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials" - "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/metadata" "google.golang.org/grpc/peer" "google.golang.org/grpc/reflection" @@ -535,7 +534,7 @@ func securityHeaders(next http.Handler) http.Handler { }) } -func (h *Headscale) createRouter(grpcMux *grpcRuntime.ServeMux) *chi.Mux { +func (h *Headscale) createRouter(apiV1 http.Handler) *chi.Mux { r := chi.NewRouter() r.Use(metrics.Collector(metrics.CollectorOpts{ Host: false, @@ -585,10 +584,11 @@ func (h *Headscale) createRouter(grpcMux *grpcRuntime.ServeMux) *chi.Mux { r.HandleFunc("/bootstrap-dns", derpServer.DERPBootstrapDNSHandler(h.state.DERPMap())) } - r.Route("/api", func(r chi.Router) { - r.Use(h.httpAuthenticationMiddleware) - r.HandleFunc("/v1/*", grpcMux.ServeHTTP) - }) + // v1 API (ogen). The generated server authenticates requests itself via + // its SecurityHandler (bearer API key), so it is mounted outside the legacy + // auth middleware. chi leaves r.URL.Path intact, so ogen's router sees the + // full /api/v1/... path. + r.Handle("/api/v1/*", apiV1) // Ping response endpoint: receives HEAD from clients responding // to a [tailcfg.PingRequest]. The unguessable ping ID serves as authentication. r.Head("/machine/ping-response", h.PingResponseHandler) @@ -731,27 +731,6 @@ func (h *Headscale) Serve() error { return fmt.Errorf("changing gRPC socket permission: %w", err) } - grpcGatewayMux := grpcRuntime.NewServeMux() - - // Make the grpc-gateway connect to grpc over socket - grpcGatewayConn, err := grpc.Dial( //nolint:staticcheck // SA1019: deprecated but supported in 1.x - h.cfg.UnixSocket, - []grpc.DialOption{ - grpc.WithTransportCredentials(insecure.NewCredentials()), - grpc.WithContextDialer(util.GrpcSocketDialer), - }..., - ) - if err != nil { - return fmt.Errorf("setting up gRPC gateway via socket: %w", err) - } - - // Connect to the gRPC server over localhost to skip - // the authentication. - err = v1.RegisterHeadscaleServiceHandler(ctx, grpcGatewayMux, grpcGatewayConn) - if err != nil { - return fmt.Errorf("registering Headscale API service to gRPC: %w", err) - } - // Start the local gRPC server without TLS and without authentication grpcSocket := grpc.NewServer( // Uncomment to debug grpc communication. @@ -831,7 +810,12 @@ func (h *Headscale) Serve() error { // // This is the regular router that we expose // over our main Addr - router := h.createRouter(grpcGatewayMux) + apiV1Handler, err := apiv1.NewHandler(h.state, h.cfg, h.Change) + if err != nil { + return fmt.Errorf("building v1 API handler: %w", err) + } + + router := h.createRouter(apiV1Handler) httpServer := &http.Server{ Addr: h.cfg.Addr, @@ -994,7 +978,6 @@ func (h *Headscale) Serve() error { } httpListener.Close() - grpcGatewayConn.Close() // Stop listening (and unlink the socket if unix type): info("closing socket listener") @@ -1158,7 +1141,14 @@ func (h *Headscale) Change(cs ...change.Change) { // The handler serves the Tailscale control protocol including the /key // endpoint and /ts2021 Noise upgrade path. func (h *Headscale) HTTPHandler() http.Handler { - return h.createRouter(grpcRuntime.NewServeMux()) + apiV1, err := apiv1.NewHandler(h.state, h.cfg, h.Change) + if err != nil { + // NewServer only fails on a nil handler or security handler, which + // cannot happen here; treat it as a programming error. + panic(fmt.Sprintf("building v1 API handler: %v", err)) + } + + return h.createRouter(apiV1) } // NoisePublicKey returns the server's Noise protocol public key. diff --git a/hscontrol/servertest/apiv1.go b/hscontrol/servertest/apiv1.go new file mode 100644 index 00000000..80dafa01 --- /dev/null +++ b/hscontrol/servertest/apiv1.go @@ -0,0 +1,63 @@ +package servertest + +import ( + "context" + "net" + "net/http" + "testing" + "time" + + apiv1 "github.com/juanfont/headscale/gen/api/v1" +) + +// APIClient returns an ogen-generated v1 API client wired to this server's +// in-memory network and authenticated with apiKey (use [TestServer.CreateAPIKey] +// to mint one). This is the entry point for HTTP-API parity tests: the +// generated client talks to the generated server in-process, exercising the +// real request/response encoding. +func (s *TestServer) APIClient(tb testing.TB, apiKey string) *apiv1.Client { + tb.Helper() + + httpClient := &http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + return s.memNet.Dial(ctx, network, addr) + }, + }, + } + + client, err := apiv1.NewClient( + s.URL, + bearerToken(apiKey), + apiv1.WithClient(httpClient), + ) + if err != nil { + tb.Fatalf("servertest: building API client: %v", err) + } + + return client +} + +// CreateAPIKey mints a non-expiring API key and returns the secret token. +func (s *TestServer) CreateAPIKey(tb testing.TB) string { + tb.Helper() + + expiry := time.Now().Add(24 * time.Hour) + + key, _, err := s.st.CreateAPIKey(&expiry) + if err != nil { + tb.Fatalf("servertest: CreateAPIKey: %v", err) + } + + return key +} + +// bearerToken is an [apiv1.SecuritySource] that supplies a fixed bearer token. +type bearerToken string + +func (t bearerToken) BearerAuth( + context.Context, + apiv1.OperationName, +) (apiv1.BearerAuth, error) { + return apiv1.BearerAuth{Token: string(t)}, nil +} diff --git a/hscontrol/servertest/apiv1_health_test.go b/hscontrol/servertest/apiv1_health_test.go new file mode 100644 index 00000000..4316f919 --- /dev/null +++ b/hscontrol/servertest/apiv1_health_test.go @@ -0,0 +1,48 @@ +package servertest_test + +import ( + "context" + "net/http" + "testing" + + apiv1 "github.com/juanfont/headscale/gen/api/v1" + "github.com/juanfont/headscale/hscontrol/servertest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestAPIv1_Health is the foundation smoke test: the ogen-generated client +// talks to the ogen-generated server in-process and gets a healthy response +// reporting database connectivity. +func TestAPIv1_Health(t *testing.T) { + srv := servertest.NewServer(t) + client := srv.APIClient(t, srv.CreateAPIKey(t)) + + resp, err := client.Health(context.Background()) + require.NoError(t, err) + assert.True( + t, + resp.DatabaseConnectivity.Value, + "expected database connectivity true", + ) +} + +// TestAPIv1_Health_Unauthorized verifies the bearer-auth SecurityHandler: +// an invalid API key yields a 401 RFC 7807 problem, matching the previous +// gRPC/gateway behaviour of rejecting bad tokens. +func TestAPIv1_Health_Unauthorized(t *testing.T) { + srv := servertest.NewServer(t) + client := srv.APIClient(t, "tskey-invalid") + + _, err := client.Health(context.Background()) + require.Error(t, err) + + var problem *apiv1.ErrorStatusCode + require.ErrorAs( + t, + err, &problem, + "expected *apiv1.ErrorStatusCode, got %T", + err, + ) + assert.Equal(t, http.StatusUnauthorized, problem.StatusCode) +}