mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-08 00:50:20 +09:00
api/v1: add code-first Huma implementation of the v1 API
Reimplement the v1 API as a code-first Huma service in hscontrol/api/v1, a thin adapter over the state layer. Huma emits the OpenAPI 3.1 document (openapi/v1/headscale.yaml) from the Go operation and type definitions; cmd/gen-openapi writes it and the 3.0.3 downgrade the client is generated from. Responses reproduce the protojson wire contract (string-encoded 64-bit IDs, all fields emitted, RFC3339/null timestamps); errors map to the correct HTTP status (404/400/409, 500 for server faults) via mapError. Serve it on the chi router at /api/v1 behind the existing API-key middleware, and point /swagger at the emitted spec.
This commit is contained in:
@@ -46,6 +46,9 @@ result
|
||||
|
||||
integration_test/etc/config.dump.yaml
|
||||
|
||||
# OpenAPI spec is served live from the code and emitted on demand, not committed
|
||||
/openapi/v1/headscale.yaml
|
||||
|
||||
# MkDocs
|
||||
.cache
|
||||
/site
|
||||
|
||||
@@ -20,7 +20,6 @@ endef
|
||||
|
||||
# Source file collections using shell find for better performance
|
||||
GO_SOURCES := $(shell find . -name '*.go' -not -path './gen/*' -not -path './vendor/*')
|
||||
PROTO_SOURCES := $(shell find . -name '*.proto' -not -path './gen/*' -not -path './vendor/*')
|
||||
PRETTIER_SOURCES := $(shell find . \( -name '*.md' -o -name '*.yaml' -o -name '*.yml' -o -name '*.ts' -o -name '*.js' -o -name '*.html' -o -name '*.css' -o -name '*.scss' -o -name '*.sass' \) -not -path './gen/*' -not -path './vendor/*' -not -path './node_modules/*')
|
||||
|
||||
# Default target
|
||||
@@ -35,8 +34,6 @@ check-deps:
|
||||
$(call check_tool,gofumpt)
|
||||
$(call check_tool,mdformat)
|
||||
$(call check_tool,prettier)
|
||||
$(call check_tool,clang-format)
|
||||
$(call check_tool,buf)
|
||||
|
||||
# Build targets
|
||||
.PHONY: build
|
||||
@@ -53,7 +50,7 @@ test: check-deps $(GO_SOURCES) go.mod go.sum
|
||||
|
||||
# Formatting targets
|
||||
.PHONY: fmt
|
||||
fmt: fmt-go fmt-mdformat fmt-prettier fmt-proto
|
||||
fmt: fmt-go fmt-mdformat fmt-prettier
|
||||
|
||||
.PHONY: fmt-go
|
||||
fmt-go: check-deps $(GO_SOURCES)
|
||||
@@ -71,35 +68,46 @@ fmt-prettier: check-deps $(PRETTIER_SOURCES)
|
||||
@echo "Formatting markup and config files..."
|
||||
prettier --write '**/*.{ts,js,md,yaml,yml,sass,css,scss,html}'
|
||||
|
||||
.PHONY: fmt-proto
|
||||
fmt-proto: check-deps $(PROTO_SOURCES)
|
||||
@echo "Formatting Protocol Buffer files..."
|
||||
clang-format -i $(PROTO_SOURCES)
|
||||
|
||||
# Linting targets
|
||||
.PHONY: lint
|
||||
lint: lint-go lint-proto
|
||||
lint: lint-go
|
||||
|
||||
.PHONY: lint-go
|
||||
lint-go: check-deps $(GO_SOURCES) go.mod go.sum
|
||||
@echo "Linting Go code..."
|
||||
golangci-lint run --timeout 10m
|
||||
|
||||
.PHONY: lint-proto
|
||||
lint-proto: check-deps $(PROTO_SOURCES)
|
||||
@echo "Linting Protocol Buffer files..."
|
||||
cd proto/ && buf lint
|
||||
|
||||
# Code generation
|
||||
.PHONY: generate
|
||||
generate: check-deps
|
||||
@echo "Generating code..."
|
||||
go generate ./...
|
||||
$(MAKE) client
|
||||
|
||||
# Emit the OpenAPI spec on demand. The server serves it live at /openapi.yaml;
|
||||
# this is for external consumers or inspection and is not committed.
|
||||
.PHONY: openapi
|
||||
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.
|
||||
.PHONY: client
|
||||
client:
|
||||
@echo "Generating API client..."
|
||||
@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"; \
|
||||
status=$$?; rm -f "$$tmp"; exit $$status
|
||||
|
||||
# Clean targets
|
||||
.PHONY: clean
|
||||
clean:
|
||||
rm -rf headscale gen
|
||||
rm -rf headscale gen/client
|
||||
|
||||
# Development workflow
|
||||
.PHONY: dev
|
||||
@@ -119,9 +127,9 @@ help:
|
||||
@echo " all - Run lint, test, and build (default)"
|
||||
@echo " build - Build headscale binary"
|
||||
@echo " test - Run Go tests"
|
||||
@echo " fmt - Format all code (Go, docs, proto)"
|
||||
@echo " lint - Lint all code (Go, proto)"
|
||||
@echo " generate - Generate code from Protocol Buffers"
|
||||
@echo " fmt - Format all code (Go, docs, markup)"
|
||||
@echo " lint - Lint all code (Go)"
|
||||
@echo " generate - Generate code (go generate + client)"
|
||||
@echo " dev - Full development workflow (fmt + lint + test + build)"
|
||||
@echo " clean - Clean build artifacts"
|
||||
@echo ""
|
||||
@@ -129,9 +137,7 @@ help:
|
||||
@echo " fmt-go - Format Go code only"
|
||||
@echo " fmt-mdformat - Format documentation only"
|
||||
@echo " fmt-prettier - Format markup and config files only"
|
||||
@echo " fmt-proto - Format Protocol Buffer files only"
|
||||
@echo " lint-go - Lint Go code only"
|
||||
@echo " lint-proto - Lint Protocol Buffer files only"
|
||||
@echo ""
|
||||
@echo "Dependencies:"
|
||||
@echo " check-deps - Verify required tools are available"
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// 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.
|
||||
//
|
||||
// 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)
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
apiv1 "github.com/juanfont/headscale/hscontrol/api/v1"
|
||||
)
|
||||
|
||||
// outPath is relative to the repository root.
|
||||
const outPath = "openapi/v1/headscale.yaml"
|
||||
|
||||
func main() {
|
||||
if len(os.Args) == 3 && os.Args[1] == "-downgrade" {
|
||||
writeSpec(os.Args[2], apiv1.Spec30)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
writeSpec(outPath, apiv1.Spec)
|
||||
}
|
||||
|
||||
func writeSpec(path string, gen func() ([]byte, error)) {
|
||||
spec, err := gen()
|
||||
if err != nil {
|
||||
log.Fatalf("generating OpenAPI spec: %v", err)
|
||||
}
|
||||
|
||||
err = os.MkdirAll(filepath.Dir(path), 0o755)
|
||||
if err != nil {
|
||||
log.Fatalf("creating output directory: %v", err)
|
||||
}
|
||||
|
||||
err = os.WriteFile(path, spec, 0o600)
|
||||
if err != nil {
|
||||
log.Fatalf("writing %s: %v", path, err)
|
||||
}
|
||||
|
||||
log.Printf("wrote %s", path)
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"vendor": {
|
||||
"goModSum": "sha256-csVm5v6HZ49PBp/FCX+yK1sjV8/nuUQz3GKN21Ne1mg=",
|
||||
"sri": "sha256-fzKyXNMw/2yAEhaTZu0n1NXatPO2IP0HFA2ey1vZIYM="
|
||||
"goModSum": "sha256-tboE8Nc56tKKOki0JVvR64HcTZJE3ESgLnfdOYQq53Q=",
|
||||
"sri": "sha256-82WhhyBCnwp0ysrI+n2vHf22cgD29Pjus3GtIOpJonU="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ require (
|
||||
github.com/coreos/go-oidc/v3 v3.18.0
|
||||
github.com/creachadair/command v0.2.6
|
||||
github.com/creachadair/flax v0.0.6
|
||||
github.com/danielgtaylor/huma/v2 v2.38.0
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc
|
||||
github.com/docker/docker v28.5.2+incompatible
|
||||
github.com/fsnotify/fsnotify v1.10.1
|
||||
@@ -20,12 +21,11 @@ require (
|
||||
github.com/go-json-experiment/json v0.0.0-20260601182631-00ed12fed2a6
|
||||
github.com/gofrs/uuid/v5 v5.4.0
|
||||
github.com/google/go-cmp v0.7.0
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7
|
||||
github.com/jagottsicher/termcolor v1.0.2
|
||||
github.com/oapi-codegen/runtime v1.4.1
|
||||
github.com/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25
|
||||
github.com/ory/dockertest/v3 v3.12.0
|
||||
github.com/philip-bui/grpc-zerolog v1.0.1
|
||||
github.com/pkg/profile v1.7.0
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/prometheus/common v0.68.1
|
||||
@@ -48,9 +48,6 @@ require (
|
||||
golang.org/x/net v0.56.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/sync v0.21.0
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260610212136-7ab31c22f7ad
|
||||
google.golang.org/grpc v1.81.1
|
||||
google.golang.org/protobuf v1.36.11
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
@@ -104,6 +101,7 @@ require (
|
||||
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect
|
||||
github.com/akutz/memconn v0.1.0 // indirect
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e // indirect
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.7 // indirect
|
||||
@@ -141,7 +139,7 @@ require (
|
||||
github.com/felixge/fgprof v0.9.5 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fogleman/gg v1.3.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.1 // indirect
|
||||
github.com/gaissmai/bart v0.26.1 // indirect
|
||||
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
||||
github.com/go-jose/go-jose/v3 v3.0.5 // indirect
|
||||
@@ -154,7 +152,6 @@ require (
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/google/btree v1.1.3 // indirect
|
||||
github.com/google/go-github v17.0.0+incompatible // indirect
|
||||
github.com/google/go-querystring v1.2.0 // indirect
|
||||
@@ -163,6 +160,7 @@ require (
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gookit/color v1.6.1 // indirect
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
|
||||
github.com/hashicorp/go-version v1.9.0 // indirect
|
||||
github.com/hdevalence/ed25519consensus v0.2.0 // indirect
|
||||
github.com/huin/goupnp v1.3.0 // indirect
|
||||
@@ -246,7 +244,10 @@ require (
|
||||
golang.org/x/tools v0.45.0 // indirect
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||
golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260610212136-7ab31c22f7ad // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
google.golang.org/grpc v1.81.1 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
k8s.io/client-go v0.34.0 // indirect
|
||||
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||
software.sslmate.com/src/go-pkcs12 v0.4.0 // indirect
|
||||
|
||||
@@ -28,12 +28,15 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw=
|
||||
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk=
|
||||
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
|
||||
github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A=
|
||||
github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
|
||||
github.com/arl/statsviz v0.8.0 h1:O6GjjVxEDxcByAucOSl29HaGYLXsuwA3ujJw8H9E7/U=
|
||||
github.com/arl/statsviz v0.8.0/go.mod h1:XlrbiT7xYT03xaW9JMMfD8KFUhBOESJwfyNJu83PbB0=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
@@ -82,6 +85,7 @@ github.com/axiomhq/hyperloglog v0.2.6 h1:sRhvvF3RIXWQgAXaTphLp4yJiX4S0IN3MWTaAgZ
|
||||
github.com/axiomhq/hyperloglog v0.2.6/go.mod h1:YjX/dQqCR/7QYX0g8mu8UZAjpIenz1FKM71UEsjFoTo=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
@@ -132,6 +136,8 @@ github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoi
|
||||
github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/danielgtaylor/huma/v2 v2.38.0 h1:fb0WZCatnaiHLphMQDDWDjygNxfMkX/ENma3QsRl7vY=
|
||||
github.com/danielgtaylor/huma/v2 v2.38.0/go.mod h1:k9hwjlgWFt1t2jsmQGlsgXAG2FBTZa4kkjV581qAtfo=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
@@ -167,8 +173,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
|
||||
github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo=
|
||||
github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
|
||||
github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I=
|
||||
@@ -217,8 +223,6 @@ github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF0
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
|
||||
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
@@ -288,6 +292,7 @@ github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfC
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/jsimonetti/rtnetlink v1.4.2 h1:Df9w9TZ3npHTyDn0Ev9e1uzmN2odmXd0QX+J5GTEn90=
|
||||
github.com/jsimonetti/rtnetlink v1.4.2/go.mod h1:92s6LJdE+1iOrw+F2/RO7LYI2Qd8pPpFNNUYW06gcoM=
|
||||
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
|
||||
github.com/kamstrup/intmap v0.5.2 h1:qnwBm1mh4XAnW9W9Ue9tZtTff8pS6+s6iKF6JRIV2Dk=
|
||||
github.com/kamstrup/intmap v0.5.2/go.mod h1:gWUVWHKzWj8xpJVFf5GC0O26bWmv3GqdnIX/LMT6Aq4=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
||||
@@ -353,6 +358,10 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
|
||||
github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs=
|
||||
github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY=
|
||||
github.com/oapi-codegen/runtime v1.4.1 h1:9nwLoI+KrWxzbBcp0jO/R8uXqbik/HUyCvPeU68Y/qo=
|
||||
github.com/oapi-codegen/runtime v1.4.1/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU=
|
||||
github.com/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25 h1:9bCMuD3TcnjeqjPT2gSlha4asp8NvgcFRYExCaikCxk=
|
||||
github.com/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25/go.mod h1:eDjgYHYDJbPLBLsyZ6qRaugP0mX8vePOhZ5id1fdzJw=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
@@ -371,8 +380,6 @@ github.com/peterbourgon/ff/v3 v3.4.0/go.mod h1:zjJVUhx+twciwfDl0zBcFzl4dW8axCRyX
|
||||
github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
||||
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM=
|
||||
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
||||
github.com/philip-bui/grpc-zerolog v1.0.1 h1:EMacvLRUd2O1K0eWod27ZP5CY1iTNkhBDLSN+Q4JEvA=
|
||||
github.com/philip-bui/grpc-zerolog v1.0.1/go.mod h1:qXbiq/2X4ZUMMshsqlWyTHOcw7ns+GZmlqZZN05ZHcQ=
|
||||
github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
|
||||
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||
github.com/pires/go-proxyproto v0.9.2 h1:H1UdHn695zUVVmB0lQ354lOWHOy6TZSpzBl3tgN0s1U=
|
||||
@@ -432,10 +439,9 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
@@ -601,8 +607,6 @@ golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeu
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
||||
golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE=
|
||||
golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260610212136-7ab31c22f7ad h1:3iLyITS/sySRwbUKoC7ogfj2Yr1Cjs0pfaRKj5U5HEw=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260610212136-7ab31c22f7ad/go.mod h1:KdNqO+rCIWgFumrNBSEDlDNrkrQnpkax7Tv1WxNY8V4=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
// Package apiv1 is the code-first Huma implementation of the Headscale v1 API.
|
||||
// Handlers are a thin adapter over hscontrol/state; Huma emits the OpenAPI 3.1
|
||||
// spec from the Go definitions (see Spec), and that spec drives the client.
|
||||
//
|
||||
// It depends only on the domain layer (hscontrol/state, hscontrol/types) via
|
||||
// Backend, never on the hscontrol server package, so a future hscontrol/api/v2
|
||||
// can sit beside it without either importing the other.
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/danielgtaylor/huma/v2/adapters/humachi"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/juanfont/headscale/hscontrol/state"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/types/change"
|
||||
)
|
||||
|
||||
// Backend is the dependency surface the v1 API needs from the control plane:
|
||||
// the state layer, the change-notification sink that distributes updates to
|
||||
// connected nodes, and the config (only Policy.Mode and Policy.Path are read).
|
||||
type Backend struct {
|
||||
State *state.State
|
||||
Change func(...change.Change)
|
||||
Cfg *types.Config
|
||||
}
|
||||
|
||||
// NewAPI builds the v1 Huma API on the given chi router and registers every
|
||||
// operation. Auth is enforced by a Huma middleware driven by each operation's
|
||||
// declared bearer security (see authMiddleware); locally-trusted requests
|
||||
// bypass it via WithLocalTrust.
|
||||
func NewAPI(router chi.Router, backend Backend) huma.API {
|
||||
config := huma.DefaultConfig("Headscale API", "v1")
|
||||
config.Info.Description = "Headscale control server API."
|
||||
|
||||
// Version the OpenAPI/docs routes under /api/v1 so a future v2 owns its own.
|
||||
// These register as plain mux routes, not operations, so they never appear
|
||||
// in the emitted spec or client.
|
||||
config.OpenAPIPath = "/api/v1/openapi"
|
||||
config.DocsPath = "/api/v1/docs"
|
||||
|
||||
// The v1 API does not emit "$schema".
|
||||
config.SchemasPath = ""
|
||||
|
||||
// Drop the default schema-link create hook: it injects a "$schema" property
|
||||
// and Link header into every response, which the v1 contract omits.
|
||||
config.CreateHooks = nil
|
||||
|
||||
config.Components.SecuritySchemes = map[string]*huma.SecurityScheme{
|
||||
"bearer": {
|
||||
Type: "http",
|
||||
Scheme: "bearer",
|
||||
},
|
||||
}
|
||||
|
||||
api := humachi.New(router, config)
|
||||
|
||||
// Must run before register: Huma snapshots the middleware chain at operation
|
||||
// registration, so a middleware added afterwards would silently never run.
|
||||
api.UseMiddleware(authMiddleware(api, backend))
|
||||
|
||||
register(api, backend)
|
||||
|
||||
return api
|
||||
}
|
||||
|
||||
// bearerAuth is the security requirement applied to every operation: all
|
||||
// /api/v1 routes require an API key.
|
||||
var bearerAuth = []map[string][]string{{"bearer": {}}}
|
||||
|
||||
// registrations is populated by each resource file's init(), so adding a
|
||||
// resource group means adding a file rather than editing a shared point. Huma
|
||||
// sorts the emitted spec, so init order does not affect output.
|
||||
var registrations []func(huma.API, Backend)
|
||||
|
||||
// register wires up every operation contributed by the resource files.
|
||||
func register(api huma.API, b Backend) {
|
||||
for _, fn := range registrations {
|
||||
fn(api, b)
|
||||
}
|
||||
}
|
||||
|
||||
// Spec emits the OpenAPI 3.1 document. The zero Backend is safe because
|
||||
// handlers are registered but never invoked during emission.
|
||||
func Spec() ([]byte, error) {
|
||||
api := NewAPI(chi.NewMux(), Backend{})
|
||||
return api.OpenAPI().YAML()
|
||||
}
|
||||
|
||||
// Spec30 emits the document downgraded to OpenAPI 3.0.3, needed because the
|
||||
// client generator (oapi-codegen v2) cannot yet read the 3.1 spec.
|
||||
func Spec30() ([]byte, error) {
|
||||
api := NewAPI(chi.NewMux(), Backend{})
|
||||
return api.OpenAPI().DowngradeYAML()
|
||||
}
|
||||
|
||||
// Handler builds the v1 API on a fresh mux and returns both. Callers mount the
|
||||
// mux and may use mux.Match to detect which paths this API serves.
|
||||
func Handler(backend Backend) (*chi.Mux, huma.API) {
|
||||
mux := chi.NewMux()
|
||||
api := NewAPI(mux, backend)
|
||||
|
||||
return mux, api
|
||||
}
|
||||
|
||||
// localTrustKey marks a request as arriving over a locally-trusted transport;
|
||||
// the auth middleware skips authentication for such requests.
|
||||
type localTrustKey struct{}
|
||||
|
||||
// WithLocalTrust wraps a handler so its requests bypass API-key authentication.
|
||||
// The unix socket uses this — access to the socket is the trust boundary — as
|
||||
// do in-process tests that exercise the mux directly.
|
||||
func WithLocalTrust(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
next.ServeHTTP(w, req.WithContext(
|
||||
context.WithValue(req.Context(), localTrustKey{}, struct{}{}),
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
// authMiddleware is a pure gate enforcing the bearer API key for any operation
|
||||
// that declares security; the v1 handlers do not read caller identity.
|
||||
// Locally-trusted requests and operations without declared security pass
|
||||
// through. b.State is nil only during spec emission, where no request is
|
||||
// served, so it is never dereferenced there.
|
||||
func authMiddleware(api huma.API, b Backend) func(huma.Context, func(huma.Context)) {
|
||||
return func(ctx huma.Context, next func(huma.Context)) {
|
||||
if ctx.Context().Value(localTrustKey{}) != nil {
|
||||
next(ctx)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if len(ctx.Operation().Security) == 0 {
|
||||
next(ctx)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
token, ok := strings.CutPrefix(ctx.Header("Authorization"), "Bearer ")
|
||||
if !ok {
|
||||
_ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "Unauthorized")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
valid, err := b.State.ValidateAPIKey(token)
|
||||
if err != nil || !valid {
|
||||
_ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "Unauthorized")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
next(ctx)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registrations = append(registrations, registerApiKeys)
|
||||
}
|
||||
|
||||
// ApiKey is the v1 ApiKey message. Timestamps are pointers so a nil source is
|
||||
// emitted as JSON null, matching protojson's unset Timestamp (e.g. lastSeen on
|
||||
// a fresh key).
|
||||
type ApiKey struct {
|
||||
ID string `format:"uint64" json:"id"`
|
||||
Prefix string `json:"prefix"`
|
||||
Expiration *time.Time `json:"expiration" nullable:"true"`
|
||||
CreatedAt *time.Time `json:"createdAt" nullable:"true"`
|
||||
LastSeen *time.Time `json:"lastSeen" nullable:"true"`
|
||||
}
|
||||
|
||||
// CreateApiKeyRequestBody is the v1.CreateApiKeyRequest body.
|
||||
type CreateApiKeyRequestBody struct {
|
||||
Expiration *time.Time `json:"expiration,omitempty"`
|
||||
}
|
||||
|
||||
// ExpireApiKeyRequestBody is the v1.ExpireApiKeyRequest body.
|
||||
type ExpireApiKeyRequestBody struct {
|
||||
Prefix string `json:"prefix,omitempty"`
|
||||
ID string `format:"uint64" json:"id,omitempty"`
|
||||
}
|
||||
|
||||
type (
|
||||
createApiKeyInput struct {
|
||||
Body CreateApiKeyRequestBody
|
||||
}
|
||||
createApiKeyOutput struct {
|
||||
Body struct {
|
||||
APIKey string `json:"apiKey"`
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
type (
|
||||
expireApiKeyInput struct {
|
||||
Body ExpireApiKeyRequestBody
|
||||
}
|
||||
expireApiKeyOutput struct {
|
||||
Body struct{}
|
||||
}
|
||||
)
|
||||
|
||||
type (
|
||||
listApiKeysOutput struct {
|
||||
Body struct {
|
||||
APIKeys []ApiKey `json:"apiKeys" nullable:"false"`
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
type (
|
||||
deleteApiKeyInput struct {
|
||||
Prefix string `path:"prefix"`
|
||||
ID string `format:"uint64" query:"id"`
|
||||
}
|
||||
deleteApiKeyOutput struct {
|
||||
Body struct{}
|
||||
}
|
||||
)
|
||||
|
||||
func registerApiKeys(api huma.API, b Backend) {
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "createApiKey",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/v1/apikey",
|
||||
Summary: "Create API key",
|
||||
Tags: []string{"ApiKeys"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, in *createApiKeyInput) (*createApiKeyOutput, error) {
|
||||
// CreateAPIKey requires a non-nil pointer; default a missing expiration
|
||||
// to the zero time as the gRPC handler does.
|
||||
var expiration time.Time
|
||||
if in.Body.Expiration != nil {
|
||||
expiration = *in.Body.Expiration
|
||||
}
|
||||
|
||||
keyStr, _, err := b.State.CreateAPIKey(&expiration)
|
||||
if err != nil {
|
||||
return nil, huma.Error500InternalServerError("creating api key", err)
|
||||
}
|
||||
|
||||
out := &createApiKeyOutput{}
|
||||
out.Body.APIKey = keyStr
|
||||
|
||||
return out, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "expireApiKey",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/v1/apikey/expire",
|
||||
Summary: "Expire API key",
|
||||
Tags: []string{"ApiKeys"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, in *expireApiKeyInput) (*expireApiKeyOutput, error) {
|
||||
key, err := lookupApiKey(b, in.Body.ID, in.Body.Prefix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = b.State.ExpireAPIKey(key)
|
||||
if err != nil {
|
||||
return nil, huma.Error500InternalServerError("expiring api key", err)
|
||||
}
|
||||
|
||||
return &expireApiKeyOutput{}, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "listApiKeys",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/v1/apikey",
|
||||
Summary: "List API keys",
|
||||
Tags: []string{"ApiKeys"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, _ *struct{}) (*listApiKeysOutput, error) {
|
||||
keys, err := b.State.ListAPIKeys()
|
||||
if err != nil {
|
||||
return nil, huma.Error500InternalServerError("listing api keys", err)
|
||||
}
|
||||
|
||||
// Match the gRPC handler's ascending-ID ordering.
|
||||
slices.SortFunc(keys, func(a, b types.APIKey) int {
|
||||
return cmp.Compare(a.ID, b.ID)
|
||||
})
|
||||
|
||||
out := &listApiKeysOutput{}
|
||||
|
||||
out.Body.APIKeys = make([]ApiKey, len(keys))
|
||||
for i := range keys {
|
||||
out.Body.APIKeys[i] = apiKeyFromState(&keys[i])
|
||||
}
|
||||
|
||||
return out, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "deleteApiKey",
|
||||
Method: http.MethodDelete,
|
||||
Path: "/api/v1/apikey/{prefix}",
|
||||
Summary: "Delete API key",
|
||||
Tags: []string{"ApiKeys"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, in *deleteApiKeyInput) (*deleteApiKeyOutput, error) {
|
||||
key, err := lookupApiKey(b, in.ID, in.Prefix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = b.State.DestroyAPIKey(*key)
|
||||
if err != nil {
|
||||
return nil, huma.Error500InternalServerError("deleting api key", err)
|
||||
}
|
||||
|
||||
return &deleteApiKeyOutput{}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// lookupApiKey resolves an API key by id or prefix; exactly one must be
|
||||
// supplied. An empty or zero id counts as "no id". Unknown id/prefix maps to
|
||||
// 404 via mapError.
|
||||
func lookupApiKey(b Backend, idStr, prefix string) (*types.APIKey, error) {
|
||||
id, err := parseApiKeyID(idStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hasID := id != 0
|
||||
hasPrefix := prefix != ""
|
||||
|
||||
switch {
|
||||
case hasID && hasPrefix:
|
||||
return nil, huma.Error400BadRequest("provide either id or prefix, not both")
|
||||
case hasID:
|
||||
key, err := b.State.GetAPIKeyByID(id)
|
||||
if err != nil {
|
||||
return nil, mapError("getting api key", err)
|
||||
}
|
||||
|
||||
return key, nil
|
||||
case hasPrefix:
|
||||
key, err := b.State.GetAPIKey(prefix)
|
||||
if err != nil {
|
||||
return nil, mapError("getting api key", err)
|
||||
}
|
||||
|
||||
return key, nil
|
||||
default:
|
||||
return nil, huma.Error400BadRequest("must provide id or prefix")
|
||||
}
|
||||
}
|
||||
|
||||
// parseApiKeyID decodes the optional uint64 id. Empty maps to zero; non-numeric
|
||||
// is rejected with 400.
|
||||
func parseApiKeyID(s string) (uint64, error) {
|
||||
if s == "" {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(s, 10, 64)
|
||||
if err != nil {
|
||||
return 0, huma.Error400BadRequest("invalid api key id", err)
|
||||
}
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// apiKeyFromState converts a domain API key into the v1 response shape, masking
|
||||
// the prefix so the secret is never returned.
|
||||
func apiKeyFromState(k *types.APIKey) ApiKey {
|
||||
return ApiKey{
|
||||
ID: formatID(k.ID),
|
||||
Prefix: apiKeyMaskedPrefix(k.Prefix),
|
||||
Expiration: k.Expiration,
|
||||
CreatedAt: k.CreatedAt,
|
||||
LastSeen: k.LastSeen,
|
||||
}
|
||||
}
|
||||
|
||||
// apiKeyMaskedPrefix reproduces the unexported types.APIKey.maskedPrefix.
|
||||
func apiKeyMaskedPrefix(prefix string) string {
|
||||
if len(prefix) == types.NewAPIKeyPrefixLength {
|
||||
return "hskey-api-" + prefix + "-***"
|
||||
}
|
||||
|
||||
return prefix + "***"
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/util"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registrations = append(registrations, registerAuth)
|
||||
}
|
||||
|
||||
// errAuthRejected is the verdict handed to the waiting registration flow when
|
||||
// an auth session is rejected.
|
||||
var errAuthRejected = errors.New("auth request rejected")
|
||||
|
||||
// AuthRegisterRequestBody is the v1.AuthRegisterRequest body.
|
||||
type AuthRegisterRequestBody struct {
|
||||
User string `json:"user,omitempty"`
|
||||
AuthID string `json:"authId,omitempty"`
|
||||
}
|
||||
|
||||
// AuthApproveRequestBody is the v1.AuthApproveRequest body.
|
||||
type AuthApproveRequestBody struct {
|
||||
AuthID string `json:"authId,omitempty"`
|
||||
}
|
||||
|
||||
// AuthRejectRequestBody is the v1.AuthRejectRequest body.
|
||||
type AuthRejectRequestBody struct {
|
||||
AuthID string `json:"authId,omitempty"`
|
||||
}
|
||||
|
||||
type (
|
||||
authRegisterInput struct {
|
||||
Body AuthRegisterRequestBody
|
||||
}
|
||||
authRegisterOutput struct {
|
||||
Body struct {
|
||||
Node Node `json:"node"`
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
type (
|
||||
authApproveInput struct {
|
||||
Body AuthApproveRequestBody
|
||||
}
|
||||
authApproveOutput struct {
|
||||
Body struct{}
|
||||
}
|
||||
)
|
||||
|
||||
type (
|
||||
authRejectInput struct {
|
||||
Body AuthRejectRequestBody
|
||||
}
|
||||
authRejectOutput struct {
|
||||
Body struct{}
|
||||
}
|
||||
)
|
||||
|
||||
func registerAuth(api huma.API, b Backend) {
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "authRegister",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/v1/auth/register",
|
||||
Summary: "Register node via auth flow",
|
||||
Tags: []string{"Auth"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, in *authRegisterInput) (*authRegisterOutput, error) {
|
||||
// Malformed auth_id is 400; unknown user and missing pending session are
|
||||
// 404 via mapError, matching the Approve/Reject handlers.
|
||||
registrationID, err := types.AuthIDFromString(in.Body.AuthID)
|
||||
if err != nil {
|
||||
return nil, huma.Error400BadRequest("registering node", err)
|
||||
}
|
||||
|
||||
user, err := b.State.GetUserByName(in.Body.User)
|
||||
if err != nil {
|
||||
return nil, mapError("looking up user", err)
|
||||
}
|
||||
|
||||
node, nodeChange, err := b.State.HandleNodeFromAuthPath(
|
||||
registrationID,
|
||||
types.UserID(user.ID),
|
||||
nil,
|
||||
util.RegisterMethodCLI,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, mapError("registering node", err)
|
||||
}
|
||||
|
||||
routeChange, err := b.State.AutoApproveRoutes(node)
|
||||
if err != nil {
|
||||
return nil, huma.Error500InternalServerError("auto approving routes", err)
|
||||
}
|
||||
|
||||
b.Change(nodeChange, routeChange)
|
||||
|
||||
out := &authRegisterOutput{}
|
||||
out.Body.Node = nodeFromView(node)
|
||||
|
||||
return out, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "authApprove",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/v1/auth/approve",
|
||||
Summary: "Approve a pending auth session",
|
||||
Tags: []string{"Auth"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, in *authApproveInput) (*authApproveOutput, error) {
|
||||
authReq, err := pendingAuthRequest(b, in.Body.AuthID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
authReq.FinishAuth(types.AuthVerdict{})
|
||||
|
||||
return &authApproveOutput{}, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "authReject",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/v1/auth/reject",
|
||||
Summary: "Reject a pending auth session",
|
||||
Tags: []string{"Auth"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, in *authRejectInput) (*authRejectOutput, error) {
|
||||
authReq, err := pendingAuthRequest(b, in.Body.AuthID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
authReq.FinishAuth(types.AuthVerdict{
|
||||
Err: errAuthRejected,
|
||||
})
|
||||
|
||||
return &authRejectOutput{}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// pendingAuthRequest looks up the pending session for auth_id. Malformed
|
||||
// auth_id is 400, unknown is 404.
|
||||
func pendingAuthRequest(b Backend, rawID string) (*types.AuthRequest, error) {
|
||||
authID, err := types.AuthIDFromString(rawID)
|
||||
if err != nil {
|
||||
return nil, huma.Error400BadRequest("invalid auth_id", err)
|
||||
}
|
||||
|
||||
authReq, ok := b.State.GetAuthCacheEntry(authID)
|
||||
if !ok {
|
||||
return nil, huma.Error404NotFound("no pending auth session for auth_id " + authID.String())
|
||||
}
|
||||
|
||||
return authReq, nil
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/juanfont/headscale/hscontrol/db"
|
||||
"github.com/juanfont/headscale/hscontrol/state"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// mapError translates a state/db-layer error into a Huma HTTP error
|
||||
// (NotFound→404, invalid input→400, conflict→409, everything else→500).
|
||||
// Handlers use this default mapping and may return a more specific huma.ErrorN
|
||||
// directly. msg is a human context prefix, e.g. "getting node".
|
||||
func mapError(msg string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch {
|
||||
case errors.Is(err, gorm.ErrRecordNotFound),
|
||||
errors.Is(err, state.ErrNodeNotFound),
|
||||
errors.Is(err, state.ErrNodeNotInNodeStore),
|
||||
errors.Is(err, db.ErrUserNotFound),
|
||||
errors.Is(err, db.ErrNodeNotFoundRegistrationCache),
|
||||
errors.Is(err, state.ErrRegistrationExpired):
|
||||
return huma.Error404NotFound(msg, err)
|
||||
|
||||
case errors.Is(err, state.ErrGivenNameInvalid),
|
||||
errors.Is(err, state.ErrGivenNameTaken),
|
||||
errors.Is(err, state.ErrNodeNameNotUnique),
|
||||
errors.Is(err, state.ErrNodeMarkedTaggedButHasNoTags),
|
||||
errors.Is(err, state.ErrNodeHasNeitherUserNorTags),
|
||||
errors.Is(err, state.ErrRequestedTagsInvalidOrNotPermitted),
|
||||
errors.Is(err, db.ErrUserStillHasNodes),
|
||||
errors.Is(err, db.ErrCannotChangeOIDCUser),
|
||||
errors.Is(err, db.ErrPreAuthKeyNotTaggedOrOwned),
|
||||
errors.Is(err, db.ErrSingleUseAuthKeyHasBeenUsed):
|
||||
return huma.Error400BadRequest(msg, err)
|
||||
|
||||
case errors.Is(err, state.ErrNodeKeyInUse),
|
||||
errors.Is(err, state.ErrAmbiguousNodeOwnership):
|
||||
return huma.Error409Conflict(msg, err)
|
||||
|
||||
default:
|
||||
return huma.Error500InternalServerError(msg, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
)
|
||||
|
||||
// HealthResponseBody mirrors the v1 HealthResponse message. database_connectivity
|
||||
// is reported true only when the database responds to a ping.
|
||||
type HealthResponseBody struct {
|
||||
DatabaseConnectivity bool `json:"databaseConnectivity"`
|
||||
}
|
||||
|
||||
type healthOutput struct {
|
||||
Body HealthResponseBody
|
||||
}
|
||||
|
||||
func init() {
|
||||
registrations = append(registrations, registerHealth)
|
||||
}
|
||||
|
||||
func registerHealth(api huma.API, b Backend) {
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "health",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/v1/health",
|
||||
Summary: "Health check",
|
||||
Description: "Reports server health, including database connectivity.",
|
||||
Tags: []string{"Health"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, _ *struct{}) (*healthOutput, error) {
|
||||
err := b.State.PingDB(ctx)
|
||||
if err != nil {
|
||||
return nil, mapError("pinging database", err)
|
||||
}
|
||||
|
||||
return &healthOutput{Body: HealthResponseBody{DatabaseConnectivity: true}}, nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,703 @@
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/util"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/key"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registrations = append(registrations, registerNodes)
|
||||
}
|
||||
|
||||
// errBackfillNotConfirmed guards BackfillNodeIPs behind explicit confirmed=true.
|
||||
var errBackfillNotConfirmed = errors.New("not confirmed, aborting")
|
||||
|
||||
// registerMethodToV1Enum maps the stored register method onto the
|
||||
// SCREAMING_SNAKE enum string the v1 contract emits.
|
||||
var registerMethodToV1Enum = map[string]string{
|
||||
util.RegisterMethodAuthKey: "REGISTER_METHOD_AUTH_KEY",
|
||||
util.RegisterMethodOIDC: "REGISTER_METHOD_OIDC",
|
||||
util.RegisterMethodCLI: "REGISTER_METHOD_CLI",
|
||||
}
|
||||
|
||||
// Node mirrors the v1 Node message. The protojson contract emits unpopulated
|
||||
// fields: scalars and slices always (no omitempty), nested messages and optional
|
||||
// timestamps as JSON null when unset.
|
||||
type Node struct {
|
||||
ID string `format:"uint64" json:"id"`
|
||||
MachineKey string `json:"machineKey"`
|
||||
NodeKey string `json:"nodeKey"`
|
||||
DiscoKey string `json:"discoKey"`
|
||||
IPAddresses []string `json:"ipAddresses" nullable:"false"`
|
||||
Name string `json:"name"`
|
||||
User *User `json:"user"`
|
||||
LastSeen *time.Time `json:"lastSeen" nullable:"true"`
|
||||
Expiry *time.Time `json:"expiry" nullable:"true"`
|
||||
PreAuthKey *NodePreAuthKey `json:"preAuthKey"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
RegisterMethod string `enum:"REGISTER_METHOD_UNSPECIFIED,REGISTER_METHOD_AUTH_KEY,REGISTER_METHOD_CLI,REGISTER_METHOD_OIDC" json:"registerMethod"`
|
||||
GivenName string `json:"givenName"`
|
||||
Online bool `json:"online"`
|
||||
ApprovedRoutes []string `json:"approvedRoutes" nullable:"false"`
|
||||
AvailableRoutes []string `json:"availableRoutes" nullable:"false"`
|
||||
SubnetRoutes []string `json:"subnetRoutes" nullable:"false"`
|
||||
Tags []string `json:"tags" nullable:"false"`
|
||||
}
|
||||
|
||||
// NodePreAuthKey is the PreAuthKey shape embedded in a Node response. The
|
||||
// /preauthkey endpoints own the standalone request/response surface.
|
||||
type NodePreAuthKey struct {
|
||||
User *User `json:"user"`
|
||||
ID string `format:"uint64" json:"id"`
|
||||
Key string `json:"key"`
|
||||
Reusable bool `json:"reusable"`
|
||||
Ephemeral bool `json:"ephemeral"`
|
||||
Used bool `json:"used"`
|
||||
Expiration *time.Time `json:"expiration" nullable:"true"`
|
||||
CreatedAt *time.Time `json:"createdAt" nullable:"true"`
|
||||
AclTags []string `json:"aclTags" nullable:"false"`
|
||||
}
|
||||
|
||||
// SetTagsRequestBody mirrors v1.SetTagsRequest.
|
||||
type SetTagsRequestBody struct {
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
// SetApprovedRoutesRequestBody mirrors v1.SetApprovedRoutesRequest.
|
||||
type SetApprovedRoutesRequestBody struct {
|
||||
Routes []string `json:"routes,omitempty"`
|
||||
}
|
||||
|
||||
// DebugCreateNodeRequestBody mirrors v1.DebugCreateNodeRequest.
|
||||
type DebugCreateNodeRequestBody struct {
|
||||
User string `json:"user,omitempty"`
|
||||
Key string `json:"key,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Routes []string `json:"routes,omitempty"`
|
||||
}
|
||||
|
||||
type (
|
||||
getNodeInput struct {
|
||||
NodeID string `format:"uint64" path:"nodeId"`
|
||||
}
|
||||
nodeOutput struct {
|
||||
Body struct {
|
||||
Node Node `json:"node"`
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
type (
|
||||
listNodesInput struct {
|
||||
User string `query:"user"`
|
||||
}
|
||||
listNodesOutput struct {
|
||||
Body struct {
|
||||
Nodes []Node `json:"nodes" nullable:"false"`
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
type (
|
||||
deleteNodeInput struct {
|
||||
NodeID string `format:"uint64" path:"nodeId"`
|
||||
}
|
||||
deleteNodeOutput struct {
|
||||
Body struct{}
|
||||
}
|
||||
)
|
||||
|
||||
// ExpireNodeRequestBody mirrors v1.ExpireNodeRequest. Both fields are optional;
|
||||
// an absent or all-zero body expires the node immediately, as gRPC does.
|
||||
type ExpireNodeRequestBody struct {
|
||||
Expiry *time.Time `json:"expiry,omitempty"`
|
||||
DisableExpiry bool `json:"disableExpiry,omitempty"`
|
||||
}
|
||||
|
||||
type expireNodeInput struct {
|
||||
NodeID string `format:"uint64" path:"nodeId"`
|
||||
Body *ExpireNodeRequestBody `required:"false"`
|
||||
}
|
||||
|
||||
type renameNodeInput struct {
|
||||
NodeID string `format:"uint64" path:"nodeId"`
|
||||
NewName string `path:"newName"`
|
||||
}
|
||||
|
||||
type setTagsInput struct {
|
||||
NodeID string `format:"uint64" path:"nodeId"`
|
||||
Body SetTagsRequestBody
|
||||
}
|
||||
|
||||
type setApprovedRoutesInput struct {
|
||||
NodeID string `format:"uint64" path:"nodeId"`
|
||||
Body SetApprovedRoutesRequestBody
|
||||
}
|
||||
|
||||
type registerNodeInput struct {
|
||||
User string `query:"user"`
|
||||
Key string `query:"key"`
|
||||
}
|
||||
|
||||
type backfillNodeIPsInput struct {
|
||||
Confirmed bool `query:"confirmed"`
|
||||
}
|
||||
|
||||
type backfillNodeIPsOutput struct {
|
||||
Body struct {
|
||||
Changes []string `json:"changes" nullable:"false"`
|
||||
}
|
||||
}
|
||||
|
||||
type debugCreateNodeInput struct {
|
||||
Body DebugCreateNodeRequestBody
|
||||
}
|
||||
|
||||
func registerNodes(api huma.API, b Backend) {
|
||||
registerNodeReadOps(api, b)
|
||||
registerNodeWriteOps(api, b)
|
||||
registerNodeAdminOps(api, b)
|
||||
}
|
||||
|
||||
func registerNodeReadOps(api huma.API, b Backend) {
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "getNode",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/v1/node/{nodeId}",
|
||||
Summary: "Get node",
|
||||
Tags: []string{"Nodes"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, in *getNodeInput) (*nodeOutput, error) {
|
||||
nodeID, err := parseNodeID(in.NodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
node, ok := b.State.GetNodeByID(nodeID)
|
||||
if !ok {
|
||||
return nil, huma.Error404NotFound("node not found")
|
||||
}
|
||||
|
||||
out := &nodeOutput{}
|
||||
out.Body.Node = nodeFromView(node)
|
||||
|
||||
return out, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "listNodes",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/v1/node",
|
||||
Summary: "List nodes",
|
||||
Tags: []string{"Nodes"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, in *listNodesInput) (*listNodesOutput, error) {
|
||||
nodes := b.State.ListNodes()
|
||||
if in.User != "" {
|
||||
user, err := b.State.GetUserByName(in.User)
|
||||
if err != nil {
|
||||
return nil, mapError("listing nodes", err)
|
||||
}
|
||||
|
||||
nodes = b.State.ListNodesByUser(types.UserID(user.ID))
|
||||
}
|
||||
|
||||
out := &listNodesOutput{}
|
||||
out.Body.Nodes = make([]Node, nodes.Len())
|
||||
|
||||
for i, node := range nodes.All() {
|
||||
n := nodeFromView(node)
|
||||
|
||||
// Tags-as-identity: tagged nodes are presented as the special
|
||||
// TaggedDevices user.
|
||||
if node.IsTagged() {
|
||||
user := userFromState(&types.TaggedDevices)
|
||||
n.User = &user
|
||||
}
|
||||
|
||||
// SubnetRoutes is the routes actively served, exit routes included.
|
||||
n.SubnetRoutes = util.PrefixesToString(
|
||||
append(b.State.GetNodePrimaryRoutes(node.ID()), node.ExitRoutes()...),
|
||||
)
|
||||
|
||||
out.Body.Nodes[i] = n
|
||||
}
|
||||
|
||||
// Match the gRPC handler's ascending-ID ordering.
|
||||
slices.SortFunc(out.Body.Nodes, func(a, b Node) int {
|
||||
return cmpNodeID(a.ID, b.ID)
|
||||
})
|
||||
|
||||
return out, nil
|
||||
})
|
||||
}
|
||||
|
||||
func registerNodeWriteOps(api huma.API, b Backend) {
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "deleteNode",
|
||||
Method: http.MethodDelete,
|
||||
Path: "/api/v1/node/{nodeId}",
|
||||
Summary: "Delete node",
|
||||
Tags: []string{"Nodes"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, in *deleteNodeInput) (*deleteNodeOutput, error) {
|
||||
nodeID, err := parseNodeID(in.NodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
node, ok := b.State.GetNodeByID(nodeID)
|
||||
if !ok {
|
||||
return nil, huma.Error404NotFound("node not found")
|
||||
}
|
||||
|
||||
nodeChange, err := b.State.DeleteNode(node)
|
||||
if err != nil {
|
||||
return nil, huma.Error500InternalServerError("deleting node", err)
|
||||
}
|
||||
|
||||
b.Change(nodeChange)
|
||||
|
||||
return &deleteNodeOutput{}, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "expireNode",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/v1/node/{nodeId}/expire",
|
||||
Summary: "Expire node",
|
||||
Tags: []string{"Nodes"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, in *expireNodeInput) (*nodeOutput, error) {
|
||||
nodeID, err := parseNodeID(in.NodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// gRPC parity: disableExpiry => nil expiry (never expires); explicit
|
||||
// expiry honoured; absent/zero body expires now. Both set is a 400.
|
||||
var (
|
||||
disableExpiry bool
|
||||
customExpiry *time.Time
|
||||
)
|
||||
|
||||
if in.Body != nil {
|
||||
disableExpiry = in.Body.DisableExpiry
|
||||
customExpiry = in.Body.Expiry
|
||||
}
|
||||
|
||||
if disableExpiry && customExpiry != nil {
|
||||
return nil, huma.Error400BadRequest("cannot set both disable_expiry and expiry")
|
||||
}
|
||||
|
||||
expiry := time.Now()
|
||||
|
||||
switch {
|
||||
case disableExpiry:
|
||||
node, nodeChange, expErr := b.State.SetNodeExpiry(nodeID, nil)
|
||||
if expErr != nil {
|
||||
return nil, mapError("expiring node", expErr)
|
||||
}
|
||||
|
||||
b.Change(nodeChange)
|
||||
|
||||
out := &nodeOutput{}
|
||||
out.Body.Node = nodeFromView(node)
|
||||
|
||||
return out, nil
|
||||
case customExpiry != nil:
|
||||
expiry = *customExpiry
|
||||
}
|
||||
|
||||
node, nodeChange, err := b.State.SetNodeExpiry(nodeID, &expiry)
|
||||
if err != nil {
|
||||
return nil, mapError("expiring node", err)
|
||||
}
|
||||
|
||||
b.Change(nodeChange)
|
||||
|
||||
out := &nodeOutput{}
|
||||
out.Body.Node = nodeFromView(node)
|
||||
|
||||
return out, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "renameNode",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/v1/node/{nodeId}/rename/{newName}",
|
||||
Summary: "Rename node",
|
||||
Tags: []string{"Nodes"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, in *renameNodeInput) (*nodeOutput, error) {
|
||||
nodeID, err := parseNodeID(in.NodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
node, nodeChange, err := b.State.RenameNode(nodeID, in.NewName)
|
||||
if err != nil {
|
||||
return nil, mapError("renaming node", err)
|
||||
}
|
||||
|
||||
b.Change(nodeChange)
|
||||
|
||||
out := &nodeOutput{}
|
||||
out.Body.Node = nodeFromView(node)
|
||||
|
||||
return out, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "setTags",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/v1/node/{nodeId}/tags",
|
||||
Summary: "Set tags",
|
||||
Tags: []string{"Nodes"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, in *setTagsInput) (*nodeOutput, error) {
|
||||
nodeID, err := parseNodeID(in.NodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Tagged nodes must keep at least one tag, so reject an empty set
|
||||
// before touching state, as gRPC does.
|
||||
if len(in.Body.Tags) == 0 {
|
||||
return nil, huma.Error400BadRequest(
|
||||
"cannot remove all tags from a node - tagged nodes must have at least one tag",
|
||||
)
|
||||
}
|
||||
|
||||
for _, tag := range in.Body.Tags {
|
||||
tagErr := validateTag(tag)
|
||||
if tagErr != nil {
|
||||
return nil, huma.Error400BadRequest("setting tags", tagErr)
|
||||
}
|
||||
}
|
||||
|
||||
_, found := b.State.GetNodeByID(nodeID)
|
||||
if !found {
|
||||
return nil, huma.Error404NotFound("node not found")
|
||||
}
|
||||
|
||||
node, nodeChange, err := b.State.SetNodeTags(nodeID, in.Body.Tags)
|
||||
if err != nil {
|
||||
return nil, huma.Error400BadRequest("setting tags", err)
|
||||
}
|
||||
|
||||
b.Change(nodeChange)
|
||||
|
||||
out := &nodeOutput{}
|
||||
out.Body.Node = nodeFromView(node)
|
||||
|
||||
return out, nil
|
||||
})
|
||||
}
|
||||
|
||||
func registerNodeAdminOps(api huma.API, b Backend) {
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "setApprovedRoutes",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/v1/node/{nodeId}/approve_routes",
|
||||
Summary: "Set approved routes",
|
||||
Tags: []string{"Nodes"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, in *setApprovedRoutesInput) (*nodeOutput, error) {
|
||||
nodeID, err := parseNodeID(in.NodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var newApproved []netip.Prefix
|
||||
|
||||
for _, route := range in.Body.Routes {
|
||||
prefix, parseErr := netip.ParsePrefix(route)
|
||||
if parseErr != nil {
|
||||
return nil, huma.Error400BadRequest("parsing route", parseErr)
|
||||
}
|
||||
|
||||
// One exit route implies both families, else the client won't
|
||||
// annotate the node as an exit node.
|
||||
if prefix == tsaddr.AllIPv4() || prefix == tsaddr.AllIPv6() {
|
||||
newApproved = append(newApproved, tsaddr.AllIPv4(), tsaddr.AllIPv6())
|
||||
} else {
|
||||
newApproved = append(newApproved, prefix)
|
||||
}
|
||||
}
|
||||
|
||||
slices.SortFunc(newApproved, netip.Prefix.Compare)
|
||||
newApproved = slices.Compact(newApproved)
|
||||
|
||||
node, nodeChange, err := b.State.SetApprovedRoutes(nodeID, newApproved)
|
||||
if err != nil {
|
||||
return nil, mapError("setting approved routes", err)
|
||||
}
|
||||
|
||||
b.Change(nodeChange)
|
||||
|
||||
out := &nodeOutput{}
|
||||
out.Body.Node = nodeFromView(node)
|
||||
// SubnetRoutes here excludes exit routes, unlike the list handler.
|
||||
out.Body.Node.SubnetRoutes = util.PrefixesToString(
|
||||
b.State.GetNodePrimaryRoutes(node.ID()),
|
||||
)
|
||||
|
||||
return out, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "registerNode",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/v1/node/register",
|
||||
Summary: "Register node",
|
||||
Tags: []string{"Nodes"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, in *registerNodeInput) (*nodeOutput, error) {
|
||||
registrationID, err := types.AuthIDFromString(in.Key)
|
||||
if err != nil {
|
||||
return nil, huma.Error400BadRequest("registering node", err)
|
||||
}
|
||||
|
||||
user, err := b.State.GetUserByName(in.User)
|
||||
if err != nil {
|
||||
return nil, mapError("looking up user", err)
|
||||
}
|
||||
|
||||
node, nodeChange, err := b.State.HandleNodeFromAuthPath(
|
||||
registrationID,
|
||||
types.UserID(user.ID),
|
||||
nil,
|
||||
util.RegisterMethodCLI,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, mapError("registering node", err)
|
||||
}
|
||||
|
||||
routeChange, err := b.State.AutoApproveRoutes(node)
|
||||
if err != nil {
|
||||
return nil, huma.Error500InternalServerError("auto approving routes", err)
|
||||
}
|
||||
|
||||
// Empty changes are ignored by the change sink.
|
||||
b.Change(nodeChange, routeChange)
|
||||
|
||||
out := &nodeOutput{}
|
||||
out.Body.Node = nodeFromView(node)
|
||||
|
||||
return out, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "backfillNodeIPs",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/v1/node/backfillips",
|
||||
Summary: "Backfill node IPs",
|
||||
Tags: []string{"Nodes"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, in *backfillNodeIPsInput) (*backfillNodeIPsOutput, error) {
|
||||
if !in.Confirmed {
|
||||
return nil, huma.Error400BadRequest("backfilling node IPs", errBackfillNotConfirmed)
|
||||
}
|
||||
|
||||
changes, err := b.State.BackfillNodeIPs()
|
||||
if err != nil {
|
||||
return nil, huma.Error500InternalServerError("backfilling node IPs", err)
|
||||
}
|
||||
|
||||
out := &backfillNodeIPsOutput{}
|
||||
out.Body.Changes = changes
|
||||
|
||||
if out.Body.Changes == nil {
|
||||
out.Body.Changes = []string{}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "debugCreateNode",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/v1/debug/node",
|
||||
Summary: "Debug create node",
|
||||
Tags: []string{"Nodes"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, in *debugCreateNodeInput) (*nodeOutput, error) {
|
||||
user, err := b.State.GetUserByName(in.Body.User)
|
||||
if err != nil {
|
||||
return nil, mapError("looking up user", err)
|
||||
}
|
||||
|
||||
routes, err := util.StringToIPPrefix(in.Body.Routes)
|
||||
if err != nil {
|
||||
return nil, huma.Error400BadRequest("parsing routes", err)
|
||||
}
|
||||
|
||||
registrationID, err := types.AuthIDFromString(in.Body.Key)
|
||||
if err != nil {
|
||||
return nil, huma.Error400BadRequest("debug creating node", err)
|
||||
}
|
||||
|
||||
regData := &types.RegistrationData{
|
||||
NodeKey: key.NewNode().Public(),
|
||||
MachineKey: key.NewMachine().Public(),
|
||||
Hostname: in.Body.Name,
|
||||
Expiry: &time.Time{}, // zero time, not nil, to keep proto JSON round-trip semantics
|
||||
}
|
||||
|
||||
authRegReq := types.NewRegisterAuthRequest(regData)
|
||||
b.State.SetAuthCacheEntry(registrationID, authRegReq)
|
||||
|
||||
// Synthetic echo; the real node is created later via the auth path
|
||||
// from the cached registration data.
|
||||
echoNode := types.Node{
|
||||
NodeKey: regData.NodeKey,
|
||||
MachineKey: regData.MachineKey,
|
||||
Hostname: regData.Hostname,
|
||||
User: user,
|
||||
Expiry: &time.Time{},
|
||||
LastSeen: &time.Time{},
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
Hostname: in.Body.Name,
|
||||
OS: "TestOS",
|
||||
RoutableIPs: routes,
|
||||
},
|
||||
}
|
||||
|
||||
out := &nodeOutput{}
|
||||
out.Body.Node = nodeFromView(echoNode.View())
|
||||
|
||||
return out, nil
|
||||
})
|
||||
}
|
||||
|
||||
// nodeFromView builds the Node response from a NodeView. SubnetRoutes is left
|
||||
// empty; callers that serve routes set it explicitly.
|
||||
func nodeFromView(view types.NodeView) Node {
|
||||
node := view.AsStruct()
|
||||
|
||||
n := Node{
|
||||
ID: formatID(uint64(node.ID)),
|
||||
MachineKey: node.MachineKey.String(),
|
||||
NodeKey: node.NodeKey.String(),
|
||||
DiscoKey: node.DiscoKey.String(),
|
||||
IPAddresses: nonNilStrings(node.IPsAsString()),
|
||||
Name: node.Hostname,
|
||||
CreatedAt: node.CreatedAt,
|
||||
RegisterMethod: registerMethodEnum(node.RegisterMethod),
|
||||
GivenName: node.GivenName,
|
||||
Online: node.IsOnline != nil && *node.IsOnline,
|
||||
ApprovedRoutes: nonNilStrings(util.PrefixesToString(node.ApprovedRoutes)),
|
||||
AvailableRoutes: nonNilStrings(util.PrefixesToString(node.AnnouncedRoutes())),
|
||||
SubnetRoutes: []string{},
|
||||
Tags: nonNilStrings(node.Tags),
|
||||
}
|
||||
|
||||
if node.User != nil {
|
||||
user := userFromState(node.User)
|
||||
n.User = &user
|
||||
}
|
||||
|
||||
if node.AuthKey != nil {
|
||||
n.PreAuthKey = nodePreAuthKeyFromState(node.AuthKey)
|
||||
}
|
||||
|
||||
if node.LastSeen != nil {
|
||||
ls := *node.LastSeen
|
||||
n.LastSeen = &ls
|
||||
}
|
||||
|
||||
if node.Expiry != nil {
|
||||
exp := *node.Expiry
|
||||
n.Expiry = &exp
|
||||
}
|
||||
|
||||
return n
|
||||
}
|
||||
|
||||
// nodePreAuthKeyFromState builds the embedded NodePreAuthKey, masking the key to
|
||||
// its prefix (legacy plaintext keys are shown in full).
|
||||
func nodePreAuthKeyFromState(key *types.PreAuthKey) *NodePreAuthKey {
|
||||
pak := &NodePreAuthKey{
|
||||
ID: formatID(key.ID),
|
||||
Key: maskedPreAuthKey(key),
|
||||
Reusable: key.Reusable,
|
||||
Ephemeral: key.Ephemeral,
|
||||
Used: key.Used,
|
||||
AclTags: nonNilStrings(key.Tags),
|
||||
}
|
||||
|
||||
if key.User != nil {
|
||||
user := userFromState(key.User)
|
||||
pak.User = &user
|
||||
}
|
||||
|
||||
if key.Expiration != nil {
|
||||
exp := *key.Expiration
|
||||
pak.Expiration = &exp
|
||||
}
|
||||
|
||||
if key.CreatedAt != nil {
|
||||
created := *key.CreatedAt
|
||||
pak.CreatedAt = &created
|
||||
}
|
||||
|
||||
return pak
|
||||
}
|
||||
|
||||
// registerMethodEnum maps the stored register method onto the v1 enum string,
|
||||
// defaulting to REGISTER_METHOD_UNSPECIFIED for unknown values.
|
||||
func registerMethodEnum(method string) string {
|
||||
if enum, ok := registerMethodToV1Enum[method]; ok {
|
||||
return enum
|
||||
}
|
||||
|
||||
return "REGISTER_METHOD_UNSPECIFIED"
|
||||
}
|
||||
|
||||
func nonNilStrings(s []string) []string {
|
||||
if s == nil {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// cmpNodeID orders two decimal node-ID strings numerically, matching the gRPC
|
||||
// handler's ascending-ID ordering.
|
||||
func cmpNodeID(a, b string) int {
|
||||
ai, _ := strconv.ParseUint(a, 10, 64)
|
||||
bi, _ := strconv.ParseUint(b, 10, 64)
|
||||
|
||||
switch {
|
||||
case ai < bi:
|
||||
return -1
|
||||
case ai > bi:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func parseNodeID(s string) (types.NodeID, error) {
|
||||
id, err := strconv.ParseUint(s, 10, 64)
|
||||
if err != nil {
|
||||
return 0, huma.Error400BadRequest(
|
||||
"type mismatch, parameter: node_id, error: " + err.Error(),
|
||||
)
|
||||
}
|
||||
|
||||
return types.NodeID(id), nil
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/util"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registrations = append(registrations, registerPolicy)
|
||||
}
|
||||
|
||||
// PolicyRequestBody carries the HuJSON policy document as a string, for both
|
||||
// v1.SetPolicyRequest and v1.CheckPolicyRequest.
|
||||
type PolicyRequestBody struct {
|
||||
Policy string `json:"policy,omitempty"`
|
||||
}
|
||||
|
||||
// PolicyResponseBody is the v1.GetPolicyResponse/SetPolicyResponse body. Fields
|
||||
// carry no omitempty so zero values are emitted (EmitUnpopulated parity).
|
||||
type PolicyResponseBody struct {
|
||||
Policy string `json:"policy"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type (
|
||||
getPolicyInput struct{}
|
||||
getPolicyOutput struct {
|
||||
Body PolicyResponseBody
|
||||
}
|
||||
|
||||
setPolicyInput struct {
|
||||
Body PolicyRequestBody
|
||||
}
|
||||
setPolicyOutput struct {
|
||||
Body PolicyResponseBody
|
||||
}
|
||||
|
||||
checkPolicyInput struct {
|
||||
Body PolicyRequestBody
|
||||
}
|
||||
checkPolicyOutput struct {
|
||||
Body struct{}
|
||||
}
|
||||
)
|
||||
|
||||
func registerPolicy(api huma.API, b Backend) {
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "getPolicy",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/v1/policy",
|
||||
Summary: "Get policy",
|
||||
Tags: []string{"Policy"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, _ *getPolicyInput) (*getPolicyOutput, error) {
|
||||
switch b.Cfg.Policy.Mode {
|
||||
case types.PolicyModeDB:
|
||||
p, err := b.State.GetPolicy()
|
||||
if err != nil {
|
||||
return nil, huma.Error500InternalServerError("loading ACL from database", err)
|
||||
}
|
||||
|
||||
out := &getPolicyOutput{}
|
||||
out.Body.Policy = p.Data
|
||||
out.Body.UpdatedAt = p.UpdatedAt
|
||||
|
||||
return out, nil
|
||||
case types.PolicyModeFile:
|
||||
absPath := util.AbsolutePathFromConfigPath(b.Cfg.Policy.Path)
|
||||
|
||||
f, err := os.Open(absPath)
|
||||
if err != nil {
|
||||
return nil, huma.Error500InternalServerError(
|
||||
fmt.Sprintf("reading policy from path %q", absPath), err,
|
||||
)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
data, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return nil, huma.Error500InternalServerError("reading policy from file", err)
|
||||
}
|
||||
|
||||
out := &getPolicyOutput{}
|
||||
out.Body.Policy = string(data)
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
return nil, huma.Error500InternalServerError(fmt.Sprintf(
|
||||
"no supported policy mode found in configuration, policy.mode: %q",
|
||||
b.Cfg.Policy.Mode,
|
||||
), nil)
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "setPolicy",
|
||||
Method: http.MethodPut,
|
||||
Path: "/api/v1/policy",
|
||||
Summary: "Set policy",
|
||||
Tags: []string{"Policy"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, in *setPolicyInput) (*setPolicyOutput, error) {
|
||||
if b.Cfg.Policy.Mode != types.PolicyModeDB {
|
||||
// Policy updates are only valid in DB mode; otherwise 400.
|
||||
return nil, huma.Error400BadRequest(
|
||||
types.ErrPolicyUpdateIsDisabled.Error(), types.ErrPolicyUpdateIsDisabled,
|
||||
)
|
||||
}
|
||||
|
||||
p := in.Body.Policy
|
||||
|
||||
// Reject policy that would fail when building a map response. SSH rule
|
||||
// validation needs a node, so a server with no nodes can't catch every
|
||||
// case here.
|
||||
nodes := b.State.ListNodes()
|
||||
|
||||
_, err := b.State.SetPolicy([]byte(p))
|
||||
if err != nil {
|
||||
return nil, huma.Error400BadRequest("setting policy", err)
|
||||
}
|
||||
|
||||
if nodes.Len() > 0 {
|
||||
_, err = b.State.SSHPolicy(nodes.At(0))
|
||||
if err != nil {
|
||||
return nil, huma.Error400BadRequest("verifying SSH rules", err)
|
||||
}
|
||||
}
|
||||
|
||||
updated, err := b.State.SetPolicyInDB(p)
|
||||
if err != nil {
|
||||
return nil, huma.Error500InternalServerError("setting policy", err)
|
||||
}
|
||||
|
||||
// Reload even when content is unchanged: routes manually disabled before
|
||||
// may now qualify for auto-approval, so they must be re-evaluated.
|
||||
cs, err := b.State.ReloadPolicy()
|
||||
if err != nil {
|
||||
return nil, huma.Error500InternalServerError("reloading policy", err)
|
||||
}
|
||||
|
||||
if len(cs) > 0 {
|
||||
b.Change(cs...)
|
||||
}
|
||||
|
||||
out := &setPolicyOutput{}
|
||||
out.Body.Policy = updated.Data
|
||||
out.Body.UpdatedAt = updated.UpdatedAt
|
||||
|
||||
return out, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "checkPolicy",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/v1/policy/check",
|
||||
Summary: "Check policy",
|
||||
Description: "Validates the given policy against the server's live users and nodes without persisting it.",
|
||||
Tags: []string{"Policy"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, in *checkPolicyInput) (*checkPolicyOutput, error) {
|
||||
polB := []byte(in.Body.Policy)
|
||||
|
||||
users, err := b.State.ListAllUsers()
|
||||
if err != nil {
|
||||
return nil, huma.Error500InternalServerError("loading users", err)
|
||||
}
|
||||
|
||||
nodes := b.State.ListNodes()
|
||||
|
||||
pm, err := policyv2.NewPolicyManager(polB, users, nodes)
|
||||
if err != nil {
|
||||
return nil, huma.Error400BadRequest(err.Error(), err)
|
||||
}
|
||||
|
||||
_, err = pm.SetPolicy(polB)
|
||||
if err != nil {
|
||||
return nil, huma.Error400BadRequest(err.Error(), err)
|
||||
}
|
||||
|
||||
return &checkPolicyOutput{}, nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registrations = append(registrations, registerPreAuthKeys)
|
||||
}
|
||||
|
||||
// PreAuthKey is the v1 PreAuthKey message. User is a pointer with no omitempty
|
||||
// so tagged (system-created) keys emit "user":null. Expiration and CreatedAt
|
||||
// are always emitted, zero-stamped when unset.
|
||||
type PreAuthKey struct {
|
||||
User *User `json:"user"`
|
||||
ID string `format:"uint64" json:"id"`
|
||||
Key string `json:"key"`
|
||||
Reusable bool `json:"reusable"`
|
||||
Ephemeral bool `json:"ephemeral"`
|
||||
Used bool `json:"used"`
|
||||
Expiration time.Time `json:"expiration"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ACLTags []string `json:"aclTags" nullable:"false"`
|
||||
}
|
||||
|
||||
// CreatePreAuthKeyRequestBody is the v1.CreatePreAuthKeyRequest body. Every
|
||||
// field is optional, hence omitempty throughout.
|
||||
type CreatePreAuthKeyRequestBody struct {
|
||||
User string `format:"uint64" json:"user,omitempty"`
|
||||
Reusable bool `json:"reusable,omitempty"`
|
||||
Ephemeral bool `json:"ephemeral,omitempty"`
|
||||
Expiration *time.Time `json:"expiration,omitempty"`
|
||||
ACLTags []string `json:"aclTags,omitempty"`
|
||||
}
|
||||
|
||||
// ExpirePreAuthKeyRequestBody is the v1.ExpirePreAuthKeyRequest body.
|
||||
type ExpirePreAuthKeyRequestBody struct {
|
||||
ID string `format:"uint64" json:"id,omitempty"`
|
||||
}
|
||||
|
||||
type (
|
||||
createPreAuthKeyInput struct {
|
||||
Body CreatePreAuthKeyRequestBody
|
||||
}
|
||||
preAuthKeyOutput struct {
|
||||
Body struct {
|
||||
PreAuthKey PreAuthKey `json:"preAuthKey"`
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
type (
|
||||
expirePreAuthKeyInput struct {
|
||||
Body ExpirePreAuthKeyRequestBody
|
||||
}
|
||||
expirePreAuthKeyOutput struct {
|
||||
Body struct{}
|
||||
}
|
||||
)
|
||||
|
||||
type (
|
||||
deletePreAuthKeyInput struct {
|
||||
ID string `format:"uint64" query:"id"`
|
||||
}
|
||||
deletePreAuthKeyOutput struct {
|
||||
Body struct{}
|
||||
}
|
||||
)
|
||||
|
||||
type listPreAuthKeysOutput struct {
|
||||
Body struct {
|
||||
PreAuthKeys []PreAuthKey `json:"preAuthKeys" nullable:"false"`
|
||||
}
|
||||
}
|
||||
|
||||
func registerPreAuthKeys(api huma.API, b Backend) {
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "createPreAuthKey",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/v1/preauthkey",
|
||||
Summary: "Create pre-auth key",
|
||||
Tags: []string{"PreAuthKeys"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, in *createPreAuthKeyInput) (*preAuthKeyOutput, error) {
|
||||
user, err := parsePreAuthKeyUser(in.Body.User)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, tag := range in.Body.ACLTags {
|
||||
tagErr := validateTag(tag)
|
||||
if tagErr != nil {
|
||||
return nil, huma.Error400BadRequest("invalid tag", tagErr)
|
||||
}
|
||||
}
|
||||
|
||||
// CreatePreAuthKey requires a non-nil pointer; zero-stamp when unset.
|
||||
var expiration time.Time
|
||||
if in.Body.Expiration != nil {
|
||||
expiration = *in.Body.Expiration
|
||||
}
|
||||
|
||||
var userID *types.UserID
|
||||
|
||||
if user != 0 {
|
||||
u, getErr := b.State.GetUserByID(user)
|
||||
if getErr != nil {
|
||||
return nil, mapError("creating pre-auth key", getErr)
|
||||
}
|
||||
|
||||
userID = u.TypedID()
|
||||
}
|
||||
|
||||
preAuthKey, err := b.State.CreatePreAuthKey(
|
||||
userID,
|
||||
in.Body.Reusable,
|
||||
in.Body.Ephemeral,
|
||||
&expiration,
|
||||
in.Body.ACLTags,
|
||||
)
|
||||
if err != nil {
|
||||
// A key that is neither tagged nor user-owned is invalid input (400).
|
||||
return nil, mapError("creating pre-auth key", err)
|
||||
}
|
||||
|
||||
out := &preAuthKeyOutput{}
|
||||
out.Body.PreAuthKey = preAuthKeyNewToResponse(preAuthKey)
|
||||
|
||||
return out, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "expirePreAuthKey",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/v1/preauthkey/expire",
|
||||
Summary: "Expire pre-auth key",
|
||||
Tags: []string{"PreAuthKeys"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, in *expirePreAuthKeyInput) (*expirePreAuthKeyOutput, error) {
|
||||
id, err := parsePreAuthKeyID(in.Body.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = b.State.ExpirePreAuthKey(id)
|
||||
if err != nil {
|
||||
// An unknown key id maps to 404.
|
||||
return nil, mapError("expiring pre-auth key", err)
|
||||
}
|
||||
|
||||
return &expirePreAuthKeyOutput{}, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "deletePreAuthKey",
|
||||
Method: http.MethodDelete,
|
||||
Path: "/api/v1/preauthkey",
|
||||
Summary: "Delete pre-auth key",
|
||||
Tags: []string{"PreAuthKeys"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, in *deletePreAuthKeyInput) (*deletePreAuthKeyOutput, error) {
|
||||
// DELETE has no body: id is bound from the query string.
|
||||
id, err := parsePreAuthKeyID(in.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = b.State.DeletePreAuthKey(id)
|
||||
if err != nil {
|
||||
// An unknown key id maps to 404.
|
||||
return nil, mapError("deleting pre-auth key", err)
|
||||
}
|
||||
|
||||
return &deletePreAuthKeyOutput{}, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "listPreAuthKeys",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/v1/preauthkey",
|
||||
Summary: "List pre-auth keys",
|
||||
Tags: []string{"PreAuthKeys"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, _ *struct{}) (*listPreAuthKeysOutput, error) {
|
||||
preAuthKeys, err := b.State.ListPreAuthKeys()
|
||||
if err != nil {
|
||||
return nil, huma.Error500InternalServerError("listing pre-auth keys", err)
|
||||
}
|
||||
|
||||
// Match the gRPC handler's ascending-ID ordering.
|
||||
slices.SortFunc(preAuthKeys, func(a, b types.PreAuthKey) int {
|
||||
return cmp.Compare(a.ID, b.ID)
|
||||
})
|
||||
|
||||
out := &listPreAuthKeysOutput{}
|
||||
|
||||
out.Body.PreAuthKeys = make([]PreAuthKey, len(preAuthKeys))
|
||||
for i := range preAuthKeys {
|
||||
out.Body.PreAuthKeys[i] = preAuthKeyToResponse(&preAuthKeys[i])
|
||||
}
|
||||
|
||||
return out, nil
|
||||
})
|
||||
}
|
||||
|
||||
// preAuthKeyNewToResponse builds the v1 response for a freshly created key. The
|
||||
// plaintext key is returned only here; Used is always false.
|
||||
func preAuthKeyNewToResponse(key *types.PreAuthKeyNew) PreAuthKey {
|
||||
out := PreAuthKey{
|
||||
ID: formatID(key.ID),
|
||||
Key: key.Key,
|
||||
Reusable: key.Reusable,
|
||||
Ephemeral: key.Ephemeral,
|
||||
ACLTags: nonNilTags(key.Tags),
|
||||
}
|
||||
|
||||
if key.User != nil {
|
||||
u := userFromState(key.User)
|
||||
out.User = &u
|
||||
}
|
||||
|
||||
if key.Expiration != nil {
|
||||
out.Expiration = *key.Expiration
|
||||
}
|
||||
|
||||
if key.CreatedAt != nil {
|
||||
out.CreatedAt = *key.CreatedAt
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// preAuthKeyToResponse builds the v1 response for a stored key, with its key
|
||||
// field masked (see maskedPreAuthKey).
|
||||
func preAuthKeyToResponse(key *types.PreAuthKey) PreAuthKey {
|
||||
out := PreAuthKey{
|
||||
ID: formatID(key.ID),
|
||||
Key: maskedPreAuthKey(key),
|
||||
Reusable: key.Reusable,
|
||||
Ephemeral: key.Ephemeral,
|
||||
Used: key.Used,
|
||||
ACLTags: nonNilTags(key.Tags),
|
||||
}
|
||||
|
||||
if key.User != nil {
|
||||
u := userFromState(key.User)
|
||||
out.User = &u
|
||||
}
|
||||
|
||||
if key.Expiration != nil {
|
||||
out.Expiration = *key.Expiration
|
||||
}
|
||||
|
||||
if key.CreatedAt != nil {
|
||||
out.CreatedAt = *key.CreatedAt
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// maskedPreAuthKey masks new keys (those with a stored prefix) so the secret is
|
||||
// never returned; legacy plaintext keys are returned in full for backwards
|
||||
// compatibility.
|
||||
func maskedPreAuthKey(key *types.PreAuthKey) string {
|
||||
if key.Prefix != "" {
|
||||
return "hskey-auth-" + key.Prefix + "-***"
|
||||
}
|
||||
|
||||
return key.Key
|
||||
}
|
||||
|
||||
// nonNilTags ensures aclTags serializes as [] rather than null, matching
|
||||
// EmitUnpopulated output.
|
||||
func nonNilTags(tags []string) []string {
|
||||
if tags == nil {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
return tags
|
||||
}
|
||||
|
||||
// parsePreAuthKeyUser parses the optional uint64 user field. Empty means "no
|
||||
// user" (user 0); non-numeric is rejected with 400.
|
||||
func parsePreAuthKeyUser(s string) (types.UserID, error) {
|
||||
if s == "" {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(s, 10, 64)
|
||||
if err != nil {
|
||||
return 0, huma.Error400BadRequest("invalid user id", err)
|
||||
}
|
||||
|
||||
return types.UserID(id), nil
|
||||
}
|
||||
|
||||
// parsePreAuthKeyID parses the uint64 key id. Empty means id 0; non-numeric is
|
||||
// rejected with 400.
|
||||
func parsePreAuthKeyID(s string) (uint64, error) {
|
||||
if s == "" {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(s, 10, 64)
|
||||
if err != nil {
|
||||
return 0, huma.Error400BadRequest("invalid pre-auth key id", err)
|
||||
}
|
||||
|
||||
return id, nil
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ACL tag validation, shared by the node and pre-auth-key resources. These
|
||||
// reproduce the gRPC validateTag checks and messages.
|
||||
var (
|
||||
errTagMissingPrefix = errors.New("tag must start with the string 'tag:'")
|
||||
errTagNotLowercase = errors.New("tag should be lowercase")
|
||||
errTagHasSpaces = errors.New("tags must not contain spaces")
|
||||
)
|
||||
|
||||
// validateTag reports whether an ACL tag is well formed: it must start with
|
||||
// "tag:", be lowercase, and contain no spaces.
|
||||
func validateTag(tag string) error {
|
||||
switch {
|
||||
case !strings.HasPrefix(tag, "tag:"):
|
||||
return errTagMissingPrefix
|
||||
case strings.ToLower(tag) != tag:
|
||||
return errTagNotLowercase
|
||||
case len(strings.Fields(tag)) > 1:
|
||||
return errTagHasSpaces
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
)
|
||||
|
||||
// The v1 contract follows protojson: 64-bit integers are JSON strings (avoiding
|
||||
// precision loss above 2^53), timestamps are RFC 3339, and zero values are
|
||||
// emitted. Hence response fields carry NO omitempty; request types keep
|
||||
// omitempty so their fields stay optional in the spec.
|
||||
|
||||
// formatID renders a uint64 identifier as the contract's decimal string.
|
||||
func formatID[T ~uint64 | ~uint](id T) string {
|
||||
return strconv.FormatUint(uint64(id), 10)
|
||||
}
|
||||
|
||||
// User mirrors the v1 User message.
|
||||
type User struct {
|
||||
ID string `format:"uint64" json:"id"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Email string `json:"email"`
|
||||
ProviderID string `json:"providerId"`
|
||||
Provider string `json:"provider"`
|
||||
ProfilePicURL string `json:"profilePicUrl"`
|
||||
}
|
||||
|
||||
// userFromState converts a domain user into the v1 response shape: Name falls
|
||||
// back to Username() (email/provider/id) when the stored Name is empty, so OIDC
|
||||
// users display their email.
|
||||
func userFromState(u *types.User) User {
|
||||
name := u.Name
|
||||
if name == "" {
|
||||
name = u.Username()
|
||||
}
|
||||
|
||||
return User{
|
||||
ID: formatID(u.ID),
|
||||
Name: name,
|
||||
CreatedAt: u.CreatedAt,
|
||||
DisplayName: u.DisplayName,
|
||||
Email: u.Email,
|
||||
ProviderID: u.ProviderIdentifier.String,
|
||||
Provider: u.Provider,
|
||||
ProfilePicURL: u.ProfilePicURL,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registrations = append(registrations, registerUsers)
|
||||
}
|
||||
|
||||
// CreateUserRequestBody mirrors v1.CreateUserRequest.
|
||||
type CreateUserRequestBody struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
DisplayName string `json:"displayName,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
PictureURL string `json:"pictureUrl,omitempty"`
|
||||
}
|
||||
|
||||
type (
|
||||
createUserInput struct {
|
||||
Body CreateUserRequestBody
|
||||
}
|
||||
userOutput struct {
|
||||
Body struct {
|
||||
User User `json:"user"`
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
type (
|
||||
renameUserInput struct {
|
||||
OldID string `format:"uint64" path:"oldId"`
|
||||
NewName string `path:"newName"`
|
||||
}
|
||||
|
||||
deleteUserInput struct {
|
||||
ID string `format:"uint64" path:"id"`
|
||||
}
|
||||
deleteUserOutput struct {
|
||||
Body struct{}
|
||||
}
|
||||
)
|
||||
|
||||
type (
|
||||
listUsersInput struct {
|
||||
ID string `format:"uint64" query:"id"`
|
||||
Name string `query:"name"`
|
||||
Email string `query:"email"`
|
||||
}
|
||||
listUsersOutput struct {
|
||||
Body struct {
|
||||
Users []User `json:"users" nullable:"false"`
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
func registerUsers(api huma.API, b Backend) {
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "createUser",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/v1/user",
|
||||
Summary: "Create user",
|
||||
Tags: []string{"Users"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, in *createUserInput) (*userOutput, error) {
|
||||
// Pre-check yields a 409 for the common case; the DB unique constraint
|
||||
// is the real guard.
|
||||
if in.Body.Name != "" {
|
||||
_, err := b.State.GetUserByName(in.Body.Name)
|
||||
if err == nil {
|
||||
return nil, huma.Error409Conflict("user already exists")
|
||||
}
|
||||
}
|
||||
|
||||
user, policyChanged, err := b.State.CreateUser(types.User{
|
||||
Name: in.Body.Name,
|
||||
DisplayName: in.Body.DisplayName,
|
||||
Email: in.Body.Email,
|
||||
ProfilePicURL: in.Body.PictureURL,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, mapError("creating user", err)
|
||||
}
|
||||
|
||||
b.Change(policyChanged)
|
||||
|
||||
out := &userOutput{}
|
||||
out.Body.User = userFromState(user)
|
||||
|
||||
return out, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "renameUser",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/v1/user/{oldId}/rename/{newName}",
|
||||
Summary: "Rename user",
|
||||
Tags: []string{"Users"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, in *renameUserInput) (*userOutput, error) {
|
||||
oldID, err := parseUserID(in.OldID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
oldUser, err := b.State.GetUserByID(oldID)
|
||||
if err != nil {
|
||||
return nil, mapError("renaming user", err)
|
||||
}
|
||||
|
||||
_, c, err := b.State.RenameUser(types.UserID(oldUser.ID), in.NewName)
|
||||
if err != nil {
|
||||
return nil, mapError("renaming user", err)
|
||||
}
|
||||
|
||||
b.Change(c)
|
||||
|
||||
newUser, err := b.State.GetUserByName(in.NewName)
|
||||
if err != nil {
|
||||
return nil, huma.Error500InternalServerError("renaming user", err)
|
||||
}
|
||||
|
||||
out := &userOutput{}
|
||||
out.Body.User = userFromState(newUser)
|
||||
|
||||
return out, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "deleteUser",
|
||||
Method: http.MethodDelete,
|
||||
Path: "/api/v1/user/{id}",
|
||||
Summary: "Delete user",
|
||||
Tags: []string{"Users"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, in *deleteUserInput) (*deleteUserOutput, error) {
|
||||
id, err := parseUserID(in.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, err := b.State.GetUserByID(id)
|
||||
if err != nil {
|
||||
return nil, mapError("deleting user", err)
|
||||
}
|
||||
|
||||
policyChanged, err := b.State.DeleteUser(types.UserID(user.ID))
|
||||
if err != nil {
|
||||
return nil, mapError("deleting user", err)
|
||||
}
|
||||
|
||||
b.Change(policyChanged)
|
||||
|
||||
return &deleteUserOutput{}, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "listUsers",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/v1/user",
|
||||
Summary: "List users",
|
||||
Tags: []string{"Users"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, in *listUsersInput) (*listUsersOutput, error) {
|
||||
// Gateway parity: a non-numeric id is a 400 even when other filters win.
|
||||
if in.ID != "" {
|
||||
_, err := strconv.ParseUint(in.ID, 10, 64)
|
||||
if err != nil {
|
||||
return nil, huma.Error400BadRequest("invalid id", err)
|
||||
}
|
||||
}
|
||||
|
||||
users, err := listUsersFiltered(b, in)
|
||||
if err != nil {
|
||||
return nil, huma.Error500InternalServerError("listing users", err)
|
||||
}
|
||||
|
||||
// Match the gRPC handler's ascending-ID ordering.
|
||||
slices.SortFunc(users, func(a, b types.User) int {
|
||||
return cmp.Compare(a.ID, b.ID)
|
||||
})
|
||||
|
||||
out := &listUsersOutput{}
|
||||
|
||||
out.Body.Users = make([]User, len(users))
|
||||
for i := range users {
|
||||
out.Body.Users[i] = userFromState(&users[i])
|
||||
}
|
||||
|
||||
return out, nil
|
||||
})
|
||||
}
|
||||
|
||||
// listUsersFiltered reproduces the gRPC ListUsers precedence: name, then email,
|
||||
// then id, otherwise all users.
|
||||
func listUsersFiltered(b Backend, in *listUsersInput) ([]types.User, error) {
|
||||
switch {
|
||||
case in.Name != "":
|
||||
return b.State.ListUsersWithFilter(&types.User{Name: in.Name})
|
||||
case in.Email != "":
|
||||
return b.State.ListUsersWithFilter(&types.User{Email: in.Email})
|
||||
case in.ID != "":
|
||||
id, err := strconv.ParseUint(in.ID, 10, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if id == 0 {
|
||||
return b.State.ListAllUsers()
|
||||
}
|
||||
|
||||
return b.State.ListUsersWithFilter(&types.User{Model: gorm.Model{ID: uint(id)}})
|
||||
default:
|
||||
return b.State.ListAllUsers()
|
||||
}
|
||||
}
|
||||
|
||||
func parseUserID(s string) (types.UserID, error) {
|
||||
id, err := strconv.ParseUint(s, 10, 64)
|
||||
if err != nil {
|
||||
return 0, huma.Error400BadRequest("invalid user id", err)
|
||||
}
|
||||
|
||||
return types.UserID(id), nil
|
||||
}
|
||||
+46
-244
@@ -24,9 +24,7 @@ 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"
|
||||
@@ -37,22 +35,12 @@ import (
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/types/change"
|
||||
"github.com/juanfont/headscale/hscontrol/util"
|
||||
zerolog "github.com/philip-bui/grpc-zerolog"
|
||||
"github.com/pkg/profile"
|
||||
zl "github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/sasha-s/go-deadlock"
|
||||
"golang.org/x/crypto/acme"
|
||||
"golang.org/x/crypto/acme/autocert"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"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"
|
||||
"google.golang.org/grpc/status"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/dnstype"
|
||||
@@ -84,7 +72,6 @@ func init() {
|
||||
}
|
||||
|
||||
const (
|
||||
AuthPrefix = "Bearer "
|
||||
updateInterval = 5 * time.Second
|
||||
privateKeyFileMode = 0o600
|
||||
headscaleDirPerm = 0o700
|
||||
@@ -385,131 +372,6 @@ func (h *Headscale) scheduledTasks(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// checkBearerToken validates an "Authorization" header value. It reports
|
||||
// whether the API key is valid, whether the header carried the "Bearer "
|
||||
// prefix, and any validation error. Callers translate these outcomes into
|
||||
// their transport-specific status.
|
||||
func (h *Headscale) checkBearerToken(authHeader string) (bool, bool, error) {
|
||||
if !strings.HasPrefix(authHeader, AuthPrefix) {
|
||||
return false, false, nil
|
||||
}
|
||||
|
||||
valid, err := h.state.ValidateAPIKey(strings.TrimPrefix(authHeader, AuthPrefix))
|
||||
|
||||
return valid, true, err
|
||||
}
|
||||
|
||||
func (h *Headscale) grpcAuthenticationInterceptor(ctx context.Context,
|
||||
req any,
|
||||
info *grpc.UnaryServerInfo,
|
||||
handler grpc.UnaryHandler,
|
||||
) (any, error) {
|
||||
// Check if the request is coming from the on-server client.
|
||||
// This is not secure, but it is to maintain maintainability
|
||||
// with the "legacy" database-based client
|
||||
// It is also needed for grpc-gateway to be able to connect to
|
||||
// the server
|
||||
client, _ := peer.FromContext(ctx)
|
||||
|
||||
log.Trace().
|
||||
Caller().
|
||||
Str("client_address", client.Addr.String()).
|
||||
Msg("Client is trying to authenticate")
|
||||
|
||||
meta, ok := metadata.FromIncomingContext(ctx)
|
||||
if !ok {
|
||||
return ctx, status.Errorf(
|
||||
codes.InvalidArgument,
|
||||
"retrieving metadata",
|
||||
)
|
||||
}
|
||||
|
||||
authHeader, ok := meta["authorization"]
|
||||
if !ok {
|
||||
return ctx, status.Errorf(
|
||||
codes.Unauthenticated,
|
||||
"authorization token not supplied",
|
||||
)
|
||||
}
|
||||
|
||||
valid, hasPrefix, err := h.checkBearerToken(authHeader[0])
|
||||
if !hasPrefix {
|
||||
return ctx, status.Error(
|
||||
codes.Unauthenticated,
|
||||
`missing "Bearer " prefix in "Authorization" header`,
|
||||
)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return ctx, status.Error(codes.Internal, "validating token")
|
||||
}
|
||||
|
||||
if !valid {
|
||||
log.Info().
|
||||
Str("client_address", client.Addr.String()).
|
||||
Msg("invalid token")
|
||||
|
||||
return ctx, status.Error(codes.Unauthenticated, "invalid token")
|
||||
}
|
||||
|
||||
return handler(ctx, req)
|
||||
}
|
||||
|
||||
func (h *Headscale) httpAuthenticationMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(
|
||||
writer http.ResponseWriter,
|
||||
req *http.Request,
|
||||
) {
|
||||
log.Trace().
|
||||
Caller().
|
||||
Str("client_address", req.RemoteAddr).
|
||||
Msg("HTTP authentication invoked")
|
||||
|
||||
authHeader := req.Header.Get("Authorization")
|
||||
|
||||
writeUnauthorized := func(statusCode int) {
|
||||
writer.WriteHeader(statusCode)
|
||||
|
||||
if _, err := writer.Write([]byte("Unauthorized")); err != nil { //nolint:noinlineerr
|
||||
log.Error().Err(err).Msg("writing HTTP response failed")
|
||||
}
|
||||
}
|
||||
|
||||
valid, hasPrefix, err := h.checkBearerToken(authHeader)
|
||||
if !hasPrefix {
|
||||
log.Error().
|
||||
Caller().
|
||||
Str("client_address", req.RemoteAddr).
|
||||
Msg(`missing "Bearer " prefix in "Authorization" header`)
|
||||
writeUnauthorized(http.StatusUnauthorized)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Info().
|
||||
Caller().
|
||||
Err(err).
|
||||
Str("client_address", req.RemoteAddr).
|
||||
Msg("failed to validate token")
|
||||
writeUnauthorized(http.StatusUnauthorized)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !valid {
|
||||
log.Info().
|
||||
Str("client_address", req.RemoteAddr).
|
||||
Msg("invalid token")
|
||||
writeUnauthorized(http.StatusUnauthorized)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(writer, req)
|
||||
})
|
||||
}
|
||||
|
||||
// ensureUnixSocketIsAbsent will check if the given path for headscales unix socket is clear
|
||||
// and will remove it if it is not.
|
||||
func (h *Headscale) ensureUnixSocketIsAbsent() error {
|
||||
@@ -535,7 +397,18 @@ func securityHeaders(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Headscale) createRouter(grpcMux *grpcRuntime.ServeMux) *chi.Mux {
|
||||
// serveHumaMux dispatches to a Huma mux mounted under the outer chi router.
|
||||
// Huma registers operations at absolute paths (/api/v1/...), so chi's route
|
||||
// context must be cleared for the inner mux to re-match against the original URL.
|
||||
func serveHumaMux(mux http.Handler) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, req *http.Request) {
|
||||
mux.ServeHTTP(w, req.WithContext(
|
||||
context.WithValue(req.Context(), chi.RouteCtxKey, nil),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Headscale) createRouter(apiMux http.Handler) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
r.Use(metrics.Collector(metrics.CollectorOpts{
|
||||
Host: false,
|
||||
@@ -572,10 +445,6 @@ func (h *Headscale) createRouter(grpcMux *grpcRuntime.ServeMux) *chi.Mux {
|
||||
r.Get("/apple/{platform}", h.ApplePlatformConfig)
|
||||
r.Get("/windows", h.WindowsConfigMessage)
|
||||
|
||||
// TODO(kristoffer): move swagger into a package
|
||||
r.Get("/swagger", headscale.SwaggerUI)
|
||||
r.Get("/swagger/v1/openapiv2.json", headscale.SwaggerAPIv1)
|
||||
|
||||
r.Post("/verify", h.VerifyHandler)
|
||||
|
||||
if h.cfg.DERP.ServerEnabled {
|
||||
@@ -585,9 +454,11 @@ func (h *Headscale) createRouter(grpcMux *grpcRuntime.ServeMux) *chi.Mux {
|
||||
r.HandleFunc("/bootstrap-dns", derpServer.DERPBootstrapDNSHandler(h.state.DERPMap()))
|
||||
}
|
||||
|
||||
// Auth is enforced inside the Huma mux per-operation (bearer security), so
|
||||
// the whole API mounts as one handler: operations need an API key while the
|
||||
// OpenAPI document and docs UI stay public. A v2 mux is one more r.Handle line.
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.Use(h.httpAuthenticationMiddleware)
|
||||
r.HandleFunc("/v1/*", grpcMux.ServeHTTP)
|
||||
r.Handle("/v1/*", serveHumaMux(apiMux))
|
||||
})
|
||||
// Ping response endpoint: receives HEAD from clients responding
|
||||
// to a [tailcfg.PingRequest]. The unguessable ping ID serves as authentication.
|
||||
@@ -599,7 +470,7 @@ func (h *Headscale) createRouter(grpcMux *grpcRuntime.ServeMux) *chi.Mux {
|
||||
return r
|
||||
}
|
||||
|
||||
// Serve launches the HTTP and gRPC server service Headscale and the API.
|
||||
// Serve launches the HTTP servers that run Headscale and its API.
|
||||
//
|
||||
//nolint:gocyclo // complex server startup function
|
||||
func (h *Headscale) Serve() error {
|
||||
@@ -690,12 +561,6 @@ func (h *Headscale) Serve() error {
|
||||
|
||||
go h.scheduledTasks(scheduleCtx)
|
||||
|
||||
if zl.GlobalLevel() == zl.TraceLevel {
|
||||
zerolog.RespLog = true
|
||||
} else {
|
||||
zerolog.RespLog = false
|
||||
}
|
||||
|
||||
// Prepare group for running listeners
|
||||
errorGroup := new(errgroup.Group)
|
||||
|
||||
@@ -723,45 +588,32 @@ func (h *Headscale) Serve() error {
|
||||
|
||||
socketListener, err := new(net.ListenConfig).Listen(context.Background(), "unix", h.cfg.UnixSocket)
|
||||
if err != nil {
|
||||
return fmt.Errorf("setting up gRPC socket: %w", err)
|
||||
return fmt.Errorf("setting up socket: %w", err)
|
||||
}
|
||||
|
||||
// Change socket permissions
|
||||
if err := os.Chmod(h.cfg.UnixSocket, h.cfg.UnixSocketPermission); err != nil { //nolint:noinlineerr
|
||||
return fmt.Errorf("changing gRPC socket permission: %w", err)
|
||||
return fmt.Errorf("changing socket permission: %w", err)
|
||||
}
|
||||
|
||||
grpcGatewayMux := grpcRuntime.NewServeMux()
|
||||
// The Huma v1 API mux matches full /api/v1/... paths and is shared by
|
||||
// the local unix socket (served without authentication, local trust)
|
||||
// and the remote TCP router (served behind the API-key middleware).
|
||||
humaMux, _ := apiv1.Handler(apiv1.Backend{
|
||||
State: h.state,
|
||||
Change: h.Change,
|
||||
Cfg: h.cfg,
|
||||
})
|
||||
|
||||
// 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)
|
||||
// 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.
|
||||
socketServer := &http.Server{
|
||||
Handler: apiv1.WithLocalTrust(humaMux),
|
||||
ReadTimeout: types.HTTPTimeout,
|
||||
}
|
||||
|
||||
// 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.
|
||||
// zerolog.UnaryInterceptor(),
|
||||
)
|
||||
|
||||
v1.RegisterHeadscaleServiceServer(grpcSocket, newHeadscaleV1APIServer(h))
|
||||
reflection.Register(grpcSocket)
|
||||
|
||||
errorGroup.Go(func() error { return grpcSocket.Serve(socketListener) })
|
||||
errorGroup.Go(func() error { return socketServer.Serve(socketListener) })
|
||||
|
||||
//
|
||||
//
|
||||
@@ -773,65 +625,13 @@ func (h *Headscale) Serve() error {
|
||||
return fmt.Errorf("configuring TLS settings: %w", err)
|
||||
}
|
||||
|
||||
//
|
||||
//
|
||||
// gRPC setup
|
||||
//
|
||||
|
||||
// We are sadly not able to run gRPC and HTTPS (2.0) on the same
|
||||
// port because the connection mux does not support matching them
|
||||
// since they are so similar. There is multiple issues open and we
|
||||
// can revisit this if changes:
|
||||
// https://github.com/soheilhy/cmux/issues/68
|
||||
// https://github.com/soheilhy/cmux/issues/91
|
||||
|
||||
var (
|
||||
grpcServer *grpc.Server
|
||||
grpcListener net.Listener
|
||||
)
|
||||
|
||||
if tlsConfig != nil || h.cfg.GRPCAllowInsecure {
|
||||
log.Info().Msgf("enabling remote gRPC at %s", h.cfg.GRPCAddr)
|
||||
|
||||
grpcOptions := []grpc.ServerOption{
|
||||
grpc.ChainUnaryInterceptor(
|
||||
h.grpcAuthenticationInterceptor,
|
||||
// Uncomment to debug grpc communication.
|
||||
// zerolog.NewUnaryServerInterceptor(),
|
||||
),
|
||||
}
|
||||
|
||||
if tlsConfig != nil {
|
||||
grpcOptions = append(
|
||||
grpcOptions,
|
||||
grpc.Creds(credentials.NewTLS(tlsConfig)),
|
||||
)
|
||||
} else {
|
||||
log.Warn().Msg("gRPC is running without security")
|
||||
}
|
||||
|
||||
grpcServer = grpc.NewServer(grpcOptions...)
|
||||
|
||||
v1.RegisterHeadscaleServiceServer(grpcServer, newHeadscaleV1APIServer(h))
|
||||
|
||||
grpcListener, err = new(net.ListenConfig).Listen(context.Background(), "tcp", h.cfg.GRPCAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("binding to TCP address: %w", err)
|
||||
}
|
||||
|
||||
errorGroup.Go(func() error { return grpcServer.Serve(grpcListener) })
|
||||
|
||||
log.Info().
|
||||
Msgf("listening and serving gRPC on: %s", h.cfg.GRPCAddr)
|
||||
}
|
||||
|
||||
//
|
||||
//
|
||||
// HTTP setup
|
||||
//
|
||||
// This is the regular router that we expose
|
||||
// over our main Addr
|
||||
router := h.createRouter(grpcGatewayMux)
|
||||
router := h.createRouter(humaMux)
|
||||
|
||||
httpServer := &http.Server{
|
||||
Addr: h.cfg.Addr,
|
||||
@@ -972,13 +772,10 @@ 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 api server (socket)")
|
||||
|
||||
if grpcServer != nil {
|
||||
info("shutting down grpc server (external)")
|
||||
grpcServer.GracefulStop()
|
||||
grpcListener.Close()
|
||||
if err := socketServer.Shutdown(shutdownCtx); err != nil { //nolint:noinlineerr
|
||||
log.Error().Err(err).Msg("failed to shutdown socket server")
|
||||
}
|
||||
|
||||
if tailsqlContext != nil {
|
||||
@@ -994,7 +791,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 +954,13 @@ 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())
|
||||
humaMux, _ := apiv1.Handler(apiv1.Backend{
|
||||
State: h.state,
|
||||
Change: h.Change,
|
||||
Cfg: h.cfg,
|
||||
})
|
||||
|
||||
return h.createRouter(humaMux)
|
||||
}
|
||||
|
||||
// NoisePublicKey returns the server's Noise protocol public key.
|
||||
|
||||
-97
@@ -1,97 +0,0 @@
|
||||
package headscale
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"html/template"
|
||||
"net/http"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
//go:embed gen/openapiv2/headscale/v1/headscale.swagger.json
|
||||
var apiV1JSON []byte
|
||||
|
||||
func SwaggerUI(
|
||||
writer http.ResponseWriter,
|
||||
req *http.Request,
|
||||
) {
|
||||
swaggerTemplate := template.Must(template.New("swagger").Parse(`
|
||||
<html>
|
||||
<head>
|
||||
<link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@3/swagger-ui.css">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<script src="https://unpkg.com/swagger-ui-dist@3/swagger-ui-standalone-preset.js"></script>
|
||||
<script src="https://unpkg.com/swagger-ui-dist@3/swagger-ui-bundle.js" charset="UTF-8"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script>
|
||||
window.addEventListener('load', (event) => {
|
||||
const ui = SwaggerUIBundle({
|
||||
url: "/swagger/v1/openapiv2.json",
|
||||
dom_id: '#swagger-ui',
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIBundle.SwaggerUIStandalonePreset
|
||||
],
|
||||
plugins: [
|
||||
SwaggerUIBundle.plugins.DownloadUrl
|
||||
],
|
||||
deepLinking: true,
|
||||
// TODO(kradalby): Figure out why this does not work
|
||||
// layout: "StandaloneLayout",
|
||||
})
|
||||
window.ui = ui
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`))
|
||||
|
||||
var payload bytes.Buffer
|
||||
if err := swaggerTemplate.Execute(&payload, struct{}{}); err != nil { //nolint:noinlineerr
|
||||
log.Error().
|
||||
Caller().
|
||||
Err(err).
|
||||
Msg("Could not render Swagger")
|
||||
|
||||
writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
writer.WriteHeader(http.StatusInternalServerError)
|
||||
|
||||
_, err := writer.Write([]byte("Could not render Swagger"))
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Caller().
|
||||
Err(err).
|
||||
Msg("Failed to write response")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
|
||||
_, err := writer.Write(payload.Bytes())
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Caller().
|
||||
Err(err).
|
||||
Msg("Failed to write response")
|
||||
}
|
||||
}
|
||||
|
||||
func SwaggerAPIv1(
|
||||
writer http.ResponseWriter,
|
||||
req *http.Request,
|
||||
) {
|
||||
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
|
||||
if _, err := writer.Write(apiV1JSON); err != nil { //nolint:noinlineerr
|
||||
log.Error().
|
||||
Caller().
|
||||
Err(err).
|
||||
Msg("Failed to write response")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user