mirror of
https://github.com/juanfont/headscale.git
synced 2026-09-19 23:04:53 +09:00
api/v1: add code-first Huma implementation of the v1 API
Reimplement the v1 API as a code-first Huma service in hscontrol/api/v1, a thin adapter over the state layer. Huma emits the OpenAPI 3.1 document (openapi/v1/headscale.yaml) from the Go operation and type definitions; cmd/gen-openapi writes it and the 3.0.3 downgrade the client is generated from. Responses reproduce the protojson wire contract (string-encoded 64-bit IDs, all fields emitted, RFC3339/null timestamps); errors map to the correct HTTP status (404/400/409, 500 for server faults) via mapError. Serve it on the chi router at /api/v1 behind the existing API-key middleware, and point /swagger at the emitted spec.
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
// Package apiv1 is the code-first Huma implementation of the Headscale v1 API.
|
||||
// Handlers are a thin adapter over hscontrol/state; Huma emits the OpenAPI 3.1
|
||||
// spec from the Go definitions (see Spec), and that spec drives the client.
|
||||
//
|
||||
// It depends only on the domain layer (hscontrol/state, hscontrol/types) via
|
||||
// Backend, never on the hscontrol server package, so a future hscontrol/api/v2
|
||||
// can sit beside it without either importing the other.
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/danielgtaylor/huma/v2/adapters/humachi"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/juanfont/headscale/hscontrol/state"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/types/change"
|
||||
)
|
||||
|
||||
// Backend is the dependency surface the v1 API needs from the control plane:
|
||||
// the state layer, the change-notification sink that distributes updates to
|
||||
// connected nodes, and the config (only Policy.Mode and Policy.Path are read).
|
||||
type Backend struct {
|
||||
State *state.State
|
||||
Change func(...change.Change)
|
||||
Cfg *types.Config
|
||||
}
|
||||
|
||||
// NewAPI builds the v1 Huma API on the given chi router and registers every
|
||||
// operation. Auth is enforced by a Huma middleware driven by each operation's
|
||||
// declared bearer security (see authMiddleware); locally-trusted requests
|
||||
// bypass it via WithLocalTrust.
|
||||
func NewAPI(router chi.Router, backend Backend) huma.API {
|
||||
config := huma.DefaultConfig("Headscale API", "v1")
|
||||
config.Info.Description = "Headscale control server API."
|
||||
|
||||
// Version the OpenAPI/docs routes under /api/v1 so a future v2 owns its own.
|
||||
// These register as plain mux routes, not operations, so they never appear
|
||||
// in the emitted spec or client.
|
||||
config.OpenAPIPath = "/api/v1/openapi"
|
||||
config.DocsPath = "/api/v1/docs"
|
||||
|
||||
// The v1 API does not emit "$schema".
|
||||
config.SchemasPath = ""
|
||||
|
||||
// Drop the default schema-link create hook: it injects a "$schema" property
|
||||
// and Link header into every response, which the v1 contract omits.
|
||||
config.CreateHooks = nil
|
||||
|
||||
config.Components.SecuritySchemes = map[string]*huma.SecurityScheme{
|
||||
"bearer": {
|
||||
Type: "http",
|
||||
Scheme: "bearer",
|
||||
},
|
||||
}
|
||||
|
||||
api := humachi.New(router, config)
|
||||
|
||||
// Must run before register: Huma snapshots the middleware chain at operation
|
||||
// registration, so a middleware added afterwards would silently never run.
|
||||
api.UseMiddleware(authMiddleware(api, backend))
|
||||
|
||||
register(api, backend)
|
||||
|
||||
return api
|
||||
}
|
||||
|
||||
// bearerAuth is the security requirement applied to every operation: all
|
||||
// /api/v1 routes require an API key.
|
||||
var bearerAuth = []map[string][]string{{"bearer": {}}}
|
||||
|
||||
// registrations is populated by each resource file's init(), so adding a
|
||||
// resource group means adding a file rather than editing a shared point. Huma
|
||||
// sorts the emitted spec, so init order does not affect output.
|
||||
var registrations []func(huma.API, Backend)
|
||||
|
||||
// register wires up every operation contributed by the resource files.
|
||||
func register(api huma.API, b Backend) {
|
||||
for _, fn := range registrations {
|
||||
fn(api, b)
|
||||
}
|
||||
}
|
||||
|
||||
// Spec emits the OpenAPI 3.1 document. The zero Backend is safe because
|
||||
// handlers are registered but never invoked during emission.
|
||||
func Spec() ([]byte, error) {
|
||||
api := NewAPI(chi.NewMux(), Backend{})
|
||||
return api.OpenAPI().YAML()
|
||||
}
|
||||
|
||||
// Spec30 emits the document downgraded to OpenAPI 3.0.3, needed because the
|
||||
// client generator (oapi-codegen v2) cannot yet read the 3.1 spec.
|
||||
func Spec30() ([]byte, error) {
|
||||
api := NewAPI(chi.NewMux(), Backend{})
|
||||
return api.OpenAPI().DowngradeYAML()
|
||||
}
|
||||
|
||||
// Handler builds the v1 API on a fresh mux and returns both. Callers mount the
|
||||
// mux and may use mux.Match to detect which paths this API serves.
|
||||
func Handler(backend Backend) (*chi.Mux, huma.API) {
|
||||
mux := chi.NewMux()
|
||||
api := NewAPI(mux, backend)
|
||||
|
||||
return mux, api
|
||||
}
|
||||
|
||||
// localTrustKey marks a request as arriving over a locally-trusted transport;
|
||||
// the auth middleware skips authentication for such requests.
|
||||
type localTrustKey struct{}
|
||||
|
||||
// WithLocalTrust wraps a handler so its requests bypass API-key authentication.
|
||||
// The unix socket uses this — access to the socket is the trust boundary — as
|
||||
// do in-process tests that exercise the mux directly.
|
||||
func WithLocalTrust(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
next.ServeHTTP(w, req.WithContext(
|
||||
context.WithValue(req.Context(), localTrustKey{}, struct{}{}),
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
// authMiddleware is a pure gate enforcing the bearer API key for any operation
|
||||
// that declares security; the v1 handlers do not read caller identity.
|
||||
// Locally-trusted requests and operations without declared security pass
|
||||
// through. b.State is nil only during spec emission, where no request is
|
||||
// served, so it is never dereferenced there.
|
||||
func authMiddleware(api huma.API, b Backend) func(huma.Context, func(huma.Context)) {
|
||||
return func(ctx huma.Context, next func(huma.Context)) {
|
||||
if ctx.Context().Value(localTrustKey{}) != nil {
|
||||
next(ctx)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if len(ctx.Operation().Security) == 0 {
|
||||
next(ctx)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
token, ok := strings.CutPrefix(ctx.Header("Authorization"), "Bearer ")
|
||||
if !ok {
|
||||
_ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "Unauthorized")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
valid, err := b.State.ValidateAPIKey(token)
|
||||
if err != nil || !valid {
|
||||
_ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "Unauthorized")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
next(ctx)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registrations = append(registrations, registerApiKeys)
|
||||
}
|
||||
|
||||
// ApiKey is the v1 ApiKey message. Timestamps are pointers so a nil source is
|
||||
// emitted as JSON null, matching protojson's unset Timestamp (e.g. lastSeen on
|
||||
// a fresh key).
|
||||
type ApiKey struct {
|
||||
ID string `format:"uint64" json:"id"`
|
||||
Prefix string `json:"prefix"`
|
||||
Expiration *time.Time `json:"expiration" nullable:"true"`
|
||||
CreatedAt *time.Time `json:"createdAt" nullable:"true"`
|
||||
LastSeen *time.Time `json:"lastSeen" nullable:"true"`
|
||||
}
|
||||
|
||||
// CreateApiKeyRequestBody is the v1.CreateApiKeyRequest body.
|
||||
type CreateApiKeyRequestBody struct {
|
||||
Expiration *time.Time `json:"expiration,omitempty"`
|
||||
}
|
||||
|
||||
// ExpireApiKeyRequestBody is the v1.ExpireApiKeyRequest body.
|
||||
type ExpireApiKeyRequestBody struct {
|
||||
Prefix string `json:"prefix,omitempty"`
|
||||
ID string `format:"uint64" json:"id,omitempty"`
|
||||
}
|
||||
|
||||
type (
|
||||
createApiKeyInput struct {
|
||||
Body CreateApiKeyRequestBody
|
||||
}
|
||||
createApiKeyOutput struct {
|
||||
Body struct {
|
||||
APIKey string `json:"apiKey"`
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
type (
|
||||
expireApiKeyInput struct {
|
||||
Body ExpireApiKeyRequestBody
|
||||
}
|
||||
expireApiKeyOutput struct {
|
||||
Body struct{}
|
||||
}
|
||||
)
|
||||
|
||||
type (
|
||||
listApiKeysOutput struct {
|
||||
Body struct {
|
||||
APIKeys []ApiKey `json:"apiKeys" nullable:"false"`
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
type (
|
||||
deleteApiKeyInput struct {
|
||||
Prefix string `path:"prefix"`
|
||||
ID string `format:"uint64" query:"id"`
|
||||
}
|
||||
deleteApiKeyOutput struct {
|
||||
Body struct{}
|
||||
}
|
||||
)
|
||||
|
||||
func registerApiKeys(api huma.API, b Backend) {
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "createApiKey",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/v1/apikey",
|
||||
Summary: "Create API key",
|
||||
Tags: []string{"ApiKeys"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, in *createApiKeyInput) (*createApiKeyOutput, error) {
|
||||
// CreateAPIKey requires a non-nil pointer; default a missing expiration
|
||||
// to the zero time as the gRPC handler does.
|
||||
var expiration time.Time
|
||||
if in.Body.Expiration != nil {
|
||||
expiration = *in.Body.Expiration
|
||||
}
|
||||
|
||||
keyStr, _, err := b.State.CreateAPIKey(&expiration)
|
||||
if err != nil {
|
||||
return nil, huma.Error500InternalServerError("creating api key", err)
|
||||
}
|
||||
|
||||
out := &createApiKeyOutput{}
|
||||
out.Body.APIKey = keyStr
|
||||
|
||||
return out, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "expireApiKey",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/v1/apikey/expire",
|
||||
Summary: "Expire API key",
|
||||
Tags: []string{"ApiKeys"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, in *expireApiKeyInput) (*expireApiKeyOutput, error) {
|
||||
key, err := lookupApiKey(b, in.Body.ID, in.Body.Prefix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = b.State.ExpireAPIKey(key)
|
||||
if err != nil {
|
||||
return nil, huma.Error500InternalServerError("expiring api key", err)
|
||||
}
|
||||
|
||||
return &expireApiKeyOutput{}, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "listApiKeys",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/v1/apikey",
|
||||
Summary: "List API keys",
|
||||
Tags: []string{"ApiKeys"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, _ *struct{}) (*listApiKeysOutput, error) {
|
||||
keys, err := b.State.ListAPIKeys()
|
||||
if err != nil {
|
||||
return nil, huma.Error500InternalServerError("listing api keys", err)
|
||||
}
|
||||
|
||||
// Match the gRPC handler's ascending-ID ordering.
|
||||
slices.SortFunc(keys, func(a, b types.APIKey) int {
|
||||
return cmp.Compare(a.ID, b.ID)
|
||||
})
|
||||
|
||||
out := &listApiKeysOutput{}
|
||||
|
||||
out.Body.APIKeys = make([]ApiKey, len(keys))
|
||||
for i := range keys {
|
||||
out.Body.APIKeys[i] = apiKeyFromState(&keys[i])
|
||||
}
|
||||
|
||||
return out, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "deleteApiKey",
|
||||
Method: http.MethodDelete,
|
||||
Path: "/api/v1/apikey/{prefix}",
|
||||
Summary: "Delete API key",
|
||||
Tags: []string{"ApiKeys"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, in *deleteApiKeyInput) (*deleteApiKeyOutput, error) {
|
||||
key, err := lookupApiKey(b, in.ID, in.Prefix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = b.State.DestroyAPIKey(*key)
|
||||
if err != nil {
|
||||
return nil, huma.Error500InternalServerError("deleting api key", err)
|
||||
}
|
||||
|
||||
return &deleteApiKeyOutput{}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// lookupApiKey resolves an API key by id or prefix; exactly one must be
|
||||
// supplied. An empty or zero id counts as "no id". Unknown id/prefix maps to
|
||||
// 404 via mapError.
|
||||
func lookupApiKey(b Backend, idStr, prefix string) (*types.APIKey, error) {
|
||||
id, err := parseApiKeyID(idStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hasID := id != 0
|
||||
hasPrefix := prefix != ""
|
||||
|
||||
switch {
|
||||
case hasID && hasPrefix:
|
||||
return nil, huma.Error400BadRequest("provide either id or prefix, not both")
|
||||
case hasID:
|
||||
key, err := b.State.GetAPIKeyByID(id)
|
||||
if err != nil {
|
||||
return nil, mapError("getting api key", err)
|
||||
}
|
||||
|
||||
return key, nil
|
||||
case hasPrefix:
|
||||
key, err := b.State.GetAPIKey(prefix)
|
||||
if err != nil {
|
||||
return nil, mapError("getting api key", err)
|
||||
}
|
||||
|
||||
return key, nil
|
||||
default:
|
||||
return nil, huma.Error400BadRequest("must provide id or prefix")
|
||||
}
|
||||
}
|
||||
|
||||
// parseApiKeyID decodes the optional uint64 id. Empty maps to zero; non-numeric
|
||||
// is rejected with 400.
|
||||
func parseApiKeyID(s string) (uint64, error) {
|
||||
if s == "" {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(s, 10, 64)
|
||||
if err != nil {
|
||||
return 0, huma.Error400BadRequest("invalid api key id", err)
|
||||
}
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// apiKeyFromState converts a domain API key into the v1 response shape, masking
|
||||
// the prefix so the secret is never returned.
|
||||
func apiKeyFromState(k *types.APIKey) ApiKey {
|
||||
return ApiKey{
|
||||
ID: formatID(k.ID),
|
||||
Prefix: apiKeyMaskedPrefix(k.Prefix),
|
||||
Expiration: k.Expiration,
|
||||
CreatedAt: k.CreatedAt,
|
||||
LastSeen: k.LastSeen,
|
||||
}
|
||||
}
|
||||
|
||||
// apiKeyMaskedPrefix reproduces the unexported types.APIKey.maskedPrefix.
|
||||
func apiKeyMaskedPrefix(prefix string) string {
|
||||
if len(prefix) == types.NewAPIKeyPrefixLength {
|
||||
return "hskey-api-" + prefix + "-***"
|
||||
}
|
||||
|
||||
return prefix + "***"
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/util"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registrations = append(registrations, registerAuth)
|
||||
}
|
||||
|
||||
// errAuthRejected is the verdict handed to the waiting registration flow when
|
||||
// an auth session is rejected.
|
||||
var errAuthRejected = errors.New("auth request rejected")
|
||||
|
||||
// AuthRegisterRequestBody is the v1.AuthRegisterRequest body.
|
||||
type AuthRegisterRequestBody struct {
|
||||
User string `json:"user,omitempty"`
|
||||
AuthID string `json:"authId,omitempty"`
|
||||
}
|
||||
|
||||
// AuthApproveRequestBody is the v1.AuthApproveRequest body.
|
||||
type AuthApproveRequestBody struct {
|
||||
AuthID string `json:"authId,omitempty"`
|
||||
}
|
||||
|
||||
// AuthRejectRequestBody is the v1.AuthRejectRequest body.
|
||||
type AuthRejectRequestBody struct {
|
||||
AuthID string `json:"authId,omitempty"`
|
||||
}
|
||||
|
||||
type (
|
||||
authRegisterInput struct {
|
||||
Body AuthRegisterRequestBody
|
||||
}
|
||||
authRegisterOutput struct {
|
||||
Body struct {
|
||||
Node Node `json:"node"`
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
type (
|
||||
authApproveInput struct {
|
||||
Body AuthApproveRequestBody
|
||||
}
|
||||
authApproveOutput struct {
|
||||
Body struct{}
|
||||
}
|
||||
)
|
||||
|
||||
type (
|
||||
authRejectInput struct {
|
||||
Body AuthRejectRequestBody
|
||||
}
|
||||
authRejectOutput struct {
|
||||
Body struct{}
|
||||
}
|
||||
)
|
||||
|
||||
func registerAuth(api huma.API, b Backend) {
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "authRegister",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/v1/auth/register",
|
||||
Summary: "Register node via auth flow",
|
||||
Tags: []string{"Auth"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, in *authRegisterInput) (*authRegisterOutput, error) {
|
||||
// Malformed auth_id is 400; unknown user and missing pending session are
|
||||
// 404 via mapError, matching the Approve/Reject handlers.
|
||||
registrationID, err := types.AuthIDFromString(in.Body.AuthID)
|
||||
if err != nil {
|
||||
return nil, huma.Error400BadRequest("registering node", err)
|
||||
}
|
||||
|
||||
user, err := b.State.GetUserByName(in.Body.User)
|
||||
if err != nil {
|
||||
return nil, mapError("looking up user", err)
|
||||
}
|
||||
|
||||
node, nodeChange, err := b.State.HandleNodeFromAuthPath(
|
||||
registrationID,
|
||||
types.UserID(user.ID),
|
||||
nil,
|
||||
util.RegisterMethodCLI,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, mapError("registering node", err)
|
||||
}
|
||||
|
||||
routeChange, err := b.State.AutoApproveRoutes(node)
|
||||
if err != nil {
|
||||
return nil, huma.Error500InternalServerError("auto approving routes", err)
|
||||
}
|
||||
|
||||
b.Change(nodeChange, routeChange)
|
||||
|
||||
out := &authRegisterOutput{}
|
||||
out.Body.Node = nodeFromView(node)
|
||||
|
||||
return out, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "authApprove",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/v1/auth/approve",
|
||||
Summary: "Approve a pending auth session",
|
||||
Tags: []string{"Auth"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, in *authApproveInput) (*authApproveOutput, error) {
|
||||
authReq, err := pendingAuthRequest(b, in.Body.AuthID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
authReq.FinishAuth(types.AuthVerdict{})
|
||||
|
||||
return &authApproveOutput{}, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "authReject",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/v1/auth/reject",
|
||||
Summary: "Reject a pending auth session",
|
||||
Tags: []string{"Auth"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, in *authRejectInput) (*authRejectOutput, error) {
|
||||
authReq, err := pendingAuthRequest(b, in.Body.AuthID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
authReq.FinishAuth(types.AuthVerdict{
|
||||
Err: errAuthRejected,
|
||||
})
|
||||
|
||||
return &authRejectOutput{}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// pendingAuthRequest looks up the pending session for auth_id. Malformed
|
||||
// auth_id is 400, unknown is 404.
|
||||
func pendingAuthRequest(b Backend, rawID string) (*types.AuthRequest, error) {
|
||||
authID, err := types.AuthIDFromString(rawID)
|
||||
if err != nil {
|
||||
return nil, huma.Error400BadRequest("invalid auth_id", err)
|
||||
}
|
||||
|
||||
authReq, ok := b.State.GetAuthCacheEntry(authID)
|
||||
if !ok {
|
||||
return nil, huma.Error404NotFound("no pending auth session for auth_id " + authID.String())
|
||||
}
|
||||
|
||||
return authReq, nil
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/juanfont/headscale/hscontrol/db"
|
||||
"github.com/juanfont/headscale/hscontrol/state"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// mapError translates a state/db-layer error into a Huma HTTP error
|
||||
// (NotFound→404, invalid input→400, conflict→409, everything else→500).
|
||||
// Handlers use this default mapping and may return a more specific huma.ErrorN
|
||||
// directly. msg is a human context prefix, e.g. "getting node".
|
||||
func mapError(msg string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch {
|
||||
case errors.Is(err, gorm.ErrRecordNotFound),
|
||||
errors.Is(err, state.ErrNodeNotFound),
|
||||
errors.Is(err, state.ErrNodeNotInNodeStore),
|
||||
errors.Is(err, db.ErrUserNotFound),
|
||||
errors.Is(err, db.ErrNodeNotFoundRegistrationCache),
|
||||
errors.Is(err, state.ErrRegistrationExpired):
|
||||
return huma.Error404NotFound(msg, err)
|
||||
|
||||
case errors.Is(err, state.ErrGivenNameInvalid),
|
||||
errors.Is(err, state.ErrGivenNameTaken),
|
||||
errors.Is(err, state.ErrNodeNameNotUnique),
|
||||
errors.Is(err, state.ErrNodeMarkedTaggedButHasNoTags),
|
||||
errors.Is(err, state.ErrNodeHasNeitherUserNorTags),
|
||||
errors.Is(err, state.ErrRequestedTagsInvalidOrNotPermitted),
|
||||
errors.Is(err, db.ErrUserStillHasNodes),
|
||||
errors.Is(err, db.ErrCannotChangeOIDCUser),
|
||||
errors.Is(err, db.ErrPreAuthKeyNotTaggedOrOwned),
|
||||
errors.Is(err, db.ErrSingleUseAuthKeyHasBeenUsed):
|
||||
return huma.Error400BadRequest(msg, err)
|
||||
|
||||
case errors.Is(err, state.ErrNodeKeyInUse),
|
||||
errors.Is(err, state.ErrAmbiguousNodeOwnership):
|
||||
return huma.Error409Conflict(msg, err)
|
||||
|
||||
default:
|
||||
return huma.Error500InternalServerError(msg, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
)
|
||||
|
||||
// HealthResponseBody mirrors the v1 HealthResponse message. database_connectivity
|
||||
// is reported true only when the database responds to a ping.
|
||||
type HealthResponseBody struct {
|
||||
DatabaseConnectivity bool `json:"databaseConnectivity"`
|
||||
}
|
||||
|
||||
type healthOutput struct {
|
||||
Body HealthResponseBody
|
||||
}
|
||||
|
||||
func init() {
|
||||
registrations = append(registrations, registerHealth)
|
||||
}
|
||||
|
||||
func registerHealth(api huma.API, b Backend) {
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "health",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/v1/health",
|
||||
Summary: "Health check",
|
||||
Description: "Reports server health, including database connectivity.",
|
||||
Tags: []string{"Health"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, _ *struct{}) (*healthOutput, error) {
|
||||
err := b.State.PingDB(ctx)
|
||||
if err != nil {
|
||||
return nil, mapError("pinging database", err)
|
||||
}
|
||||
|
||||
return &healthOutput{Body: HealthResponseBody{DatabaseConnectivity: true}}, nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,703 @@
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/util"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/key"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registrations = append(registrations, registerNodes)
|
||||
}
|
||||
|
||||
// errBackfillNotConfirmed guards BackfillNodeIPs behind explicit confirmed=true.
|
||||
var errBackfillNotConfirmed = errors.New("not confirmed, aborting")
|
||||
|
||||
// registerMethodToV1Enum maps the stored register method onto the
|
||||
// SCREAMING_SNAKE enum string the v1 contract emits.
|
||||
var registerMethodToV1Enum = map[string]string{
|
||||
util.RegisterMethodAuthKey: "REGISTER_METHOD_AUTH_KEY",
|
||||
util.RegisterMethodOIDC: "REGISTER_METHOD_OIDC",
|
||||
util.RegisterMethodCLI: "REGISTER_METHOD_CLI",
|
||||
}
|
||||
|
||||
// Node mirrors the v1 Node message. The protojson contract emits unpopulated
|
||||
// fields: scalars and slices always (no omitempty), nested messages and optional
|
||||
// timestamps as JSON null when unset.
|
||||
type Node struct {
|
||||
ID string `format:"uint64" json:"id"`
|
||||
MachineKey string `json:"machineKey"`
|
||||
NodeKey string `json:"nodeKey"`
|
||||
DiscoKey string `json:"discoKey"`
|
||||
IPAddresses []string `json:"ipAddresses" nullable:"false"`
|
||||
Name string `json:"name"`
|
||||
User *User `json:"user"`
|
||||
LastSeen *time.Time `json:"lastSeen" nullable:"true"`
|
||||
Expiry *time.Time `json:"expiry" nullable:"true"`
|
||||
PreAuthKey *NodePreAuthKey `json:"preAuthKey"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
RegisterMethod string `enum:"REGISTER_METHOD_UNSPECIFIED,REGISTER_METHOD_AUTH_KEY,REGISTER_METHOD_CLI,REGISTER_METHOD_OIDC" json:"registerMethod"`
|
||||
GivenName string `json:"givenName"`
|
||||
Online bool `json:"online"`
|
||||
ApprovedRoutes []string `json:"approvedRoutes" nullable:"false"`
|
||||
AvailableRoutes []string `json:"availableRoutes" nullable:"false"`
|
||||
SubnetRoutes []string `json:"subnetRoutes" nullable:"false"`
|
||||
Tags []string `json:"tags" nullable:"false"`
|
||||
}
|
||||
|
||||
// NodePreAuthKey is the PreAuthKey shape embedded in a Node response. The
|
||||
// /preauthkey endpoints own the standalone request/response surface.
|
||||
type NodePreAuthKey struct {
|
||||
User *User `json:"user"`
|
||||
ID string `format:"uint64" json:"id"`
|
||||
Key string `json:"key"`
|
||||
Reusable bool `json:"reusable"`
|
||||
Ephemeral bool `json:"ephemeral"`
|
||||
Used bool `json:"used"`
|
||||
Expiration *time.Time `json:"expiration" nullable:"true"`
|
||||
CreatedAt *time.Time `json:"createdAt" nullable:"true"`
|
||||
AclTags []string `json:"aclTags" nullable:"false"`
|
||||
}
|
||||
|
||||
// SetTagsRequestBody mirrors v1.SetTagsRequest.
|
||||
type SetTagsRequestBody struct {
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
// SetApprovedRoutesRequestBody mirrors v1.SetApprovedRoutesRequest.
|
||||
type SetApprovedRoutesRequestBody struct {
|
||||
Routes []string `json:"routes,omitempty"`
|
||||
}
|
||||
|
||||
// DebugCreateNodeRequestBody mirrors v1.DebugCreateNodeRequest.
|
||||
type DebugCreateNodeRequestBody struct {
|
||||
User string `json:"user,omitempty"`
|
||||
Key string `json:"key,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Routes []string `json:"routes,omitempty"`
|
||||
}
|
||||
|
||||
type (
|
||||
getNodeInput struct {
|
||||
NodeID string `format:"uint64" path:"nodeId"`
|
||||
}
|
||||
nodeOutput struct {
|
||||
Body struct {
|
||||
Node Node `json:"node"`
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
type (
|
||||
listNodesInput struct {
|
||||
User string `query:"user"`
|
||||
}
|
||||
listNodesOutput struct {
|
||||
Body struct {
|
||||
Nodes []Node `json:"nodes" nullable:"false"`
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
type (
|
||||
deleteNodeInput struct {
|
||||
NodeID string `format:"uint64" path:"nodeId"`
|
||||
}
|
||||
deleteNodeOutput struct {
|
||||
Body struct{}
|
||||
}
|
||||
)
|
||||
|
||||
// ExpireNodeRequestBody mirrors v1.ExpireNodeRequest. Both fields are optional;
|
||||
// an absent or all-zero body expires the node immediately, as gRPC does.
|
||||
type ExpireNodeRequestBody struct {
|
||||
Expiry *time.Time `json:"expiry,omitempty"`
|
||||
DisableExpiry bool `json:"disableExpiry,omitempty"`
|
||||
}
|
||||
|
||||
type expireNodeInput struct {
|
||||
NodeID string `format:"uint64" path:"nodeId"`
|
||||
Body *ExpireNodeRequestBody `required:"false"`
|
||||
}
|
||||
|
||||
type renameNodeInput struct {
|
||||
NodeID string `format:"uint64" path:"nodeId"`
|
||||
NewName string `path:"newName"`
|
||||
}
|
||||
|
||||
type setTagsInput struct {
|
||||
NodeID string `format:"uint64" path:"nodeId"`
|
||||
Body SetTagsRequestBody
|
||||
}
|
||||
|
||||
type setApprovedRoutesInput struct {
|
||||
NodeID string `format:"uint64" path:"nodeId"`
|
||||
Body SetApprovedRoutesRequestBody
|
||||
}
|
||||
|
||||
type registerNodeInput struct {
|
||||
User string `query:"user"`
|
||||
Key string `query:"key"`
|
||||
}
|
||||
|
||||
type backfillNodeIPsInput struct {
|
||||
Confirmed bool `query:"confirmed"`
|
||||
}
|
||||
|
||||
type backfillNodeIPsOutput struct {
|
||||
Body struct {
|
||||
Changes []string `json:"changes" nullable:"false"`
|
||||
}
|
||||
}
|
||||
|
||||
type debugCreateNodeInput struct {
|
||||
Body DebugCreateNodeRequestBody
|
||||
}
|
||||
|
||||
func registerNodes(api huma.API, b Backend) {
|
||||
registerNodeReadOps(api, b)
|
||||
registerNodeWriteOps(api, b)
|
||||
registerNodeAdminOps(api, b)
|
||||
}
|
||||
|
||||
func registerNodeReadOps(api huma.API, b Backend) {
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "getNode",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/v1/node/{nodeId}",
|
||||
Summary: "Get node",
|
||||
Tags: []string{"Nodes"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, in *getNodeInput) (*nodeOutput, error) {
|
||||
nodeID, err := parseNodeID(in.NodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
node, ok := b.State.GetNodeByID(nodeID)
|
||||
if !ok {
|
||||
return nil, huma.Error404NotFound("node not found")
|
||||
}
|
||||
|
||||
out := &nodeOutput{}
|
||||
out.Body.Node = nodeFromView(node)
|
||||
|
||||
return out, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "listNodes",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/v1/node",
|
||||
Summary: "List nodes",
|
||||
Tags: []string{"Nodes"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, in *listNodesInput) (*listNodesOutput, error) {
|
||||
nodes := b.State.ListNodes()
|
||||
if in.User != "" {
|
||||
user, err := b.State.GetUserByName(in.User)
|
||||
if err != nil {
|
||||
return nil, mapError("listing nodes", err)
|
||||
}
|
||||
|
||||
nodes = b.State.ListNodesByUser(types.UserID(user.ID))
|
||||
}
|
||||
|
||||
out := &listNodesOutput{}
|
||||
out.Body.Nodes = make([]Node, nodes.Len())
|
||||
|
||||
for i, node := range nodes.All() {
|
||||
n := nodeFromView(node)
|
||||
|
||||
// Tags-as-identity: tagged nodes are presented as the special
|
||||
// TaggedDevices user.
|
||||
if node.IsTagged() {
|
||||
user := userFromState(&types.TaggedDevices)
|
||||
n.User = &user
|
||||
}
|
||||
|
||||
// SubnetRoutes is the routes actively served, exit routes included.
|
||||
n.SubnetRoutes = util.PrefixesToString(
|
||||
append(b.State.GetNodePrimaryRoutes(node.ID()), node.ExitRoutes()...),
|
||||
)
|
||||
|
||||
out.Body.Nodes[i] = n
|
||||
}
|
||||
|
||||
// Match the gRPC handler's ascending-ID ordering.
|
||||
slices.SortFunc(out.Body.Nodes, func(a, b Node) int {
|
||||
return cmpNodeID(a.ID, b.ID)
|
||||
})
|
||||
|
||||
return out, nil
|
||||
})
|
||||
}
|
||||
|
||||
func registerNodeWriteOps(api huma.API, b Backend) {
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "deleteNode",
|
||||
Method: http.MethodDelete,
|
||||
Path: "/api/v1/node/{nodeId}",
|
||||
Summary: "Delete node",
|
||||
Tags: []string{"Nodes"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, in *deleteNodeInput) (*deleteNodeOutput, error) {
|
||||
nodeID, err := parseNodeID(in.NodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
node, ok := b.State.GetNodeByID(nodeID)
|
||||
if !ok {
|
||||
return nil, huma.Error404NotFound("node not found")
|
||||
}
|
||||
|
||||
nodeChange, err := b.State.DeleteNode(node)
|
||||
if err != nil {
|
||||
return nil, huma.Error500InternalServerError("deleting node", err)
|
||||
}
|
||||
|
||||
b.Change(nodeChange)
|
||||
|
||||
return &deleteNodeOutput{}, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "expireNode",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/v1/node/{nodeId}/expire",
|
||||
Summary: "Expire node",
|
||||
Tags: []string{"Nodes"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, in *expireNodeInput) (*nodeOutput, error) {
|
||||
nodeID, err := parseNodeID(in.NodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// gRPC parity: disableExpiry => nil expiry (never expires); explicit
|
||||
// expiry honoured; absent/zero body expires now. Both set is a 400.
|
||||
var (
|
||||
disableExpiry bool
|
||||
customExpiry *time.Time
|
||||
)
|
||||
|
||||
if in.Body != nil {
|
||||
disableExpiry = in.Body.DisableExpiry
|
||||
customExpiry = in.Body.Expiry
|
||||
}
|
||||
|
||||
if disableExpiry && customExpiry != nil {
|
||||
return nil, huma.Error400BadRequest("cannot set both disable_expiry and expiry")
|
||||
}
|
||||
|
||||
expiry := time.Now()
|
||||
|
||||
switch {
|
||||
case disableExpiry:
|
||||
node, nodeChange, expErr := b.State.SetNodeExpiry(nodeID, nil)
|
||||
if expErr != nil {
|
||||
return nil, mapError("expiring node", expErr)
|
||||
}
|
||||
|
||||
b.Change(nodeChange)
|
||||
|
||||
out := &nodeOutput{}
|
||||
out.Body.Node = nodeFromView(node)
|
||||
|
||||
return out, nil
|
||||
case customExpiry != nil:
|
||||
expiry = *customExpiry
|
||||
}
|
||||
|
||||
node, nodeChange, err := b.State.SetNodeExpiry(nodeID, &expiry)
|
||||
if err != nil {
|
||||
return nil, mapError("expiring node", err)
|
||||
}
|
||||
|
||||
b.Change(nodeChange)
|
||||
|
||||
out := &nodeOutput{}
|
||||
out.Body.Node = nodeFromView(node)
|
||||
|
||||
return out, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "renameNode",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/v1/node/{nodeId}/rename/{newName}",
|
||||
Summary: "Rename node",
|
||||
Tags: []string{"Nodes"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, in *renameNodeInput) (*nodeOutput, error) {
|
||||
nodeID, err := parseNodeID(in.NodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
node, nodeChange, err := b.State.RenameNode(nodeID, in.NewName)
|
||||
if err != nil {
|
||||
return nil, mapError("renaming node", err)
|
||||
}
|
||||
|
||||
b.Change(nodeChange)
|
||||
|
||||
out := &nodeOutput{}
|
||||
out.Body.Node = nodeFromView(node)
|
||||
|
||||
return out, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "setTags",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/v1/node/{nodeId}/tags",
|
||||
Summary: "Set tags",
|
||||
Tags: []string{"Nodes"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, in *setTagsInput) (*nodeOutput, error) {
|
||||
nodeID, err := parseNodeID(in.NodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Tagged nodes must keep at least one tag, so reject an empty set
|
||||
// before touching state, as gRPC does.
|
||||
if len(in.Body.Tags) == 0 {
|
||||
return nil, huma.Error400BadRequest(
|
||||
"cannot remove all tags from a node - tagged nodes must have at least one tag",
|
||||
)
|
||||
}
|
||||
|
||||
for _, tag := range in.Body.Tags {
|
||||
tagErr := validateTag(tag)
|
||||
if tagErr != nil {
|
||||
return nil, huma.Error400BadRequest("setting tags", tagErr)
|
||||
}
|
||||
}
|
||||
|
||||
_, found := b.State.GetNodeByID(nodeID)
|
||||
if !found {
|
||||
return nil, huma.Error404NotFound("node not found")
|
||||
}
|
||||
|
||||
node, nodeChange, err := b.State.SetNodeTags(nodeID, in.Body.Tags)
|
||||
if err != nil {
|
||||
return nil, huma.Error400BadRequest("setting tags", err)
|
||||
}
|
||||
|
||||
b.Change(nodeChange)
|
||||
|
||||
out := &nodeOutput{}
|
||||
out.Body.Node = nodeFromView(node)
|
||||
|
||||
return out, nil
|
||||
})
|
||||
}
|
||||
|
||||
func registerNodeAdminOps(api huma.API, b Backend) {
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "setApprovedRoutes",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/v1/node/{nodeId}/approve_routes",
|
||||
Summary: "Set approved routes",
|
||||
Tags: []string{"Nodes"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, in *setApprovedRoutesInput) (*nodeOutput, error) {
|
||||
nodeID, err := parseNodeID(in.NodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var newApproved []netip.Prefix
|
||||
|
||||
for _, route := range in.Body.Routes {
|
||||
prefix, parseErr := netip.ParsePrefix(route)
|
||||
if parseErr != nil {
|
||||
return nil, huma.Error400BadRequest("parsing route", parseErr)
|
||||
}
|
||||
|
||||
// One exit route implies both families, else the client won't
|
||||
// annotate the node as an exit node.
|
||||
if prefix == tsaddr.AllIPv4() || prefix == tsaddr.AllIPv6() {
|
||||
newApproved = append(newApproved, tsaddr.AllIPv4(), tsaddr.AllIPv6())
|
||||
} else {
|
||||
newApproved = append(newApproved, prefix)
|
||||
}
|
||||
}
|
||||
|
||||
slices.SortFunc(newApproved, netip.Prefix.Compare)
|
||||
newApproved = slices.Compact(newApproved)
|
||||
|
||||
node, nodeChange, err := b.State.SetApprovedRoutes(nodeID, newApproved)
|
||||
if err != nil {
|
||||
return nil, mapError("setting approved routes", err)
|
||||
}
|
||||
|
||||
b.Change(nodeChange)
|
||||
|
||||
out := &nodeOutput{}
|
||||
out.Body.Node = nodeFromView(node)
|
||||
// SubnetRoutes here excludes exit routes, unlike the list handler.
|
||||
out.Body.Node.SubnetRoutes = util.PrefixesToString(
|
||||
b.State.GetNodePrimaryRoutes(node.ID()),
|
||||
)
|
||||
|
||||
return out, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "registerNode",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/v1/node/register",
|
||||
Summary: "Register node",
|
||||
Tags: []string{"Nodes"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, in *registerNodeInput) (*nodeOutput, error) {
|
||||
registrationID, err := types.AuthIDFromString(in.Key)
|
||||
if err != nil {
|
||||
return nil, huma.Error400BadRequest("registering node", err)
|
||||
}
|
||||
|
||||
user, err := b.State.GetUserByName(in.User)
|
||||
if err != nil {
|
||||
return nil, mapError("looking up user", err)
|
||||
}
|
||||
|
||||
node, nodeChange, err := b.State.HandleNodeFromAuthPath(
|
||||
registrationID,
|
||||
types.UserID(user.ID),
|
||||
nil,
|
||||
util.RegisterMethodCLI,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, mapError("registering node", err)
|
||||
}
|
||||
|
||||
routeChange, err := b.State.AutoApproveRoutes(node)
|
||||
if err != nil {
|
||||
return nil, huma.Error500InternalServerError("auto approving routes", err)
|
||||
}
|
||||
|
||||
// Empty changes are ignored by the change sink.
|
||||
b.Change(nodeChange, routeChange)
|
||||
|
||||
out := &nodeOutput{}
|
||||
out.Body.Node = nodeFromView(node)
|
||||
|
||||
return out, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "backfillNodeIPs",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/v1/node/backfillips",
|
||||
Summary: "Backfill node IPs",
|
||||
Tags: []string{"Nodes"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, in *backfillNodeIPsInput) (*backfillNodeIPsOutput, error) {
|
||||
if !in.Confirmed {
|
||||
return nil, huma.Error400BadRequest("backfilling node IPs", errBackfillNotConfirmed)
|
||||
}
|
||||
|
||||
changes, err := b.State.BackfillNodeIPs()
|
||||
if err != nil {
|
||||
return nil, huma.Error500InternalServerError("backfilling node IPs", err)
|
||||
}
|
||||
|
||||
out := &backfillNodeIPsOutput{}
|
||||
out.Body.Changes = changes
|
||||
|
||||
if out.Body.Changes == nil {
|
||||
out.Body.Changes = []string{}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "debugCreateNode",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/v1/debug/node",
|
||||
Summary: "Debug create node",
|
||||
Tags: []string{"Nodes"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, in *debugCreateNodeInput) (*nodeOutput, error) {
|
||||
user, err := b.State.GetUserByName(in.Body.User)
|
||||
if err != nil {
|
||||
return nil, mapError("looking up user", err)
|
||||
}
|
||||
|
||||
routes, err := util.StringToIPPrefix(in.Body.Routes)
|
||||
if err != nil {
|
||||
return nil, huma.Error400BadRequest("parsing routes", err)
|
||||
}
|
||||
|
||||
registrationID, err := types.AuthIDFromString(in.Body.Key)
|
||||
if err != nil {
|
||||
return nil, huma.Error400BadRequest("debug creating node", err)
|
||||
}
|
||||
|
||||
regData := &types.RegistrationData{
|
||||
NodeKey: key.NewNode().Public(),
|
||||
MachineKey: key.NewMachine().Public(),
|
||||
Hostname: in.Body.Name,
|
||||
Expiry: &time.Time{}, // zero time, not nil, to keep proto JSON round-trip semantics
|
||||
}
|
||||
|
||||
authRegReq := types.NewRegisterAuthRequest(regData)
|
||||
b.State.SetAuthCacheEntry(registrationID, authRegReq)
|
||||
|
||||
// Synthetic echo; the real node is created later via the auth path
|
||||
// from the cached registration data.
|
||||
echoNode := types.Node{
|
||||
NodeKey: regData.NodeKey,
|
||||
MachineKey: regData.MachineKey,
|
||||
Hostname: regData.Hostname,
|
||||
User: user,
|
||||
Expiry: &time.Time{},
|
||||
LastSeen: &time.Time{},
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
Hostname: in.Body.Name,
|
||||
OS: "TestOS",
|
||||
RoutableIPs: routes,
|
||||
},
|
||||
}
|
||||
|
||||
out := &nodeOutput{}
|
||||
out.Body.Node = nodeFromView(echoNode.View())
|
||||
|
||||
return out, nil
|
||||
})
|
||||
}
|
||||
|
||||
// nodeFromView builds the Node response from a NodeView. SubnetRoutes is left
|
||||
// empty; callers that serve routes set it explicitly.
|
||||
func nodeFromView(view types.NodeView) Node {
|
||||
node := view.AsStruct()
|
||||
|
||||
n := Node{
|
||||
ID: formatID(uint64(node.ID)),
|
||||
MachineKey: node.MachineKey.String(),
|
||||
NodeKey: node.NodeKey.String(),
|
||||
DiscoKey: node.DiscoKey.String(),
|
||||
IPAddresses: nonNilStrings(node.IPsAsString()),
|
||||
Name: node.Hostname,
|
||||
CreatedAt: node.CreatedAt,
|
||||
RegisterMethod: registerMethodEnum(node.RegisterMethod),
|
||||
GivenName: node.GivenName,
|
||||
Online: node.IsOnline != nil && *node.IsOnline,
|
||||
ApprovedRoutes: nonNilStrings(util.PrefixesToString(node.ApprovedRoutes)),
|
||||
AvailableRoutes: nonNilStrings(util.PrefixesToString(node.AnnouncedRoutes())),
|
||||
SubnetRoutes: []string{},
|
||||
Tags: nonNilStrings(node.Tags),
|
||||
}
|
||||
|
||||
if node.User != nil {
|
||||
user := userFromState(node.User)
|
||||
n.User = &user
|
||||
}
|
||||
|
||||
if node.AuthKey != nil {
|
||||
n.PreAuthKey = nodePreAuthKeyFromState(node.AuthKey)
|
||||
}
|
||||
|
||||
if node.LastSeen != nil {
|
||||
ls := *node.LastSeen
|
||||
n.LastSeen = &ls
|
||||
}
|
||||
|
||||
if node.Expiry != nil {
|
||||
exp := *node.Expiry
|
||||
n.Expiry = &exp
|
||||
}
|
||||
|
||||
return n
|
||||
}
|
||||
|
||||
// nodePreAuthKeyFromState builds the embedded NodePreAuthKey, masking the key to
|
||||
// its prefix (legacy plaintext keys are shown in full).
|
||||
func nodePreAuthKeyFromState(key *types.PreAuthKey) *NodePreAuthKey {
|
||||
pak := &NodePreAuthKey{
|
||||
ID: formatID(key.ID),
|
||||
Key: maskedPreAuthKey(key),
|
||||
Reusable: key.Reusable,
|
||||
Ephemeral: key.Ephemeral,
|
||||
Used: key.Used,
|
||||
AclTags: nonNilStrings(key.Tags),
|
||||
}
|
||||
|
||||
if key.User != nil {
|
||||
user := userFromState(key.User)
|
||||
pak.User = &user
|
||||
}
|
||||
|
||||
if key.Expiration != nil {
|
||||
exp := *key.Expiration
|
||||
pak.Expiration = &exp
|
||||
}
|
||||
|
||||
if key.CreatedAt != nil {
|
||||
created := *key.CreatedAt
|
||||
pak.CreatedAt = &created
|
||||
}
|
||||
|
||||
return pak
|
||||
}
|
||||
|
||||
// registerMethodEnum maps the stored register method onto the v1 enum string,
|
||||
// defaulting to REGISTER_METHOD_UNSPECIFIED for unknown values.
|
||||
func registerMethodEnum(method string) string {
|
||||
if enum, ok := registerMethodToV1Enum[method]; ok {
|
||||
return enum
|
||||
}
|
||||
|
||||
return "REGISTER_METHOD_UNSPECIFIED"
|
||||
}
|
||||
|
||||
func nonNilStrings(s []string) []string {
|
||||
if s == nil {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// cmpNodeID orders two decimal node-ID strings numerically, matching the gRPC
|
||||
// handler's ascending-ID ordering.
|
||||
func cmpNodeID(a, b string) int {
|
||||
ai, _ := strconv.ParseUint(a, 10, 64)
|
||||
bi, _ := strconv.ParseUint(b, 10, 64)
|
||||
|
||||
switch {
|
||||
case ai < bi:
|
||||
return -1
|
||||
case ai > bi:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func parseNodeID(s string) (types.NodeID, error) {
|
||||
id, err := strconv.ParseUint(s, 10, 64)
|
||||
if err != nil {
|
||||
return 0, huma.Error400BadRequest(
|
||||
"type mismatch, parameter: node_id, error: " + err.Error(),
|
||||
)
|
||||
}
|
||||
|
||||
return types.NodeID(id), nil
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/util"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registrations = append(registrations, registerPolicy)
|
||||
}
|
||||
|
||||
// PolicyRequestBody carries the HuJSON policy document as a string, for both
|
||||
// v1.SetPolicyRequest and v1.CheckPolicyRequest.
|
||||
type PolicyRequestBody struct {
|
||||
Policy string `json:"policy,omitempty"`
|
||||
}
|
||||
|
||||
// PolicyResponseBody is the v1.GetPolicyResponse/SetPolicyResponse body. Fields
|
||||
// carry no omitempty so zero values are emitted (EmitUnpopulated parity).
|
||||
type PolicyResponseBody struct {
|
||||
Policy string `json:"policy"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type (
|
||||
getPolicyInput struct{}
|
||||
getPolicyOutput struct {
|
||||
Body PolicyResponseBody
|
||||
}
|
||||
|
||||
setPolicyInput struct {
|
||||
Body PolicyRequestBody
|
||||
}
|
||||
setPolicyOutput struct {
|
||||
Body PolicyResponseBody
|
||||
}
|
||||
|
||||
checkPolicyInput struct {
|
||||
Body PolicyRequestBody
|
||||
}
|
||||
checkPolicyOutput struct {
|
||||
Body struct{}
|
||||
}
|
||||
)
|
||||
|
||||
func registerPolicy(api huma.API, b Backend) {
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "getPolicy",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/v1/policy",
|
||||
Summary: "Get policy",
|
||||
Tags: []string{"Policy"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, _ *getPolicyInput) (*getPolicyOutput, error) {
|
||||
switch b.Cfg.Policy.Mode {
|
||||
case types.PolicyModeDB:
|
||||
p, err := b.State.GetPolicy()
|
||||
if err != nil {
|
||||
return nil, huma.Error500InternalServerError("loading ACL from database", err)
|
||||
}
|
||||
|
||||
out := &getPolicyOutput{}
|
||||
out.Body.Policy = p.Data
|
||||
out.Body.UpdatedAt = p.UpdatedAt
|
||||
|
||||
return out, nil
|
||||
case types.PolicyModeFile:
|
||||
absPath := util.AbsolutePathFromConfigPath(b.Cfg.Policy.Path)
|
||||
|
||||
f, err := os.Open(absPath)
|
||||
if err != nil {
|
||||
return nil, huma.Error500InternalServerError(
|
||||
fmt.Sprintf("reading policy from path %q", absPath), err,
|
||||
)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
data, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return nil, huma.Error500InternalServerError("reading policy from file", err)
|
||||
}
|
||||
|
||||
out := &getPolicyOutput{}
|
||||
out.Body.Policy = string(data)
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
return nil, huma.Error500InternalServerError(fmt.Sprintf(
|
||||
"no supported policy mode found in configuration, policy.mode: %q",
|
||||
b.Cfg.Policy.Mode,
|
||||
), nil)
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "setPolicy",
|
||||
Method: http.MethodPut,
|
||||
Path: "/api/v1/policy",
|
||||
Summary: "Set policy",
|
||||
Tags: []string{"Policy"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, in *setPolicyInput) (*setPolicyOutput, error) {
|
||||
if b.Cfg.Policy.Mode != types.PolicyModeDB {
|
||||
// Policy updates are only valid in DB mode; otherwise 400.
|
||||
return nil, huma.Error400BadRequest(
|
||||
types.ErrPolicyUpdateIsDisabled.Error(), types.ErrPolicyUpdateIsDisabled,
|
||||
)
|
||||
}
|
||||
|
||||
p := in.Body.Policy
|
||||
|
||||
// Reject policy that would fail when building a map response. SSH rule
|
||||
// validation needs a node, so a server with no nodes can't catch every
|
||||
// case here.
|
||||
nodes := b.State.ListNodes()
|
||||
|
||||
_, err := b.State.SetPolicy([]byte(p))
|
||||
if err != nil {
|
||||
return nil, huma.Error400BadRequest("setting policy", err)
|
||||
}
|
||||
|
||||
if nodes.Len() > 0 {
|
||||
_, err = b.State.SSHPolicy(nodes.At(0))
|
||||
if err != nil {
|
||||
return nil, huma.Error400BadRequest("verifying SSH rules", err)
|
||||
}
|
||||
}
|
||||
|
||||
updated, err := b.State.SetPolicyInDB(p)
|
||||
if err != nil {
|
||||
return nil, huma.Error500InternalServerError("setting policy", err)
|
||||
}
|
||||
|
||||
// Reload even when content is unchanged: routes manually disabled before
|
||||
// may now qualify for auto-approval, so they must be re-evaluated.
|
||||
cs, err := b.State.ReloadPolicy()
|
||||
if err != nil {
|
||||
return nil, huma.Error500InternalServerError("reloading policy", err)
|
||||
}
|
||||
|
||||
if len(cs) > 0 {
|
||||
b.Change(cs...)
|
||||
}
|
||||
|
||||
out := &setPolicyOutput{}
|
||||
out.Body.Policy = updated.Data
|
||||
out.Body.UpdatedAt = updated.UpdatedAt
|
||||
|
||||
return out, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "checkPolicy",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/v1/policy/check",
|
||||
Summary: "Check policy",
|
||||
Description: "Validates the given policy against the server's live users and nodes without persisting it.",
|
||||
Tags: []string{"Policy"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, in *checkPolicyInput) (*checkPolicyOutput, error) {
|
||||
polB := []byte(in.Body.Policy)
|
||||
|
||||
users, err := b.State.ListAllUsers()
|
||||
if err != nil {
|
||||
return nil, huma.Error500InternalServerError("loading users", err)
|
||||
}
|
||||
|
||||
nodes := b.State.ListNodes()
|
||||
|
||||
pm, err := policyv2.NewPolicyManager(polB, users, nodes)
|
||||
if err != nil {
|
||||
return nil, huma.Error400BadRequest(err.Error(), err)
|
||||
}
|
||||
|
||||
_, err = pm.SetPolicy(polB)
|
||||
if err != nil {
|
||||
return nil, huma.Error400BadRequest(err.Error(), err)
|
||||
}
|
||||
|
||||
return &checkPolicyOutput{}, nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registrations = append(registrations, registerPreAuthKeys)
|
||||
}
|
||||
|
||||
// PreAuthKey is the v1 PreAuthKey message. User is a pointer with no omitempty
|
||||
// so tagged (system-created) keys emit "user":null. Expiration and CreatedAt
|
||||
// are always emitted, zero-stamped when unset.
|
||||
type PreAuthKey struct {
|
||||
User *User `json:"user"`
|
||||
ID string `format:"uint64" json:"id"`
|
||||
Key string `json:"key"`
|
||||
Reusable bool `json:"reusable"`
|
||||
Ephemeral bool `json:"ephemeral"`
|
||||
Used bool `json:"used"`
|
||||
Expiration time.Time `json:"expiration"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ACLTags []string `json:"aclTags" nullable:"false"`
|
||||
}
|
||||
|
||||
// CreatePreAuthKeyRequestBody is the v1.CreatePreAuthKeyRequest body. Every
|
||||
// field is optional, hence omitempty throughout.
|
||||
type CreatePreAuthKeyRequestBody struct {
|
||||
User string `format:"uint64" json:"user,omitempty"`
|
||||
Reusable bool `json:"reusable,omitempty"`
|
||||
Ephemeral bool `json:"ephemeral,omitempty"`
|
||||
Expiration *time.Time `json:"expiration,omitempty"`
|
||||
ACLTags []string `json:"aclTags,omitempty"`
|
||||
}
|
||||
|
||||
// ExpirePreAuthKeyRequestBody is the v1.ExpirePreAuthKeyRequest body.
|
||||
type ExpirePreAuthKeyRequestBody struct {
|
||||
ID string `format:"uint64" json:"id,omitempty"`
|
||||
}
|
||||
|
||||
type (
|
||||
createPreAuthKeyInput struct {
|
||||
Body CreatePreAuthKeyRequestBody
|
||||
}
|
||||
preAuthKeyOutput struct {
|
||||
Body struct {
|
||||
PreAuthKey PreAuthKey `json:"preAuthKey"`
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
type (
|
||||
expirePreAuthKeyInput struct {
|
||||
Body ExpirePreAuthKeyRequestBody
|
||||
}
|
||||
expirePreAuthKeyOutput struct {
|
||||
Body struct{}
|
||||
}
|
||||
)
|
||||
|
||||
type (
|
||||
deletePreAuthKeyInput struct {
|
||||
ID string `format:"uint64" query:"id"`
|
||||
}
|
||||
deletePreAuthKeyOutput struct {
|
||||
Body struct{}
|
||||
}
|
||||
)
|
||||
|
||||
type listPreAuthKeysOutput struct {
|
||||
Body struct {
|
||||
PreAuthKeys []PreAuthKey `json:"preAuthKeys" nullable:"false"`
|
||||
}
|
||||
}
|
||||
|
||||
func registerPreAuthKeys(api huma.API, b Backend) {
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "createPreAuthKey",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/v1/preauthkey",
|
||||
Summary: "Create pre-auth key",
|
||||
Tags: []string{"PreAuthKeys"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, in *createPreAuthKeyInput) (*preAuthKeyOutput, error) {
|
||||
user, err := parsePreAuthKeyUser(in.Body.User)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, tag := range in.Body.ACLTags {
|
||||
tagErr := validateTag(tag)
|
||||
if tagErr != nil {
|
||||
return nil, huma.Error400BadRequest("invalid tag", tagErr)
|
||||
}
|
||||
}
|
||||
|
||||
// CreatePreAuthKey requires a non-nil pointer; zero-stamp when unset.
|
||||
var expiration time.Time
|
||||
if in.Body.Expiration != nil {
|
||||
expiration = *in.Body.Expiration
|
||||
}
|
||||
|
||||
var userID *types.UserID
|
||||
|
||||
if user != 0 {
|
||||
u, getErr := b.State.GetUserByID(user)
|
||||
if getErr != nil {
|
||||
return nil, mapError("creating pre-auth key", getErr)
|
||||
}
|
||||
|
||||
userID = u.TypedID()
|
||||
}
|
||||
|
||||
preAuthKey, err := b.State.CreatePreAuthKey(
|
||||
userID,
|
||||
in.Body.Reusable,
|
||||
in.Body.Ephemeral,
|
||||
&expiration,
|
||||
in.Body.ACLTags,
|
||||
)
|
||||
if err != nil {
|
||||
// A key that is neither tagged nor user-owned is invalid input (400).
|
||||
return nil, mapError("creating pre-auth key", err)
|
||||
}
|
||||
|
||||
out := &preAuthKeyOutput{}
|
||||
out.Body.PreAuthKey = preAuthKeyNewToResponse(preAuthKey)
|
||||
|
||||
return out, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "expirePreAuthKey",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/v1/preauthkey/expire",
|
||||
Summary: "Expire pre-auth key",
|
||||
Tags: []string{"PreAuthKeys"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, in *expirePreAuthKeyInput) (*expirePreAuthKeyOutput, error) {
|
||||
id, err := parsePreAuthKeyID(in.Body.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = b.State.ExpirePreAuthKey(id)
|
||||
if err != nil {
|
||||
// An unknown key id maps to 404.
|
||||
return nil, mapError("expiring pre-auth key", err)
|
||||
}
|
||||
|
||||
return &expirePreAuthKeyOutput{}, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "deletePreAuthKey",
|
||||
Method: http.MethodDelete,
|
||||
Path: "/api/v1/preauthkey",
|
||||
Summary: "Delete pre-auth key",
|
||||
Tags: []string{"PreAuthKeys"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, in *deletePreAuthKeyInput) (*deletePreAuthKeyOutput, error) {
|
||||
// DELETE has no body: id is bound from the query string.
|
||||
id, err := parsePreAuthKeyID(in.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = b.State.DeletePreAuthKey(id)
|
||||
if err != nil {
|
||||
// An unknown key id maps to 404.
|
||||
return nil, mapError("deleting pre-auth key", err)
|
||||
}
|
||||
|
||||
return &deletePreAuthKeyOutput{}, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "listPreAuthKeys",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/v1/preauthkey",
|
||||
Summary: "List pre-auth keys",
|
||||
Tags: []string{"PreAuthKeys"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, _ *struct{}) (*listPreAuthKeysOutput, error) {
|
||||
preAuthKeys, err := b.State.ListPreAuthKeys()
|
||||
if err != nil {
|
||||
return nil, huma.Error500InternalServerError("listing pre-auth keys", err)
|
||||
}
|
||||
|
||||
// Match the gRPC handler's ascending-ID ordering.
|
||||
slices.SortFunc(preAuthKeys, func(a, b types.PreAuthKey) int {
|
||||
return cmp.Compare(a.ID, b.ID)
|
||||
})
|
||||
|
||||
out := &listPreAuthKeysOutput{}
|
||||
|
||||
out.Body.PreAuthKeys = make([]PreAuthKey, len(preAuthKeys))
|
||||
for i := range preAuthKeys {
|
||||
out.Body.PreAuthKeys[i] = preAuthKeyToResponse(&preAuthKeys[i])
|
||||
}
|
||||
|
||||
return out, nil
|
||||
})
|
||||
}
|
||||
|
||||
// preAuthKeyNewToResponse builds the v1 response for a freshly created key. The
|
||||
// plaintext key is returned only here; Used is always false.
|
||||
func preAuthKeyNewToResponse(key *types.PreAuthKeyNew) PreAuthKey {
|
||||
out := PreAuthKey{
|
||||
ID: formatID(key.ID),
|
||||
Key: key.Key,
|
||||
Reusable: key.Reusable,
|
||||
Ephemeral: key.Ephemeral,
|
||||
ACLTags: nonNilTags(key.Tags),
|
||||
}
|
||||
|
||||
if key.User != nil {
|
||||
u := userFromState(key.User)
|
||||
out.User = &u
|
||||
}
|
||||
|
||||
if key.Expiration != nil {
|
||||
out.Expiration = *key.Expiration
|
||||
}
|
||||
|
||||
if key.CreatedAt != nil {
|
||||
out.CreatedAt = *key.CreatedAt
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// preAuthKeyToResponse builds the v1 response for a stored key, with its key
|
||||
// field masked (see maskedPreAuthKey).
|
||||
func preAuthKeyToResponse(key *types.PreAuthKey) PreAuthKey {
|
||||
out := PreAuthKey{
|
||||
ID: formatID(key.ID),
|
||||
Key: maskedPreAuthKey(key),
|
||||
Reusable: key.Reusable,
|
||||
Ephemeral: key.Ephemeral,
|
||||
Used: key.Used,
|
||||
ACLTags: nonNilTags(key.Tags),
|
||||
}
|
||||
|
||||
if key.User != nil {
|
||||
u := userFromState(key.User)
|
||||
out.User = &u
|
||||
}
|
||||
|
||||
if key.Expiration != nil {
|
||||
out.Expiration = *key.Expiration
|
||||
}
|
||||
|
||||
if key.CreatedAt != nil {
|
||||
out.CreatedAt = *key.CreatedAt
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// maskedPreAuthKey masks new keys (those with a stored prefix) so the secret is
|
||||
// never returned; legacy plaintext keys are returned in full for backwards
|
||||
// compatibility.
|
||||
func maskedPreAuthKey(key *types.PreAuthKey) string {
|
||||
if key.Prefix != "" {
|
||||
return "hskey-auth-" + key.Prefix + "-***"
|
||||
}
|
||||
|
||||
return key.Key
|
||||
}
|
||||
|
||||
// nonNilTags ensures aclTags serializes as [] rather than null, matching
|
||||
// EmitUnpopulated output.
|
||||
func nonNilTags(tags []string) []string {
|
||||
if tags == nil {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
return tags
|
||||
}
|
||||
|
||||
// parsePreAuthKeyUser parses the optional uint64 user field. Empty means "no
|
||||
// user" (user 0); non-numeric is rejected with 400.
|
||||
func parsePreAuthKeyUser(s string) (types.UserID, error) {
|
||||
if s == "" {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(s, 10, 64)
|
||||
if err != nil {
|
||||
return 0, huma.Error400BadRequest("invalid user id", err)
|
||||
}
|
||||
|
||||
return types.UserID(id), nil
|
||||
}
|
||||
|
||||
// parsePreAuthKeyID parses the uint64 key id. Empty means id 0; non-numeric is
|
||||
// rejected with 400.
|
||||
func parsePreAuthKeyID(s string) (uint64, error) {
|
||||
if s == "" {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(s, 10, 64)
|
||||
if err != nil {
|
||||
return 0, huma.Error400BadRequest("invalid pre-auth key id", err)
|
||||
}
|
||||
|
||||
return id, nil
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ACL tag validation, shared by the node and pre-auth-key resources. These
|
||||
// reproduce the gRPC validateTag checks and messages.
|
||||
var (
|
||||
errTagMissingPrefix = errors.New("tag must start with the string 'tag:'")
|
||||
errTagNotLowercase = errors.New("tag should be lowercase")
|
||||
errTagHasSpaces = errors.New("tags must not contain spaces")
|
||||
)
|
||||
|
||||
// validateTag reports whether an ACL tag is well formed: it must start with
|
||||
// "tag:", be lowercase, and contain no spaces.
|
||||
func validateTag(tag string) error {
|
||||
switch {
|
||||
case !strings.HasPrefix(tag, "tag:"):
|
||||
return errTagMissingPrefix
|
||||
case strings.ToLower(tag) != tag:
|
||||
return errTagNotLowercase
|
||||
case len(strings.Fields(tag)) > 1:
|
||||
return errTagHasSpaces
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
)
|
||||
|
||||
// The v1 contract follows protojson: 64-bit integers are JSON strings (avoiding
|
||||
// precision loss above 2^53), timestamps are RFC 3339, and zero values are
|
||||
// emitted. Hence response fields carry NO omitempty; request types keep
|
||||
// omitempty so their fields stay optional in the spec.
|
||||
|
||||
// formatID renders a uint64 identifier as the contract's decimal string.
|
||||
func formatID[T ~uint64 | ~uint](id T) string {
|
||||
return strconv.FormatUint(uint64(id), 10)
|
||||
}
|
||||
|
||||
// User mirrors the v1 User message.
|
||||
type User struct {
|
||||
ID string `format:"uint64" json:"id"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Email string `json:"email"`
|
||||
ProviderID string `json:"providerId"`
|
||||
Provider string `json:"provider"`
|
||||
ProfilePicURL string `json:"profilePicUrl"`
|
||||
}
|
||||
|
||||
// userFromState converts a domain user into the v1 response shape: Name falls
|
||||
// back to Username() (email/provider/id) when the stored Name is empty, so OIDC
|
||||
// users display their email.
|
||||
func userFromState(u *types.User) User {
|
||||
name := u.Name
|
||||
if name == "" {
|
||||
name = u.Username()
|
||||
}
|
||||
|
||||
return User{
|
||||
ID: formatID(u.ID),
|
||||
Name: name,
|
||||
CreatedAt: u.CreatedAt,
|
||||
DisplayName: u.DisplayName,
|
||||
Email: u.Email,
|
||||
ProviderID: u.ProviderIdentifier.String,
|
||||
Provider: u.Provider,
|
||||
ProfilePicURL: u.ProfilePicURL,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registrations = append(registrations, registerUsers)
|
||||
}
|
||||
|
||||
// CreateUserRequestBody mirrors v1.CreateUserRequest.
|
||||
type CreateUserRequestBody struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
DisplayName string `json:"displayName,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
PictureURL string `json:"pictureUrl,omitempty"`
|
||||
}
|
||||
|
||||
type (
|
||||
createUserInput struct {
|
||||
Body CreateUserRequestBody
|
||||
}
|
||||
userOutput struct {
|
||||
Body struct {
|
||||
User User `json:"user"`
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
type (
|
||||
renameUserInput struct {
|
||||
OldID string `format:"uint64" path:"oldId"`
|
||||
NewName string `path:"newName"`
|
||||
}
|
||||
|
||||
deleteUserInput struct {
|
||||
ID string `format:"uint64" path:"id"`
|
||||
}
|
||||
deleteUserOutput struct {
|
||||
Body struct{}
|
||||
}
|
||||
)
|
||||
|
||||
type (
|
||||
listUsersInput struct {
|
||||
ID string `format:"uint64" query:"id"`
|
||||
Name string `query:"name"`
|
||||
Email string `query:"email"`
|
||||
}
|
||||
listUsersOutput struct {
|
||||
Body struct {
|
||||
Users []User `json:"users" nullable:"false"`
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
func registerUsers(api huma.API, b Backend) {
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "createUser",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/v1/user",
|
||||
Summary: "Create user",
|
||||
Tags: []string{"Users"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, in *createUserInput) (*userOutput, error) {
|
||||
// Pre-check yields a 409 for the common case; the DB unique constraint
|
||||
// is the real guard.
|
||||
if in.Body.Name != "" {
|
||||
_, err := b.State.GetUserByName(in.Body.Name)
|
||||
if err == nil {
|
||||
return nil, huma.Error409Conflict("user already exists")
|
||||
}
|
||||
}
|
||||
|
||||
user, policyChanged, err := b.State.CreateUser(types.User{
|
||||
Name: in.Body.Name,
|
||||
DisplayName: in.Body.DisplayName,
|
||||
Email: in.Body.Email,
|
||||
ProfilePicURL: in.Body.PictureURL,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, mapError("creating user", err)
|
||||
}
|
||||
|
||||
b.Change(policyChanged)
|
||||
|
||||
out := &userOutput{}
|
||||
out.Body.User = userFromState(user)
|
||||
|
||||
return out, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "renameUser",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/v1/user/{oldId}/rename/{newName}",
|
||||
Summary: "Rename user",
|
||||
Tags: []string{"Users"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, in *renameUserInput) (*userOutput, error) {
|
||||
oldID, err := parseUserID(in.OldID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
oldUser, err := b.State.GetUserByID(oldID)
|
||||
if err != nil {
|
||||
return nil, mapError("renaming user", err)
|
||||
}
|
||||
|
||||
_, c, err := b.State.RenameUser(types.UserID(oldUser.ID), in.NewName)
|
||||
if err != nil {
|
||||
return nil, mapError("renaming user", err)
|
||||
}
|
||||
|
||||
b.Change(c)
|
||||
|
||||
newUser, err := b.State.GetUserByName(in.NewName)
|
||||
if err != nil {
|
||||
return nil, huma.Error500InternalServerError("renaming user", err)
|
||||
}
|
||||
|
||||
out := &userOutput{}
|
||||
out.Body.User = userFromState(newUser)
|
||||
|
||||
return out, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "deleteUser",
|
||||
Method: http.MethodDelete,
|
||||
Path: "/api/v1/user/{id}",
|
||||
Summary: "Delete user",
|
||||
Tags: []string{"Users"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, in *deleteUserInput) (*deleteUserOutput, error) {
|
||||
id, err := parseUserID(in.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, err := b.State.GetUserByID(id)
|
||||
if err != nil {
|
||||
return nil, mapError("deleting user", err)
|
||||
}
|
||||
|
||||
policyChanged, err := b.State.DeleteUser(types.UserID(user.ID))
|
||||
if err != nil {
|
||||
return nil, mapError("deleting user", err)
|
||||
}
|
||||
|
||||
b.Change(policyChanged)
|
||||
|
||||
return &deleteUserOutput{}, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "listUsers",
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/v1/user",
|
||||
Summary: "List users",
|
||||
Tags: []string{"Users"},
|
||||
Security: bearerAuth,
|
||||
}, func(ctx context.Context, in *listUsersInput) (*listUsersOutput, error) {
|
||||
// Gateway parity: a non-numeric id is a 400 even when other filters win.
|
||||
if in.ID != "" {
|
||||
_, err := strconv.ParseUint(in.ID, 10, 64)
|
||||
if err != nil {
|
||||
return nil, huma.Error400BadRequest("invalid id", err)
|
||||
}
|
||||
}
|
||||
|
||||
users, err := listUsersFiltered(b, in)
|
||||
if err != nil {
|
||||
return nil, huma.Error500InternalServerError("listing users", err)
|
||||
}
|
||||
|
||||
// Match the gRPC handler's ascending-ID ordering.
|
||||
slices.SortFunc(users, func(a, b types.User) int {
|
||||
return cmp.Compare(a.ID, b.ID)
|
||||
})
|
||||
|
||||
out := &listUsersOutput{}
|
||||
|
||||
out.Body.Users = make([]User, len(users))
|
||||
for i := range users {
|
||||
out.Body.Users[i] = userFromState(&users[i])
|
||||
}
|
||||
|
||||
return out, nil
|
||||
})
|
||||
}
|
||||
|
||||
// listUsersFiltered reproduces the gRPC ListUsers precedence: name, then email,
|
||||
// then id, otherwise all users.
|
||||
func listUsersFiltered(b Backend, in *listUsersInput) ([]types.User, error) {
|
||||
switch {
|
||||
case in.Name != "":
|
||||
return b.State.ListUsersWithFilter(&types.User{Name: in.Name})
|
||||
case in.Email != "":
|
||||
return b.State.ListUsersWithFilter(&types.User{Email: in.Email})
|
||||
case in.ID != "":
|
||||
id, err := strconv.ParseUint(in.ID, 10, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if id == 0 {
|
||||
return b.State.ListAllUsers()
|
||||
}
|
||||
|
||||
return b.State.ListUsersWithFilter(&types.User{Model: gorm.Model{ID: uint(id)}})
|
||||
default:
|
||||
return b.State.ListAllUsers()
|
||||
}
|
||||
}
|
||||
|
||||
func parseUserID(s string) (types.UserID, error) {
|
||||
id, err := strconv.ParseUint(s, 10, 64)
|
||||
if err != nil {
|
||||
return 0, huma.Error400BadRequest("invalid user id", err)
|
||||
}
|
||||
|
||||
return types.UserID(id), nil
|
||||
}
|
||||
Reference in New Issue
Block a user