mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-08 00:50:20 +09:00
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:
@@ -91,17 +91,20 @@ openapi:
|
||||
@echo "Emitting OpenAPI spec from code..."
|
||||
go run ./cmd/gen-openapi
|
||||
|
||||
# Generate the strongly-typed Go HTTP client. The served spec is OpenAPI 3.1,
|
||||
# but oapi-codegen v2 does not yet read 3.1, so the client is generated from a
|
||||
# transient 3.0.3 downgrade of the same document. Pinned so the committed client
|
||||
# is reproducible.
|
||||
# Generate the strongly-typed Go HTTP clients (v1 and v2). The served specs are
|
||||
# OpenAPI 3.1, but oapi-codegen v2 does not yet read 3.1, so each client is
|
||||
# generated from a transient 3.0.3 downgrade of its document. Pinned so the
|
||||
# committed clients are reproducible.
|
||||
.PHONY: client
|
||||
client:
|
||||
@echo "Generating API client..."
|
||||
@echo "Generating API clients..."
|
||||
@tmp=$$(mktemp -t headscale-openapi-3.0.XXXXXX.yaml); \
|
||||
go run ./cmd/gen-openapi -downgrade "$$tmp" && \
|
||||
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
|
||||
|
||||
# Clean targets
|
||||
|
||||
+36
-11
@@ -1,33 +1,58 @@
|
||||
// Command gen-openapi emits the Headscale v1 OpenAPI document from the
|
||||
// authoritative Huma definitions in hscontrol/api/v1. The server also serves the
|
||||
// spec live at /openapi.yaml; this tool emits it on demand, and with -downgrade
|
||||
// the 3.0.3 form used to generate the client. The output is not committed.
|
||||
// Command gen-openapi emits a Headscale OpenAPI document from the authoritative
|
||||
// Huma definitions in hscontrol/api/v1 and hscontrol/api/v2. The server also
|
||||
// serves each spec live (at /openapi.yaml and /api/v2/openapi); this tool emits
|
||||
// them on demand, and with -downgrade the 3.0.3 form used to generate the typed
|
||||
// client. The output is not committed.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// go run ./cmd/gen-openapi # write the 3.1 spec to openapi/v1/headscale.yaml
|
||||
// go run ./cmd/gen-openapi -downgrade <path> # write the 3.0.3 downgrade (for client gen)
|
||||
// go run ./cmd/gen-openapi # write the v1 3.1 spec to its default path
|
||||
// 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
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
apiv1 "github.com/juanfont/headscale/hscontrol/api/v1"
|
||||
apiv2 "github.com/juanfont/headscale/hscontrol/api/v2"
|
||||
)
|
||||
|
||||
// outPath is relative to the repository root.
|
||||
const outPath = "openapi/v1/headscale.yaml"
|
||||
// spec bundles a version's full (3.1) and downgraded (3.0.3) generators with the
|
||||
// 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() {
|
||||
if len(os.Args) == 3 && os.Args[1] == "-downgrade" {
|
||||
writeSpec(os.Args[2], apiv1.Spec30)
|
||||
api := flag.String("api", "v1", "which API spec to emit: v1 or v2")
|
||||
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
|
||||
}
|
||||
|
||||
writeSpec(outPath, apiv1.Spec)
|
||||
writeSpec(s.outPath, s.full)
|
||||
}
|
||||
|
||||
func writeSpec(path string, gen func() ([]byte, error)) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+26
-7
@@ -319,6 +319,11 @@ func (h *Headscale) scheduledTasks(ctx context.Context) {
|
||||
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 {
|
||||
select {
|
||||
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")
|
||||
}
|
||||
|
||||
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:
|
||||
var (
|
||||
expiredNodeChanges []change.Change
|
||||
@@ -627,20 +640,26 @@ func (h *Headscale) Serve() error {
|
||||
Cfg: h.cfg,
|
||||
})
|
||||
|
||||
// The Headscale v2 API. It is served only over the remote
|
||||
// listener (Basic/Bearer auth); no unix-socket mount yet, as nothing local
|
||||
// calls it — the CLI still speaks v1.
|
||||
// The Headscale v2 API. Served behind Basic/Bearer auth on the remote
|
||||
// listener, and over the local unix socket (local trust) so the CLI can
|
||||
// manage OAuth clients through the same v2 keys handler the Tailscale
|
||||
// ecosystem uses.
|
||||
humaV2Mux, _ := apiv2.Handler(apiv2.Backend{
|
||||
State: h.state,
|
||||
Change: h.Change,
|
||||
Cfg: h.cfg,
|
||||
})
|
||||
|
||||
// Serve the Huma API over the unix socket without TLS or auth: socket access
|
||||
// implies trust. WithLocalTrust marks these requests so the security
|
||||
// middleware skips the API-key check.
|
||||
// Serve both Huma APIs over the unix socket without TLS or auth: socket
|
||||
// access implies trust. WithLocalTrust marks these requests so each API's
|
||||
// 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{
|
||||
Handler: apiv1.WithLocalTrust(humaMux),
|
||||
Handler: socketHandler,
|
||||
ReadTimeout: types.HTTPTimeout,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user