hscontrol, cli: serve the v2 API on the local socket and add the oauth-clients command

Serve the v2 API over the local unix socket for the CLI and add the
oauth-clients command that drives it.
This commit is contained in:
Kristoffer Dalby 2026-06-21 17:35:36 +00:00
parent 84715e976c
commit b05f8875c3
5 changed files with 4355 additions and 24 deletions

View file

@ -91,17 +91,20 @@ openapi:
@echo "Emitting OpenAPI spec from code..." @echo "Emitting OpenAPI spec from code..."
go run ./cmd/gen-openapi go run ./cmd/gen-openapi
# Generate the strongly-typed Go HTTP client. The served spec is OpenAPI 3.1, # Generate the strongly-typed Go HTTP clients (v1 and v2). The served specs are
# but oapi-codegen v2 does not yet read 3.1, so the client is generated from a # OpenAPI 3.1, but oapi-codegen v2 does not yet read 3.1, so each client is
# transient 3.0.3 downgrade of the same document. Pinned so the committed client # generated from a transient 3.0.3 downgrade of its document. Pinned so the
# is reproducible. # committed clients are reproducible.
.PHONY: client .PHONY: client
client: client:
@echo "Generating API client..." @echo "Generating API clients..."
@tmp=$$(mktemp -t headscale-openapi-3.0.XXXXXX.yaml); \ @tmp=$$(mktemp -t headscale-openapi-3.0.XXXXXX.yaml); \
go run ./cmd/gen-openapi -downgrade "$$tmp" && \ go run ./cmd/gen-openapi -downgrade "$$tmp" && \
go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@v2.7.1 \ go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@v2.7.1 \
-generate types,client -package clientv1 -o gen/client/v1/client.gen.go "$$tmp"; \ -generate types,client -package clientv1 -o gen/client/v1/client.gen.go "$$tmp" && \
go run ./cmd/gen-openapi -api v2 -downgrade "$$tmp" && \
go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@v2.7.1 \
-generate types,client -package clientv2 -o gen/client/v2/client.gen.go "$$tmp"; \
status=$$?; rm -f "$$tmp"; exit $$status status=$$?; rm -f "$$tmp"; exit $$status
# Clean targets # Clean targets

View file

