mirror of
https://github.com/juanfont/headscale.git
synced 2026-08-07 07:48:44 +09:00
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.
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
+21
-31
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user