From c9bbb5bdec1dd52ed91248a718d74acb7b52e493 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Fri, 19 Jun 2026 06:12:20 +0000 Subject: [PATCH] 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. --- .gitignore | 3 + Makefile | 48 ++- cmd/gen-openapi/main.go | 50 +++ flakehashes.json | 4 +- go.mod | 15 +- go.sum | 24 +- hscontrol/api/v1/api.go | 160 ++++++++ hscontrol/api/v1/apikeys.go | 245 +++++++++++ hscontrol/api/v1/auth.go | 163 ++++++++ hscontrol/api/v1/errors.go | 49 +++ hscontrol/api/v1/health.go | 41 ++ hscontrol/api/v1/nodes.go | 703 ++++++++++++++++++++++++++++++++ hscontrol/api/v1/policy.go | 191 +++++++++ hscontrol/api/v1/preauthkeys.go | 318 +++++++++++++++ hscontrol/api/v1/tags.go | 29 ++ hscontrol/api/v1/types.go | 51 +++ hscontrol/api/v1/users.go | 233 +++++++++++ hscontrol/app.go | 290 +++---------- swagger.go | 97 ----- 19 files changed, 2333 insertions(+), 381 deletions(-) create mode 100644 cmd/gen-openapi/main.go create mode 100644 hscontrol/api/v1/api.go create mode 100644 hscontrol/api/v1/apikeys.go create mode 100644 hscontrol/api/v1/auth.go create mode 100644 hscontrol/api/v1/errors.go create mode 100644 hscontrol/api/v1/health.go create mode 100644 hscontrol/api/v1/nodes.go create mode 100644 hscontrol/api/v1/policy.go create mode 100644 hscontrol/api/v1/preauthkeys.go create mode 100644 hscontrol/api/v1/tags.go create mode 100644 hscontrol/api/v1/types.go create mode 100644 hscontrol/api/v1/users.go delete mode 100644 swagger.go diff --git a/.gitignore b/.gitignore index 8dbdbe85..9f72ef80 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/Makefile b/Makefile index 6a2f8458..8bfd2037 100644 --- a/Makefile +++ b/Makefile @@ -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" diff --git a/cmd/gen-openapi/main.go b/cmd/gen-openapi/main.go new file mode 100644 index 00000000..3e77a2f1 --- /dev/null +++ b/cmd/gen-openapi/main.go @@ -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 # 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) +} diff --git a/flakehashes.json b/flakehashes.json index 37888e99..65bbdacd 100644 --- a/flakehashes.json +++ b/flakehashes.json @@ -1,6 +1,6 @@ { "vendor": { - "goModSum": "sha256-csVm5v6HZ49PBp/FCX+yK1sjV8/nuUQz3GKN21Ne1mg=", - "sri": "sha256-fzKyXNMw/2yAEhaTZu0n1NXatPO2IP0HFA2ey1vZIYM=" + "goModSum": "sha256-tboE8Nc56tKKOki0JVvR64HcTZJE3ESgLnfdOYQq53Q=", + "sri": "sha256-82WhhyBCnwp0ysrI+n2vHf22cgD29Pjus3GtIOpJonU=" } } diff --git a/go.mod b/go.mod index bf7794df..c6eaad91 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 2de56cbe..da29c9a4 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/hscontrol/api/v1/api.go b/hscontrol/api/v1/api.go new file mode 100644 index 00000000..1ca86694 --- /dev/null +++ b/hscontrol/api/v1/api.go @@ -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) + } +} diff --git a/hscontrol/api/v1/apikeys.go b/hscontrol/api/v1/apikeys.go new file mode 100644 index 00000000..2411d59d --- /dev/null +++ b/hscontrol/api/v1/apikeys.go @@ -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 + "***" +} diff --git a/hscontrol/api/v1/auth.go b/hscontrol/api/v1/auth.go new file mode 100644 index 00000000..e6ff980f --- /dev/null +++ b/hscontrol/api/v1/auth.go @@ -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 +} diff --git a/hscontrol/api/v1/errors.go b/hscontrol/api/v1/errors.go new file mode 100644 index 00000000..478a0e45 --- /dev/null +++ b/hscontrol/api/v1/errors.go @@ -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) + } +} diff --git a/hscontrol/api/v1/health.go b/hscontrol/api/v1/health.go new file mode 100644 index 00000000..7da72e70 --- /dev/null +++ b/hscontrol/api/v1/health.go @@ -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 + }) +} diff --git a/hscontrol/api/v1/nodes.go b/hscontrol/api/v1/nodes.go new file mode 100644 index 00000000..c105388f --- /dev/null +++ b/hscontrol/api/v1/nodes.go @@ -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 +} diff --git a/hscontrol/api/v1/policy.go b/hscontrol/api/v1/policy.go new file mode 100644 index 00000000..45214811 --- /dev/null +++ b/hscontrol/api/v1/policy.go @@ -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 + }) +} diff --git a/hscontrol/api/v1/preauthkeys.go b/hscontrol/api/v1/preauthkeys.go new file mode 100644 index 00000000..6658262e --- /dev/null +++ b/hscontrol/api/v1/preauthkeys.go @@ -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 +} diff --git a/hscontrol/api/v1/tags.go b/hscontrol/api/v1/tags.go new file mode 100644 index 00000000..bd3b4b94 --- /dev/null +++ b/hscontrol/api/v1/tags.go @@ -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 + } +} diff --git a/hscontrol/api/v1/types.go b/hscontrol/api/v1/types.go new file mode 100644 index 00000000..91943b77 --- /dev/null +++ b/hscontrol/api/v1/types.go @@ -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, + } +} diff --git a/hscontrol/api/v1/users.go b/hscontrol/api/v1/users.go new file mode 100644 index 00000000..db8a0c5a --- /dev/null +++ b/hscontrol/api/v1/users.go @@ -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 +} diff --git a/hscontrol/app.go b/hscontrol/app.go index 7ce63261..46a26d66 100644 --- a/hscontrol/app.go +++ b/hscontrol/app.go @@ -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. diff --git a/swagger.go b/swagger.go deleted file mode 100644 index 514bbdf7..00000000 --- a/swagger.go +++ /dev/null @@ -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(` - - - - - - - - -
- - -`)) - - 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") - } -}