@ -1,33 +1,58 @@
// Command gen-openapi emits the Headscale v1 OpenAPI document from the // Command gen-openapi emits a Headscale OpenAPI document from the authoritative
// authoritative Huma definitions in hscontrol/api/v1. The server also serves the // Huma definitions in hscontrol/api/v1 and hscontrol/api/v2. The server also
// spec live at /openapi.yaml; this tool emits it on demand, and with -downgrade // serves each spec live (at /openapi.yaml and /api/v2/openapi); this tool emits
// the 3.0.3 form used to generate the client. The output is not committed. // them on demand, and with -downgrade the 3.0.3 form used to generate the typed
// client. The output is not committed.
// //
// Usage: // Usage:
// //
// go run ./cmd/gen-openapi # write the 3.1 spec to openapi/v1/headscale.yaml // go run ./cmd/gen-openapi # write the v1 3.1 spec to its default path
// go run ./cmd/gen-openapi -downgrade <path> # write the 3.0.3 downgrade (for client gen) // go run ./cmd/gen-openapi -api v2 # write the v2 3.1 spec to its default path
// go run ./cmd/gen-openapi -downgrade <path> # write the v1 3.0.3 downgrade (for client gen)
// go run ./cmd/gen-openapi -api v2 -downgrade <path> # the v2 3.0.3 downgrade
package main package main
import ( import (
"flag"
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
apiv1 "github.com/juanfont/headscale/hscontrol/api/v1" apiv1 "github.com/juanfont/headscale/hscontrol/api/v1"
apiv2 "github.com/juanfont/headscale/hscontrol/api/v2"
) )
// outPath is relative to the repository root. // spec bundles a version's full (3.1) and downgraded (3.0.3) generators with the
const outPath = "openapi/v1/headscale.yaml" // committed output path. outPath is relative to the repository root.
type spec struct {
full func() ([]byte, error)
down func() ([]byte, error)
outPath string
}
// specs maps the -api value to its generators.
var specs = map[string]spec{
"v1": {apiv1.Spec, apiv1.Spec30, "openapi/v1/headscale.yaml"},
"v2": {apiv2.Spec, apiv2.Spec30, "openapi/v2/headscale.yaml"},
}
func main() { func main() {
if len(os.Args) == 3 && os.Args[1] == "-downgrade" { api := flag.String("api", "v1", "which API spec to emit: v1 or v2")
writeSpec(os.Args[2], apiv1.Spec30) downgrade := flag.String("downgrade", "", "write the OpenAPI 3.0.3 downgrade to this path instead of the committed 3.1 spec")
flag.Parse()
s, ok := specs[*api]
if !ok {
log.Fatalf("unknown -api %q (want v1 or v2)", *api)
}
if *downgrade != "" {
writeSpec(*downgrade, s.down)
return return
} }
writeSpec(outPath, apiv1.Spec) writeSpec(s.outPath, s.full)
} }
func writeSpec(path string, gen func() ([]byte, error)) { func writeSpec(path string, gen func() ([]byte, error)) {

View file

@ -0,0 +1,271 @@
package cli
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"net"
"net/http"
"strings"
clientv2 "github.com/juanfont/headscale/gen/client/v2"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/spf13/cobra"
)
// oauthTailnet is the single Headscale tailnet the v2 API addresses as "-".
const oauthTailnet = "-"
func init() {
rootCmd.AddCommand(oauthClientsCmd)
oauthClientsCmd.AddCommand(listOAuthClientsCmd)
createOAuthClientCmd.Flags().
StringArrayP("scope", "s", nil, "Scope the client's tokens are granted (repeatable): auth_keys, oauth_keys, devices:core, devices:routes, policy_file, feature_settings (each with a :read variant), or all/all:read")
createOAuthClientCmd.Flags().
StringArrayP("tag", "t", nil, "Tag the client's tokens may assign to devices (repeatable), e.g. tag:k8s-operator")
createOAuthClientCmd.Flags().StringP("description", "d", "", "Human-readable description")
oauthClientsCmd.AddCommand(createOAuthClientCmd)
deleteOAuthClientCmd.Flags().StringP("id", "i", "", "OAuth client id")
oauthClientsCmd.AddCommand(deleteOAuthClientCmd)
}
var oauthClientsCmd = &cobra.Command{
Use: "oauth-clients",
Short: "Manage OAuth clients",
Aliases: []string{"oauth-client", "oauthclients", "oauthclient", "oauth"},
}
var createOAuthClientCmd = &cobra.Command{
Use: "create",
Short: "Create an OAuth client",
Long: `Create a general-purpose OAuth client. It authenticates with the OAuth 2.0
client-credentials grant and mints short-lived, scope-limited access tokens.
The wire format is compatible with Tailscale tooling (the Terraform provider,
the Kubernetes operator, tscli, ...), so those can drive Headscale unchanged.
The client secret is shown ONCE on creation and cannot be retrieved again; if
you lose it, delete the client and create a new one.
Scopes gate what the client's tokens may do; tags are the device tags those
tokens may assign and are required when the scopes include devices:core or
auth_keys.`,
Aliases: []string{"c", cmdNew},
RunE: func(cmd *cobra.Command, _ []string) error {
scopes, _ := cmd.Flags().GetStringArray("scope")
tags, _ := cmd.Flags().GetStringArray("tag")
description, _ := cmd.Flags().GetString("description")
if len(scopes) == 0 {
return fmt.Errorf("at least one --scope is required: %w", errMissingParameter)
}
ctx, client, cancel, err := newV2Client()
if err != nil {
return err
}
defer cancel()
keyType := "client"
resp, err := client.CreateKeyWithResponse(ctx, oauthTailnet, clientv2.CreateKeyRequest{
KeyType: &keyType,
Scopes: &scopes,
Tags: &tags,
Description: &description,
})
if err != nil {
return fmt.Errorf("creating oauth client: %w", err)
}
err = v2Error(resp.HTTPResponse.StatusCode, resp.Body)
if err != nil {
return err
}
key := resp.JSON200
return printOutput(cmd, key,
fmt.Sprintf("OAuth client %s created.\nSecret (shown once, store it now): %s", key.Id, ptrStr(key.Key)))
},
}
var listOAuthClientsCmd = &cobra.Command{
Use: cmdList,
Short: "List OAuth clients",
Aliases: []string{"ls", cmdShow},
RunE: func(cmd *cobra.Command, _ []string) error {
ctx, client, cancel, err := newV2Client()
if err != nil {
return err
}
defer cancel()
resp, err := client.ListKeysWithResponse(ctx, oauthTailnet, nil)
if err != nil {
return fmt.Errorf("listing oauth clients: %w", err)
}
err = v2Error(resp.HTTPResponse.StatusCode, resp.Body)
if err != nil {
return err
}
// The keys endpoint is multiplexed; keep only OAuth clients.
clients := make([]clientv2.Key, 0, len(resp.JSON200.Keys))
for _, k := range resp.JSON200.Keys {
if k.KeyType == "client" {
clients = append(clients, k)
}
}
return printListOutput(cmd, clients, func() error {
rows := make([][]string, 0, len(clients))
for _, c := range clients {
rows = append(rows, []string{
c.Id,
strings.Join(ptrStrs(c.Scopes), ","),
strings.Join(ptrStrs(c.Tags), ","),
ptrStr(c.Description),
c.Created.Format(HeadscaleDateTimeFormat),
})
}
return renderTable([]string{"ID", "Scopes", "Tags", "Description", colCreated}, rows)
})
},
}
var deleteOAuthClientCmd = &cobra.Command{
Use: cmdDelete,
Short: "Delete an OAuth client",
Aliases: []string{"remove", aliasDel},
RunE: func(cmd *cobra.Command, _ []string) error {
id, _ := cmd.Flags().GetString("id")
if id == "" {
return fmt.Errorf("--id is required: %w", errMissingParameter)
}
ctx, client, cancel, err := newV2Client()
if err != nil {
return err
}
defer cancel()
resp, err := client.DeleteKeyWithResponse(ctx, oauthTailnet, id)
if err != nil {
return fmt.Errorf("deleting oauth client: %w", err)
}
err = v2Error(resp.HTTPResponse.StatusCode, resp.Body)
if err != nil {
return err
}
return printOutput(cmd, map[string]string{"id": id}, "OAuth client "+id+" deleted")
},
}
// newV2Client builds a generated v2 API client, selecting the transport the same
// way the v1 client does: over the local unix socket it is unauthenticated
// (local trust); a remote address injects the configured API key as a bearer
// token.
func newV2Client() (context.Context, *clientv2.ClientWithResponses, context.CancelFunc, error) {
cfg, err := types.LoadCLIConfig()
if err != nil {
return nil, nil, nil, fmt.Errorf("loading configuration: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), cfg.CLI.Timeout)
if cfg.CLI.Address == "" {
socketPath := cfg.UnixSocket
httpClient := &http.Client{Transport: &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return dialHeadscaleSocket(ctx, socketPath)
},
}}
client, err := clientv2.NewClientWithResponses("http://local", clientv2.WithHTTPClient(httpClient))
if err != nil {
cancel()
return nil, nil, nil, err
}
return ctx, client, cancel, nil
}
if cfg.CLI.APIKey == "" {
cancel()
return nil, nil, nil, errAPIKeyNotSet
}
transport := &http.Transport{}
if cfg.CLI.Insecure {
//nolint:gosec // intentionally honouring the insecure flag
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
}
apiKey := cfg.CLI.APIKey
client, err := clientv2.NewClientWithResponses(
clientBaseURL(cfg.CLI.Address),
clientv2.WithHTTPClient(&http.Client{Transport: transport}),
clientv2.WithRequestEditorFn(func(_ context.Context, req *http.Request) error {
req.Header.Set("Authorization", "Bearer "+apiKey)
return nil
}),
)
if err != nil {
cancel()
return nil, nil, nil, err
}
return ctx, client, cancel, nil
}
// v2Error turns a non-2xx v2 response into an error. The v2 API emits the
// Tailscale error body ({"message":...}) rather than RFC 7807, so it reads the
// "message" field instead of the generated problem+json types.
func v2Error(status int, body []byte) error {
if status >= http.StatusOK && status < http.StatusMultipleChoices {
return nil
}
var e struct {
Message string `json:"message"`
}
if json.Unmarshal(body, &e) == nil && e.Message != "" {
//nolint:err113 // surfacing the server's message
return fmt.Errorf("api error (%d): %s", status, e.Message)
}
//nolint:err113 // surfacing the server's body
return fmt.Errorf("api error (%d): %s", status, strings.TrimSpace(string(body)))
}
func ptrStr(s *string) string {
if s == nil {
return ""
}
return *s
}
func ptrStrs(s *[]string) []string {
if s == nil {
return nil
}
return *s
}

4013
gen/client/v2/client.gen.go Normal file

File diff suppressed because it is too large Load diff

View file

@ -319,6 +319,11 @@ func (h *Headscale) scheduledTasks(ctx context.Context) {
revokedKeyGCChan = revokedKeyTicker.C revokedKeyGCChan = revokedKeyTicker.C
} }
// OAuth access tokens are short-lived (1h) and re-minted on demand; reap
// expired rows hourly so the table stays bounded.
accessTokenTicker := time.NewTicker(time.Hour)
defer accessTokenTicker.Stop()
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
@ -335,6 +340,14 @@ func (h *Headscale) scheduledTasks(ctx context.Context) {
log.Info().Int("count", reaped).Msg("reaped revoked pre-auth keys") log.Info().Int("count", reaped).Msg("reaped revoked pre-auth keys")
} }
case <-accessTokenTicker.C:
reaped, err := h.state.DeleteExpiredAccessTokens(time.Now())
if err != nil {
log.Error().Err(err).Msg("reaping expired oauth access tokens")
} else if reaped > 0 {
log.Debug().Int64("count", reaped).Msg("reaped expired oauth access tokens")
}
case <-expireTicker.C: case <-expireTicker.C:
var ( var (
expiredNodeChanges []change.Change expiredNodeChanges []change.Change
@ -627,20 +640,26 @@ func (h *Headscale) Serve() error {
Cfg: h.cfg, Cfg: h.cfg,
}) })
// The Headscale v2 API. It is served only over the remote // The Headscale v2 API. Served behind Basic/Bearer auth on the remote
// listener (Basic/Bearer auth); no unix-socket mount yet, as nothing local // listener, and over the local unix socket (local trust) so the CLI can
// calls it — the CLI still speaks v1. // manage OAuth clients through the same v2 keys handler the Tailscale
// ecosystem uses.
humaV2Mux, _ := apiv2.Handler(apiv2.Backend{ humaV2Mux, _ := apiv2.Handler(apiv2.Backend{
State: h.state, State: h.state,
Change: h.Change, Change: h.Change,
Cfg: h.cfg, Cfg: h.cfg,
}) })
// Serve the Huma API over the unix socket without TLS or auth: socket access // Serve both Huma APIs over the unix socket without TLS or auth: socket
// implies trust. WithLocalTrust marks these requests so the security // access implies trust. WithLocalTrust marks these requests so each API's
// middleware skips the API-key check. // security middleware skips the credential check. v2 paths route to the v2
// mux; everything else (the v1 paths) to v1.
socketHandler := http.NewServeMux()
socketHandler.Handle("/api/v2/", apiv2.WithLocalTrust(humaV2Mux))
socketHandler.Handle("/", apiv1.WithLocalTrust(humaMux))
socketServer := &http.Server{ socketServer := &http.Server{
Handler: apiv1.WithLocalTrust(humaMux), Handler: socketHandler,
ReadTimeout: types.HTTPTimeout, ReadTimeout: types.HTTPTimeout,
} }