mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-17 16:36:02 +00: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:
parent
84715e976c
commit
b05f8875c3
5 changed files with 4355 additions and 24 deletions
15
Makefile
15
Makefile
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)) {
|
||||||
|
|
|
||||||
271
cmd/headscale/cli/oauth_client.go
Normal file
271
cmd/headscale/cli/oauth_client.go
Normal 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
4013
gen/client/v2/client.gen.go
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue