cmd/headscale: run the CLI on the generated v1 HTTP client

Serve the API over the unix socket (auth bypassed; socket permissions are the
trust boundary) and convert every CLI command from the gRPC client to the
generated ogen client. Remote CLI now uses the HTTP API URL.
This commit is contained in:
Kristoffer Dalby
2026-06-17 15:55:16 +00:00
parent de85965bd8
commit 29fc14dd73
14 changed files with 526 additions and 508 deletions
+20
View File
@@ -11,6 +11,21 @@ import (
var errAuthRejected = errors.New("auth request rejected")
type socketAuthContextKey struct{}
// WithSocketAuth marks ctx as originating from the trusted local unix socket,
// where filesystem permissions are the trust boundary. Requests carrying this
// marker bypass bearer-token validation. The server mounts this on the
// socket-only listener.
func WithSocketAuth(ctx context.Context) context.Context {
return context.WithValue(ctx, socketAuthContextKey{}, true)
}
func isSocketAuth(ctx context.Context) bool {
v, _ := ctx.Value(socketAuthContextKey{}).(bool)
return v
}
// 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
@@ -22,6 +37,11 @@ func (s *Server) HandleBearerAuth(
_ oas.OperationName,
t oas.BearerAuth,
) (context.Context, error) {
// Requests from the local unix socket are trusted via filesystem permissions.
if isSocketAuth(ctx) {
return ctx, nil
}
valid, err := s.state.ValidateAPIKey(t.Token)
if err != nil || !valid {
return ctx, apiError(http.StatusUnauthorized, "invalid API key")
+39 -20
View File
@@ -50,7 +50,6 @@ import (
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/peer"
"google.golang.org/grpc/reflection"
"google.golang.org/grpc/status"
"tailscale.com/envknob"
"tailscale.com/tailcfg"
@@ -534,6 +533,22 @@ func securityHeaders(next http.Handler) http.Handler {
})
}
// unixSocketHandler wraps the API router for the local unix socket, marking
// every request as socket-authenticated so the v1 API bypasses bearer-token
// validation. The socket's filesystem permissions are the trust boundary.
func unixSocketHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// ogen only invokes its security handler when an Authorization header
// is present; inject a placeholder so socket requests reach it (where
// the socket-auth marker then short-circuits validation).
if r.Header.Get("Authorization") == "" {
r.Header.Set("Authorization", "Bearer local-socket")
}
next.ServeHTTP(w, r.WithContext(apiv1.WithSocketAuth(r.Context())))
})
}
func (h *Headscale) createRouter(apiV1 http.Handler) *chi.Mux {
r := chi.NewRouter()
r.Use(metrics.Collector(metrics.CollectorOpts{
@@ -731,16 +746,23 @@ func (h *Headscale) Serve() error {
return fmt.Errorf("changing gRPC socket permission: %w", err)
}
// Start the local gRPC server without TLS and without authentication
grpcSocket := grpc.NewServer(
// Uncomment to debug grpc communication.
// zerolog.UnaryInterceptor(),
)
// Build the v1 API handler and HTTP router once; both the local unix
// socket and the public HTTP listener serve it.
apiV1Handler, err := apiv1.NewHandler(h.state, h.cfg, h.Change)
if err != nil {
return fmt.Errorf("building v1 API handler: %w", err)
}
v1.RegisterHeadscaleServiceServer(grpcSocket, newHeadscaleV1APIServer(h))
reflection.Register(grpcSocket)
router := h.createRouter(apiV1Handler)
errorGroup.Go(func() error { return grpcSocket.Serve(socketListener) })
// Serve the API over the local unix socket without bearer auth: the
// socket's filesystem permissions are the trust boundary.
socketHTTPServer := &http.Server{
Handler: unixSocketHandler(router),
ReadTimeout: types.HTTPTimeout,
}
errorGroup.Go(func() error { return socketHTTPServer.Serve(socketListener) })
//
//
@@ -808,15 +830,8 @@ func (h *Headscale) Serve() error {
//
// HTTP setup
//
// This is the regular router that we expose
// over our main Addr
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)
// This is the regular router that we expose over our main Addr; it is the
// same router served over the unix socket above.
httpServer := &http.Server{
Addr: h.cfg.Addr,
Handler: router,
@@ -956,8 +971,12 @@ func (h *Headscale) Serve() error {
info("waiting for netmap stream to close")
h.clientStreamsOpen.Wait()
info("shutting down grpc server (socket)")
grpcSocket.GracefulStop()
info("shutting down socket http server")
socketErr := socketHTTPServer.Close()
if socketErr != nil {
log.Error().Err(socketErr).Msg("failed to shutdown socket http")
}
if grpcServer != nil {
info("shutting down grpc server (external)")