mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-17 16:36:02 +00:00
api/v1: add code-first Huma implementation of the v1 API
Reimplement the v1 API as a code-first Huma service in hscontrol/api/v1, a thin adapter over the state layer. Huma emits the OpenAPI 3.1 document (openapi/v1/headscale.yaml) from the Go operation and type definitions; cmd/gen-openapi writes it and the 3.0.3 downgrade the client is generated from. Responses reproduce the protojson wire contract (string-encoded 64-bit IDs, all fields emitted, RFC3339/null timestamps); errors map to the correct HTTP status (404/400/409, 500 for server faults) via mapError. Serve it on the chi router at /api/v1 behind the existing API-key middleware, and point /swagger at the emitted spec.
This commit is contained in:
parent
97eff90ebe
commit
c9bbb5bdec
19 changed files with 2333 additions and 381 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -46,6 +46,9 @@ result
|
||||||
|
|
||||||
integration_test/etc/config.dump.yaml
|
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
|
# MkDocs
|
||||||
.cache
|
.cache
|
||||||
/site
|
/site
|
||||||
|
|
|
||||||
48
Makefile
48
Makefile
|
|
@ -20,7 +20,6 @@ endef
|
||||||
|
|
||||||
# Source file collections using shell find for better performance
|
# Source file collections using shell find for better performance
|
||||||
GO_SOURCES := $(shell find . -name '*.go' -not -path './gen/*' -not -path './vendor/*')
|
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/*')
|
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
|
# Default target
|
||||||
|
|
@ -35,8 +34,6 @@ check-deps:
|
||||||
$(call check_tool,gofumpt)
|
$(call check_tool,gofumpt)
|
||||||
$(call check_tool,mdformat)
|
$(call check_tool,mdformat)
|
||||||
$(call check_tool,prettier)
|
$(call check_tool,prettier)
|
||||||
$(call check_tool,clang-format)
|
|
||||||
$(call check_tool,buf)
|
|
||||||
|
|
||||||
# Build targets
|
# Build targets
|
||||||
.PHONY: build
|
.PHONY: build
|
||||||
|
|
@ -53,7 +50,7 @@ test: check-deps $(GO_SOURCES) go.mod go.sum
|
||||||
|
|
||||||
# Formatting targets
|
# Formatting targets
|
||||||
.PHONY: fmt
|
.PHONY: fmt
|
||||||
fmt: fmt-go fmt-mdformat fmt-prettier fmt-proto
|
fmt: fmt-go fmt-mdformat fmt-prettier
|
||||||
|
|
||||||
.PHONY: fmt-go
|
.PHONY: fmt-go
|
||||||
fmt-go: check-deps $(GO_SOURCES)
|
fmt-go: check-deps $(GO_SOURCES)
|
||||||
|
|
@ -71,35 +68,46 @@ fmt-prettier: check-deps $(PRETTIER_SOURCES)
|
||||||
@echo "Formatting markup and config files..."
|
@echo "Formatting markup and config files..."
|
||||||
prettier --write '**/*.{ts,js,md,yaml,yml,sass,css,scss,html}'
|
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
|
# Linting targets
|
||||||
.PHONY: lint
|
.PHONY: lint
|
||||||
lint: lint-go lint-proto
|
lint: lint-go
|
||||||
|
|
||||||
.PHONY: lint-go
|
.PHONY: lint-go
|
||||||
lint-go: check-deps $(GO_SOURCES) go.mod go.sum
|
lint-go: check-deps $(GO_SOURCES) go.mod go.sum
|
||||||
@echo "Linting Go code..."
|
@echo "Linting Go code..."
|
||||||
golangci-lint run --timeout 10m
|
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
|
# Code generation
|
||||||
.PHONY: generate
|
.PHONY: generate
|
||||||
generate: check-deps
|
generate: check-deps
|
||||||
@echo "Generating code..."
|
@echo "Generating code..."
|
||||||
go generate ./...
|
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
|
# Clean targets
|
||||||
.PHONY: clean
|
.PHONY: clean
|
||||||
clean:
|
clean:
|
||||||
rm -rf headscale gen
|
rm -rf headscale gen/client
|
||||||
|
|
||||||
# Development workflow
|
# Development workflow
|
||||||
.PHONY: dev
|
.PHONY: dev
|
||||||
|
|
@ -119,9 +127,9 @@ help:
|
||||||
@echo " all - Run lint, test, and build (default)"
|
@echo " all - Run lint, test, and build (default)"
|
||||||
@echo " build - Build headscale binary"
|
@echo " build - Build headscale binary"
|
||||||
@echo " test - Run Go tests"
|
@echo " test - Run Go tests"
|
||||||
@echo " fmt - Format all code (Go, docs, proto)"
|
@echo " fmt - Format all code (Go, docs, markup)"
|
||||||
@echo " lint - Lint all code (Go, proto)"
|
@echo " lint - Lint all code (Go)"
|
||||||
@echo " generate - Generate code from Protocol Buffers"
|
@echo " generate - Generate code (go generate + client)"
|
||||||
@echo " dev - Full development workflow (fmt + lint + test + build)"
|
@echo " dev - Full development workflow (fmt + lint + test + build)"
|
||||||
@echo " clean - Clean build artifacts"
|
@echo " clean - Clean build artifacts"
|
||||||
@echo ""
|
@echo ""
|
||||||
|
|
@ -129,9 +137,7 @@ help:
|
||||||
@echo " fmt-go - Format Go code only"
|
@echo " fmt-go - Format Go code only"
|
||||||
@echo " fmt-mdformat - Format documentation only"
|
@echo " fmt-mdformat - Format documentation only"
|
||||||
@echo " fmt-prettier - Format markup and config files 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-go - Lint Go code only"
|
||||||
@echo " lint-proto - Lint Protocol Buffer files only"
|
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "Dependencies:"
|
@echo "Dependencies:"
|
||||||
@echo " check-deps - Verify required tools are available"
|
@echo " check-deps - Verify required tools are available"
|
||||||
|
|
|
||||||
50
cmd/gen-openapi/main.go
Normal file
50
cmd/gen-openapi/main.go
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
// Command gen-openapi emits the Headscale v1 OpenAPI document from the
|
||||||
|
// authoritative Huma definitions in hscontrol/api/v1. The server also serves the
|
||||||
|
// spec live at /openapi.yaml; this tool emits it on demand, and with -downgrade
|
||||||
|
// the 3.0.3 form used to generate the client. The output is not committed.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
//
|
||||||
|
// go run ./cmd/gen-openapi # write the 3.1 spec to openapi/v1/headscale.yaml
|
||||||
|
// go run ./cmd/gen-openapi -downgrade <path> # write the 3.0.3 downgrade (for client gen)
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
apiv1 "github.com/juanfont/headscale/hscontrol/api/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// outPath is relative to the repository root.
|
||||||
|
const outPath = "openapi/v1/headscale.yaml"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) == 3 && os.Args[1] == "-downgrade" {
|
||||||
|
writeSpec(os.Args[2], apiv1.Spec30)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeSpec(outPath, apiv1.Spec)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeSpec(path string, gen func() ([]byte, error)) {
|
||||||
|
spec, err := gen()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("generating OpenAPI spec: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.MkdirAll(filepath.Dir(path), 0o755)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("creating output directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.WriteFile(path, spec, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("writing %s: %v", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("wrote %s", path)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"vendor": {
|
"vendor": {
|
||||||
"goModSum": "sha256-csVm5v6HZ49PBp/FCX+yK1sjV8/nuUQz3GKN21Ne1mg=",
|
"goModSum": "sha256-tboE8Nc56tKKOki0JVvR64HcTZJE3ESgLnfdOYQq53Q=",
|
||||||
"sri": "sha256-fzKyXNMw/2yAEhaTZu0n1NXatPO2IP0HFA2ey1vZIYM="
|
"sri": "sha256-82WhhyBCnwp0ysrI+n2vHf22cgD29Pjus3GtIOpJonU="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
15
go.mod
15
go.mod
|
|
@ -10,6 +10,7 @@ require (
|
||||||
github.com/coreos/go-oidc/v3 v3.18.0
|
github.com/coreos/go-oidc/v3 v3.18.0
|
||||||
github.com/creachadair/command v0.2.6
|
github.com/creachadair/command v0.2.6
|
||||||
github.com/creachadair/flax v0.0.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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc
|
||||||
github.com/docker/docker v28.5.2+incompatible
|
github.com/docker/docker v28.5.2+incompatible
|
||||||
github.com/fsnotify/fsnotify v1.10.1
|
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/go-json-experiment/json v0.0.0-20260601182631-00ed12fed2a6
|
||||||
github.com/gofrs/uuid/v5 v5.4.0
|
github.com/gofrs/uuid/v5 v5.4.0
|
||||||
github.com/google/go-cmp v0.7.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/hashicorp/golang-lru/v2 v2.0.7
|
||||||
github.com/jagottsicher/termcolor v1.0.2
|
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/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25
|
||||||
github.com/ory/dockertest/v3 v3.12.0
|
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/pkg/profile v1.7.0
|
||||||
github.com/prometheus/client_golang v1.23.2
|
github.com/prometheus/client_golang v1.23.2
|
||||||
github.com/prometheus/common v0.68.1
|
github.com/prometheus/common v0.68.1
|
||||||
|
|
@ -48,9 +48,6 @@ require (
|
||||||
golang.org/x/net v0.56.0
|
golang.org/x/net v0.56.0
|
||||||
golang.org/x/oauth2 v0.36.0
|
golang.org/x/oauth2 v0.36.0
|
||||||
golang.org/x/sync v0.21.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
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
gorm.io/driver/postgres v1.6.0
|
gorm.io/driver/postgres v1.6.0
|
||||||
gorm.io/gorm v1.31.1
|
gorm.io/gorm v1.31.1
|
||||||
|
|
@ -104,6 +101,7 @@ require (
|
||||||
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect
|
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect
|
||||||
github.com/akutz/memconn v0.1.0 // indirect
|
github.com/akutz/memconn v0.1.0 // indirect
|
||||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e // 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/atotto/clipboard v0.1.4 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2 v1.41.1 // indirect
|
github.com/aws/aws-sdk-go-v2 v1.41.1 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.32.7 // 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/fgprof v0.9.5 // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
github.com/fogleman/gg v1.3.0 // 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/gaissmai/bart v0.26.1 // indirect
|
||||||
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
||||||
github.com/go-jose/go-jose/v3 v3.0.5 // 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-jwt/jwt/v5 v5.3.1 // indirect
|
||||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // 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/btree v1.1.3 // indirect
|
||||||
github.com/google/go-github v17.0.0+incompatible // indirect
|
github.com/google/go-github v17.0.0+incompatible // indirect
|
||||||
github.com/google/go-querystring v1.2.0 // 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/google/uuid v1.6.0 // indirect
|
||||||
github.com/gookit/color v1.6.1 // indirect
|
github.com/gookit/color v1.6.1 // indirect
|
||||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // 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/hashicorp/go-version v1.9.0 // indirect
|
||||||
github.com/hdevalence/ed25519consensus v0.2.0 // indirect
|
github.com/hdevalence/ed25519consensus v0.2.0 // indirect
|
||||||
github.com/huin/goupnp v1.3.0 // indirect
|
github.com/huin/goupnp v1.3.0 // indirect
|
||||||
|
|
@ -246,7 +244,10 @@ require (
|
||||||
golang.org/x/tools v0.45.0 // indirect
|
golang.org/x/tools v0.45.0 // indirect
|
||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||||
golang.zx2c4.com/wireguard/windows v0.5.3 // 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/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
|
k8s.io/client-go v0.34.0 // indirect
|
||||||
sigs.k8s.io/yaml v1.6.0 // indirect
|
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||||
software.sslmate.com/src/go-pkcs12 v0.4.0 // indirect
|
software.sslmate.com/src/go-pkcs12 v0.4.0 // indirect
|
||||||
|
|
|
||||||
24
go.sum
24
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/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 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw=
|
||||||
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk=
|
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 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A=
|
||||||
github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw=
|
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 h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
|
||||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
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 h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
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 h1:O6GjjVxEDxcByAucOSl29HaGYLXsuwA3ujJw8H9E7/U=
|
||||||
github.com/arl/statsviz v0.8.0/go.mod h1:XlrbiT7xYT03xaW9JMMfD8KFUhBOESJwfyNJu83PbB0=
|
github.com/arl/statsviz v0.8.0/go.mod h1:XlrbiT7xYT03xaW9JMMfD8KFUhBOESJwfyNJu83PbB0=
|
||||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
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/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 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
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 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
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=
|
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/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 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
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.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.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
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/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 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
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.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
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 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo=
|
||||||
github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
|
github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
|
||||||
github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I=
|
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/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 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
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 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
|
||||||
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
|
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=
|
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/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 h1:Df9w9TZ3npHTyDn0Ev9e1uzmN2odmXd0QX+J5GTEn90=
|
||||||
github.com/jsimonetti/rtnetlink v1.4.2/go.mod h1:92s6LJdE+1iOrw+F2/RO7LYI2Qd8pPpFNNUYW06gcoM=
|
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 h1:qnwBm1mh4XAnW9W9Ue9tZtTff8pS6+s6iKF6JRIV2Dk=
|
||||||
github.com/kamstrup/intmap v0.5.2/go.mod h1:gWUVWHKzWj8xpJVFf5GC0O26bWmv3GqdnIX/LMT6Aq4=
|
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=
|
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/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 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
|
||||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
|
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 h1:9bCMuD3TcnjeqjPT2gSlha4asp8NvgcFRYExCaikCxk=
|
||||||
github.com/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25/go.mod h1:eDjgYHYDJbPLBLsyZ6qRaugP0mX8vePOhZ5id1fdzJw=
|
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=
|
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-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 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM=
|
||||||
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
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 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
|
||||||
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||||
github.com/pires/go-proxyproto v0.9.2 h1:H1UdHn695zUVVmB0lQ354lOWHOy6TZSpzBl3tgN0s1U=
|
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/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 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
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.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.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.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.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.7.1/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/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 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE=
|
||||||
golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI=
|
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 h1:3iLyITS/sySRwbUKoC7ogfj2Yr1Cjs0pfaRKj5U5HEw=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260610212136-7ab31c22f7ad/go.mod h1:KdNqO+rCIWgFumrNBSEDlDNrkrQnpkax7Tv1WxNY8V4=
|
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=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
||||||
|
|
|
||||||
160
hscontrol/api/v1/api.go
Normal file
160
hscontrol/api/v1/api.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
245
hscontrol/api/v1/apikeys.go
Normal file
245
hscontrol/api/v1/apikeys.go
Normal file
|
|
@ -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 + "***"
|
||||||
|
}
|
||||||
163
hscontrol/api/v1/auth.go
Normal file
163
hscontrol/api/v1/auth.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
49
hscontrol/api/v1/errors.go
Normal file
49
hscontrol/api/v1/errors.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
41
hscontrol/api/v1/health.go
Normal file
41
hscontrol/api/v1/health.go
Normal file
|
|
@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
703
hscontrol/api/v1/nodes.go
Normal file
703
hscontrol/api/v1/nodes.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
191
hscontrol/api/v1/policy.go
Normal file
191
hscontrol/api/v1/policy.go
Normal file
|
|
@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
318
hscontrol/api/v1/preauthkeys.go
Normal file
318
hscontrol/api/v1/preauthkeys.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
29
hscontrol/api/v1/tags.go
Normal file
29
hscontrol/api/v1/tags.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
51
hscontrol/api/v1/types.go
Normal file
51
hscontrol/api/v1/types.go
Normal file
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
233
hscontrol/api/v1/users.go
Normal file
233
hscontrol/api/v1/users.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
290
hscontrol/app.go
290
hscontrol/app.go
|
|
@ -24,9 +24,7 @@ import (
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
"github.com/go-chi/metrics"
|
"github.com/go-chi/metrics"
|
||||||
grpcRuntime "github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
apiv1 "github.com/juanfont/headscale/hscontrol/api/v1"
|
||||||
"github.com/juanfont/headscale"
|
|
||||||
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
|
|
||||||
"github.com/juanfont/headscale/hscontrol/capver"
|
"github.com/juanfont/headscale/hscontrol/capver"
|
||||||
"github.com/juanfont/headscale/hscontrol/db"
|
"github.com/juanfont/headscale/hscontrol/db"
|
||||||
"github.com/juanfont/headscale/hscontrol/derp"
|
"github.com/juanfont/headscale/hscontrol/derp"
|
||||||
|
|
@ -37,22 +35,12 @@ import (
|
||||||
"github.com/juanfont/headscale/hscontrol/types"
|
"github.com/juanfont/headscale/hscontrol/types"
|
||||||
"github.com/juanfont/headscale/hscontrol/types/change"
|
"github.com/juanfont/headscale/hscontrol/types/change"
|
||||||
"github.com/juanfont/headscale/hscontrol/util"
|
"github.com/juanfont/headscale/hscontrol/util"
|
||||||
zerolog "github.com/philip-bui/grpc-zerolog"
|
|
||||||
"github.com/pkg/profile"
|
"github.com/pkg/profile"
|
||||||
zl "github.com/rs/zerolog"
|
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
"github.com/sasha-s/go-deadlock"
|
"github.com/sasha-s/go-deadlock"
|
||||||
"golang.org/x/crypto/acme"
|
"golang.org/x/crypto/acme"
|
||||||
"golang.org/x/crypto/acme/autocert"
|
"golang.org/x/crypto/acme/autocert"
|
||||||
"golang.org/x/sync/errgroup"
|
"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/envknob"
|
||||||
"tailscale.com/tailcfg"
|
"tailscale.com/tailcfg"
|
||||||
"tailscale.com/types/dnstype"
|
"tailscale.com/types/dnstype"
|
||||||
|
|
@ -84,7 +72,6 @@ func init() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
AuthPrefix = "Bearer "
|
|
||||||
updateInterval = 5 * time.Second
|
updateInterval = 5 * time.Second
|
||||||
privateKeyFileMode = 0o600
|
privateKeyFileMode = 0o600
|
||||||
headscaleDirPerm = 0o700
|
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
|
// ensureUnixSocketIsAbsent will check if the given path for headscales unix socket is clear
|
||||||
// and will remove it if it is not.
|
// and will remove it if it is not.
|
||||||
func (h *Headscale) ensureUnixSocketIsAbsent() error {
|
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 := chi.NewRouter()
|
||||||
r.Use(metrics.Collector(metrics.CollectorOpts{
|
r.Use(metrics.Collector(metrics.CollectorOpts{
|
||||||
Host: false,
|
Host: false,
|
||||||
|
|
@ -572,10 +445,6 @@ func (h *Headscale) createRouter(grpcMux *grpcRuntime.ServeMux) *chi.Mux {
|
||||||
r.Get("/apple/{platform}", h.ApplePlatformConfig)
|
r.Get("/apple/{platform}", h.ApplePlatformConfig)
|
||||||
r.Get("/windows", h.WindowsConfigMessage)
|
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)
|
r.Post("/verify", h.VerifyHandler)
|
||||||
|
|
||||||
if h.cfg.DERP.ServerEnabled {
|
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()))
|
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.Route("/api", func(r chi.Router) {
|
||||||
r.Use(h.httpAuthenticationMiddleware)
|
r.Handle("/v1/*", serveHumaMux(apiMux))
|
||||||
r.HandleFunc("/v1/*", grpcMux.ServeHTTP)
|
|
||||||
})
|
})
|
||||||
// Ping response endpoint: receives HEAD from clients responding
|
// Ping response endpoint: receives HEAD from clients responding
|
||||||
// to a [tailcfg.PingRequest]. The unguessable ping ID serves as authentication.
|
// 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
|
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
|
//nolint:gocyclo // complex server startup function
|
||||||
func (h *Headscale) Serve() error {
|
func (h *Headscale) Serve() error {
|
||||||
|
|
@ -690,12 +561,6 @@ func (h *Headscale) Serve() error {
|
||||||
|
|
||||||
go h.scheduledTasks(scheduleCtx)
|
go h.scheduledTasks(scheduleCtx)
|
||||||
|
|
||||||
if zl.GlobalLevel() == zl.TraceLevel {
|
|
||||||
zerolog.RespLog = true
|
|
||||||
} else {
|
|
||||||
zerolog.RespLog = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare group for running listeners
|
// Prepare group for running listeners
|
||||||
errorGroup := new(errgroup.Group)
|
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)
|
socketListener, err := new(net.ListenConfig).Listen(context.Background(), "unix", h.cfg.UnixSocket)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("setting up gRPC socket: %w", err)
|
return fmt.Errorf("setting up socket: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Change socket permissions
|
// Change socket permissions
|
||||||
if err := os.Chmod(h.cfg.UnixSocket, h.cfg.UnixSocketPermission); err != nil { //nolint:noinlineerr
|
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
|
// Serve the Huma API over the unix socket without TLS or auth: socket access
|
||||||
grpcGatewayConn, err := grpc.Dial( //nolint:staticcheck // SA1019: deprecated but supported in 1.x
|
// implies trust. WithLocalTrust marks these requests so the security
|
||||||
h.cfg.UnixSocket,
|
// middleware skips the API-key check.
|
||||||
[]grpc.DialOption{
|
socketServer := &http.Server{
|
||||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
Handler: apiv1.WithLocalTrust(humaMux),
|
||||||
grpc.WithContextDialer(util.GrpcSocketDialer),
|
ReadTimeout: types.HTTPTimeout,
|
||||||
}...,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("setting up gRPC gateway via socket: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connect to the gRPC server over localhost to skip
|
errorGroup.Go(func() error { return socketServer.Serve(socketListener) })
|
||||||
// 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) })
|
|
||||||
|
|
||||||
//
|
//
|
||||||
//
|
//
|
||||||
|
|
@ -773,65 +625,13 @@ func (h *Headscale) Serve() error {
|
||||||
return fmt.Errorf("configuring TLS settings: %w", err)
|
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
|
// HTTP setup
|
||||||
//
|
//
|
||||||
// This is the regular router that we expose
|
// This is the regular router that we expose
|
||||||
// over our main Addr
|
// over our main Addr
|
||||||
router := h.createRouter(grpcGatewayMux)
|
router := h.createRouter(humaMux)
|
||||||
|
|
||||||
httpServer := &http.Server{
|
httpServer := &http.Server{
|
||||||
Addr: h.cfg.Addr,
|
Addr: h.cfg.Addr,
|
||||||
|
|
@ -972,13 +772,10 @@ func (h *Headscale) Serve() error {
|
||||||
info("waiting for netmap stream to close")
|
info("waiting for netmap stream to close")
|
||||||
h.clientStreamsOpen.Wait()
|
h.clientStreamsOpen.Wait()
|
||||||
|
|
||||||
info("shutting down grpc server (socket)")
|
info("shutting down api server (socket)")
|
||||||
grpcSocket.GracefulStop()
|
|
||||||
|
|
||||||
if grpcServer != nil {
|
if err := socketServer.Shutdown(shutdownCtx); err != nil { //nolint:noinlineerr
|
||||||
info("shutting down grpc server (external)")
|
log.Error().Err(err).Msg("failed to shutdown socket server")
|
||||||
grpcServer.GracefulStop()
|
|
||||||
grpcListener.Close()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if tailsqlContext != nil {
|
if tailsqlContext != nil {
|
||||||
|
|
@ -994,7 +791,6 @@ func (h *Headscale) Serve() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
httpListener.Close()
|
httpListener.Close()
|
||||||
grpcGatewayConn.Close()
|
|
||||||
|
|
||||||
// Stop listening (and unlink the socket if unix type):
|
// Stop listening (and unlink the socket if unix type):
|
||||||
info("closing socket listener")
|
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
|
// The handler serves the Tailscale control protocol including the /key
|
||||||
// endpoint and /ts2021 Noise upgrade path.
|
// endpoint and /ts2021 Noise upgrade path.
|
||||||
func (h *Headscale) HTTPHandler() http.Handler {
|
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.
|
// NoisePublicKey returns the server's Noise protocol public key.
|
||||||
|
|
|
||||||
97
swagger.go
97
swagger.go
|
|
@ -1,97 +0,0 @@
|
||||||
package headscale
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
_ "embed"
|
|
||||||
"html/template"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed gen/openapiv2/headscale/v1/headscale.swagger.json
|
|
||||||
var apiV1JSON []byte
|
|
||||||
|
|
||||||
func SwaggerUI(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
req *http.Request,
|
|
||||||
) {
|
|
||||||
swaggerTemplate := template.Must(template.New("swagger").Parse(`
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@3/swagger-ui.css">
|
|
||||||
<link rel="icon" href="/favicon.ico">
|
|
||||||
<script src="https://unpkg.com/swagger-ui-dist@3/swagger-ui-standalone-preset.js"></script>
|
|
||||||
<script src="https://unpkg.com/swagger-ui-dist@3/swagger-ui-bundle.js" charset="UTF-8"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="swagger-ui"></div>
|
|
||||||
<script>
|
|
||||||
window.addEventListener('load', (event) => {
|
|
||||||
const ui = SwaggerUIBundle({
|
|
||||||
url: "/swagger/v1/openapiv2.json",
|
|
||||||
dom_id: '#swagger-ui',
|
|
||||||
presets: [
|
|
||||||
SwaggerUIBundle.presets.apis,
|
|
||||||
SwaggerUIBundle.SwaggerUIStandalonePreset
|
|
||||||
],
|
|
||||||
plugins: [
|
|
||||||
SwaggerUIBundle.plugins.DownloadUrl
|
|
||||||
],
|
|
||||||
deepLinking: true,
|
|
||||||
// TODO(kradalby): Figure out why this does not work
|
|
||||||
// layout: "StandaloneLayout",
|
|
||||||
})
|
|
||||||
window.ui = ui
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>`))
|
|
||||||
|
|
||||||
var payload bytes.Buffer
|
|
||||||
if err := swaggerTemplate.Execute(&payload, struct{}{}); err != nil { //nolint:noinlineerr
|
|
||||||
log.Error().
|
|
||||||
Caller().
|
|
||||||
Err(err).
|
|
||||||
Msg("Could not render Swagger")
|
|
||||||
|
|
||||||
writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
||||||
writer.WriteHeader(http.StatusInternalServerError)
|
|
||||||
|
|
||||||
_, err := writer.Write([]byte("Could not render Swagger"))
|
|
||||||
if err != nil {
|
|
||||||
log.Error().
|
|
||||||
Caller().
|
|
||||||
Err(err).
|
|
||||||
Msg("Failed to write response")
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
writer.WriteHeader(http.StatusOK)
|
|
||||||
|
|
||||||
_, err := writer.Write(payload.Bytes())
|
|
||||||
if err != nil {
|
|
||||||
log.Error().
|
|
||||||
Caller().
|
|
||||||
Err(err).
|
|
||||||
Msg("Failed to write response")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func SwaggerAPIv1(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
req *http.Request,
|
|
||||||
) {
|
|
||||||
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
||||||
writer.WriteHeader(http.StatusOK)
|
|
||||||
|
|
||||||
if _, err := writer.Write(apiV1JSON); err != nil { //nolint:noinlineerr
|
|
||||||
log.Error().
|
|
||||||
Caller().
|
|
||||||
Err(err).
|
|
||||||
Msg("Failed to write response")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue