Compare commits

..

3 Commits

Author SHA1 Message Date
Aarnav Tale 87a9219fd3 feat: add some basic mutations 2026-05-16 21:30:16 -04:00
Aarnav Tale ad7e58570f feat: add fate vite integration 2026-05-16 20:37:57 -04:00
Aarnav Tale 04ff2138d2 feat: initial fate based SPA 2026-05-16 20:15:04 -04:00
113 changed files with 4170 additions and 2076 deletions
+2
View File
@@ -1,6 +1,8 @@
node_modules
/.fate
/.react-router
/.cache
/.data
/build
/test
.env
-21
View File
@@ -1,24 +1,3 @@
# 0.7.0-beta.4 (May 31, 2026)
> This is a beta release. Please report any issues you encounter.
- **Headplane now requires Headscale 0.27.0 or newer.** Support for 0.26.x has been dropped. If `/version` returns 404 (the endpoint was added in 0.27.0), Headplane logs an error and keeps retrying so an in-place Headscale upgrade is picked up without a restart.
- **Replaced the OpenAPI hash detection with `/version`.** Capabilities are now derived from the version reported by `/version` instead of fingerprinting the OpenAPI schema. This dramatically simplifies version detection and works with every supported release out of the box.
- **Made Headscale boot resilient.** Headplane now boots even when Headscale is unreachable; capabilities default permissively and a background retry settles them once Headscale responds. No more cold-start ordering problems with docker-compose.
- **Added optional in-process TLS termination.** Setting `server.tls_cert_path` and `server.tls_key_path` makes Headplane serve HTTPS/1.1 on `server.port` directly — no reverse proxy required. `server.cookie_secure` is auto-forced to `true` (with a warning) whenever TLS is enabled, since browsers refuse `Secure`-less cookies over HTTPS. HTTP/2 and HTTP/3 are intentionally not supported in-process; terminate those at a reverse proxy if you need them (closes [#403](https://github.com/tale/headplane/issues/403)).
- **Made the bundled Docker healthcheck zero-config across HTTP and HTTPS.** Headplane writes its loopback URL (scheme, port, and basename included) to `/tmp/headplane-listen` on startup, and `hp_healthcheck` reads that file and probes the URL verbatim. Enabling TLS or changing `server.port` no longer requires any healthcheck-specific configuration. Native installs are unaffected — the listen file is only written when `HEADPLANE_LISTEN_FILE` is set, which the Dockerfile does automatically.
- Added Rename and Delete actions for unlinked Headscale users on the Users page so admins can manage Headscale users that have no Headplane account (closes [#525](https://github.com/tale/headplane/issues/525)).
- Documented [Custom Certificate Authorities](/configuration/tls#custom-certificate-authorities) for trusting private or self-signed CAs across every outbound TLS connection (OIDC, Headscale, Docker, etc.) via Node's `NODE_EXTRA_CA_CERTS`. This replaces the previous workaround of rebuilding the Docker image to extend the system trust store (closes [#313](https://github.com/tale/headplane/issues/313)).
- Fixed user-management actions (link, change role, transfer ownership) using the wrong ID type for unlinked Headplane users. Form fields are now explicitly `headplane_user_id` vs. `headscale_user_id`, and the auth layer no longer round-trips through Headscale to recover the OIDC subject.
- Fixed the "Register Machine Key" dialog passing the Headscale numeric user id instead of the username. Headscale's `RegisterNodeRequest.user` proto field is a `string` looked up via `GetUserByName` (no numeric fallback), so registration was failing whenever the selected owner's display name differed from their numeric id (closes [#532](https://github.com/tale/headplane/issues/532)).
- Fixed pre-auth key expiration on Headscale 0.27.x. The pre-0.28 expire endpoint takes a `uint64 user` field which the API layer reads from `key.user?.id`, but the caller was wrapping the id as `{ name: user }`, causing the request to send an empty user field. Headplane now correctly passes the numeric Headscale user id.
- Fixed dialog panels growing beyond the viewport; dialog content is now constrained and scrollable (via [#556](https://github.com/tale/headplane/pull/556)).
- Fixed focus rings on inputs and buttons inside dialogs being clipped by the scrollable content container.
- Fixed tooltips on the last row of the machines table being clipped by the viewport; tooltips now anchor above the trigger with collision padding (closes [#508](https://github.com/tale/headplane/issues/508)).
- Corrected the Docker healthcheck example in the docs to use the required `CMD` prefix so reverse proxies don't see the container as unhealthy (closes [#535](https://github.com/tale/headplane/issues/535)).
---
# 0.7.0-beta.3 (May 14, 2026)
> This is a beta release. Please report any issues you encounter.
+616
View File
@@ -0,0 +1,616 @@
# Headplane codebase findings
Date: 2026-05-16
Scope: server-side TypeScript, React Router route modules, client components, live-data/data-fetching paths, auth/session/RBAC, Headscale config/API integration, Go agent/WebSSH code, runtime/build/CI, and docs. This is an architecture and pattern review, not a completed security audit.
## Executive summary
Headplane's biggest codebase risks are not isolated style issues; they cluster around ownership boundaries:
- **Auth/session boundary:** API-key sessions put the raw Headscale API key in the auth cookie, and self-service account linking can claim arbitrary unclaimed Headscale users.
- **Live-data boundary:** the current live path polls globally, caches globally, and tells React Router to re-run whole active route loaders on any resource change.
- **Lifecycle boundary:** process-lifetime services have `setInterval`, child processes, SSE streams, Undici agents, and WASM/tsnet state, but there is no coordinated shutdown/disposal model.
- **Mutation boundary:** config-file edits and several admin/user mutations rely on UI constraints, mutable process state, non-transactional writes, or inconsistent server-side validation.
- **Framework/tooling boundary:** React Router loaders/actions currently carry a lot of app state orchestration. Fate could be a good fit for live `nodes`/`users` data, but it will not fix auth, authorization, config mutation, WebSSH, or agent lifecycle issues by itself.
## Highest-priority findings
### 1. OIDC self-linking can claim arbitrary unclaimed Headscale users
**Severity:** Critical
**Area:** AuthZ / account linking
**Evidence:**
- `app/routes/home.tsx:84-97` accepts a posted `headscale_user_id` and calls `context.auth.linkHeadscaleUser(principal.user.id, headscaleUserId)`.
- `app/server/web/auth.ts:225-237` grants machine ownership based on `principal.user.headscaleUserId === node.user?.id`.
- `app/routes/machines/machine-actions.ts:57-68` authorizes machine mutations through `canManageNode`.
**Why it matters:** a logged-in OIDC user can forge the form POST and link themselves to any unclaimed Headscale user ID. Once linked, they can manage that user's machines through the machine action authorization path.
**Suggested direction:** recompute allowed self-link targets server-side inside the action and reject anything not in that set. Prefer auto-linking only by verified OIDC subject/email match, and reserve arbitrary linking for admins.
### 2. API-key sessions put the raw Headscale API key in a signed cookie
**Severity:** Critical
**Area:** Auth/session security
**Evidence:**
- `app/server/web/auth.ts:96-105` manually base64url-encodes JSON and signs it with HMAC.
- `app/server/web/auth.ts:269-283` serializes `{ sid, api_key }` for API-key sessions.
- `app/server/web/auth.ts:144-173` resolves API-key principals from `payload.api_key`; the DB `api_key_hash` is not used to retrieve a server-side secret.
- `app/utils/oidc-state.ts:13-19` explicitly sets `httpOnly`, while the main auth cookie options are not explicit in `app/server/context.ts:49-54` / `app/server/web/auth.ts:96-105`.
- `docs/install/index.md` describes `server.cookie_secret` as encrypting cookies, but the implementation signs rather than encrypts.
**Why it matters:** cookie compromise becomes direct Headscale API compromise, not just Headplane session compromise. HMAC protects integrity, not confidentiality. The auth cookie should be an opaque session handle, not a transport for upstream credentials.
**Suggested direction:** store only `sid` in the cookie; keep the API key encrypted or server-side in the DB/secret store; verify `api_key_hash` if retaining API-key sessions; explicitly set `httpOnly`, `sameSite`, `secure`, `path`, and consider key rotation/versioning. Use timing-safe HMAC comparison if manual signatures remain.
## Live data and data-fetching findings
### 3. SSE events trigger whole-route revalidation instead of resource-scoped updates
**Severity:** High
**Area:** Client data fetching / live data
**Evidence:**
- `app/utils/live-data.tsx:35-39` calls `revalidator.revalidate()`.
- `app/utils/live-data.tsx:63-82` treats every `changed` event as a global route revalidation.
- `app/routes/util/live.ts:34-37` sends only `{ resource, version }`, not field/object-level deltas or subscribed views.
**Why it matters:** any node/user change can re-run every active loader for the current route tree. Pages such as machines/users load multiple resources and agent metadata, so frequent Headscale changes can become over-fetching and UI churn.
**Suggested direction:** short term, include enough resource metadata to revalidate only affected routes or debounce/coalesce revalidation. Long term, move live data to normalized object/list subscriptions instead of request/route invalidation.
### 4. Live-data pause state is global, boolean, and can stick disabled
**Severity:** High
**Area:** Client state / live data
**Evidence:**
- `app/root.tsx:41-66` mounts `LiveDataProvider` above all routes.
- `app/routes/auth/login/page.tsx:59-66` calls `pause()` with no dependency array and no cleanup/resume.
- `app/components/dialog.tsx:21-27` uses the same single boolean pause model for dialogs.
- `app/utils/live-data.tsx:136-141` exposes imperative `pause()` / `resume()` with no ownership token.
**Why it matters:** visiting `/login` can leave live updates disabled after navigating into the app. Multiple pause consumers can also fight: closing one dialog can resume live updates while another still expects them paused.
**Suggested direction:** add immediate cleanup in login (`pause(); return resume`) and a dependency array. Replace the boolean with a refcount/token model so each consumer releases only its own pause.
### 5. `hsLive` is process-global but stores one mutable API client
**Severity:** High
**Area:** Server live data / cache scoping
**Evidence:**
- `app/server/context.ts:86-101` creates one `hsLive` for the entire process.
- `app/server/headscale/live-store.ts:69` keeps `storedApiClient` in shared closure state.
- `app/server/headscale/live-store.ts:113-120` polling uses that shared client.
- `app/server/headscale/live-store.ts:143` and `app/server/headscale/live-store.ts:158` overwrite it from each caller.
**Why it matters:** the last request/action to touch `hsLive` controls which API key future background polls use. A bad/expired user-supplied API-key session can poison live refresh for everyone until another request resets it. Snapshots are also not principal/session scoped.
**Suggested direction:** poll with one stable server credential only, or scope cache/polling by principal/session. If adopting Fate, make this one of the first seams to replace.
### 6. Live store polling is coarse and lifecycle-unmanaged
**Severity:** Medium
**Area:** Server lifecycle / live data
**Evidence:**
- `app/server/headscale/live-store.ts:38-45` polls nodes every 5s and users every 15s.
- `app/server/headscale/live-store.ts:82-104` compares `JSON.stringify(data)` for the whole resource.
- `app/server/headscale/live-store.ts:113-123` starts intervals lazily but never stops them unless `dispose()` is called.
- `app/server/context.ts:94` creates the store, but no app shutdown path calls `hsLive.dispose()`.
**Why it matters:** whole-resource JSON comparison is order-sensitive and grows with tailnet size. The polling intervals continue for process lifetime and can leak during dev HMR or repeated context creation.
**Suggested direction:** introduce app-level lifecycle management and subscription-aware polling, or replace with event/object-scoped live subscriptions.
### 7. Loader data is mirrored into local state and can overwrite edits during revalidation
**Severity:** Medium
**Area:** Client state / React patterns
**Evidence:**
- `app/routes/acls/overview.tsx:30-41` mirrors `policy` into `codePolicy` and updates it when loader data changes.
- `app/routes/dns/components/manage-domains.tsx:25-31` mirrors `searchDomains` into local state.
**Why it matters:** global SSE revalidation can replace local edit state. The ACL editor is especially risky because an unsaved policy can be overwritten by background loader refresh.
**Suggested direction:** separate “initial server value” from “dirty draft” state; never overwrite dirty drafts on background revalidation without prompting.
### 8. Fetcher/dialog orchestration is imperative and duplicated
**Severity:** Medium
**Area:** Client forms / mutations
**Evidence:**
- `app/routes/machines/dialogs/tags.tsx:34-43` uses `submittingRef` + effects to close/unlock.
- `app/routes/settings/auth-keys/dialogs/add-auth-key.tsx:56-71` uses similar orchestration and directly assigns `fetcher.data = undefined`.
- `app/routes/machines/dialogs/routes.tsx:49-63` submits on switch changes without optimistic/error state.
**Why it matters:** each dialog reimplements mutation lifecycle handling. Direct mutation of `fetcher.data` fights router-owned state and can hide stale or failed submissions.
**Suggested direction:** centralize fetcher mutation state handling, or move to an action/mutation primitive with explicit pending/error/success states and optimistic updates.
## Auth, authorization, and route handling findings
### 9. Agent settings route/action lacks capability checks
**Severity:** High
**Area:** Authorization
**Evidence:**
- `app/routes/settings/agent.tsx:13-27` only authenticates.
- `app/routes/settings/agent.tsx:29-39` only authenticates before triggering sync.
**Why it matters:** any authenticated user can view agent status and trigger sync work if they know the URL, regardless of settings/admin capability.
**Suggested direction:** gate read and sync separately, probably using `read_feature`/`write_feature` or a dedicated agent capability.
### 10. App layout catches all loader errors and destroys sessions
**Severity:** High
**Area:** Error handling / auth UX
**Evidence:**
- `app/layout/app.tsx:32-103` wraps the whole loader in `try/catch` and redirects to `/login` with `destroySession()` for any error.
- `app/layout/app.tsx:50-67` handles one expected expired-API-key case, but the outer catch covers unrelated failures too.
**Why it matters:** network errors, programming errors, and transient Headscale failures can become silent logouts. It also hides diagnostics from route error boundaries.
**Suggested direction:** catch only expected auth/session errors; let operational/programming errors hit the error boundary or a health banner.
### 11. RBAC is inconsistent across routes/actions
**Severity:** Medium/High
**Area:** Authorization
**Evidence:**
- `app/routes/machines/machine-actions.ts:64-68` performs resource-specific checks via `canManageNode`.
- `app/routes/users/user-actions.ts:10-17` checks only broad `write_users` before operations.
- `app/routes/home.tsx:84-97` lets OIDC users link Headscale identities without revalidating target ownership.
- `app/routes/settings/auth-keys/actions.ts:23-35` has more careful self-service ownership checks, showing the pattern exists but is not universal.
**Why it matters:** permissions are enforced route-by-route with no single policy layer, so new actions can accidentally rely on UI hiding. The self-linking issue is one concrete outcome.
**Suggested direction:** create server-side action guards for common ownership/role decisions and require every mutation to call one.
### 12. Runtime role input is not validated and ownership updates are non-transactional
**Severity:** Medium
**Area:** Auth/RBAC data integrity
**Evidence:**
- `app/routes/users/user-actions.ts:82-104` casts `newRole as Role` from form data.
- `app/server/web/auth.ts:467-484` upserts that role into the DB.
- `app/server/web/auth.ts:430-464` transfers ownership with two separate updates.
- `app/server/web/auth.ts:324-342` makes the first user owner with a count-after-insert flow.
**Why it matters:** forged role values can reach storage, and failures/races in ownership transfer or first-login owner selection can produce invalid owner state.
**Suggested direction:** validate `newRole` against `Roles` at the route boundary, use transactions for ownership transfer, and make first-owner assignment atomic.
### 13. Login page has live-data and URL cleanup bugs
**Severity:** Medium
**Area:** Client route behavior
**Evidence:**
- `app/routes/auth/login/page.tsx:59-66` pauses live data on every render and never resumes.
- `app/routes/auth/login/page.tsx:68-84` uses `window.history.replaceState` manually.
- `app/routes/auth/login/page.tsx:78-80` builds `newUrl` with `` `{${window.location.pathname}?...` ``, leaving a literal `{` in the URL.
**Why it matters:** this can disable live updates and corrupt/uglify login URLs. Manual history mutation bypasses router state.
**Suggested direction:** use a loader redirect or router navigation where possible; otherwise fix the string and make the effect one-shot with cleanup.
## Headscale API and config mutation findings
### 14. Headscale API errors are collapsed into `502 Bad Gateway`
**Severity:** Medium/High
**Area:** API wrapper / error handling
**Evidence:**
- `app/server/headscale/api/index.ts:139-145` converts network errors to React Router `data(..., 502)`.
- `app/server/headscale/api/index.ts:183-203` converts every Headscale `>=400` response to outer status 502 while preserving the real status only inside the body.
- Many callers inspect raw strings/statuses manually, e.g. `app/routes/auth/login/action.ts:78-93` and `app/routes/acls/acl-action.ts:39-107`.
**Why it matters:** UI and route logic must understand wrapper internals. HTTP semantics are obscured, and handling becomes string-fragile.
**Suggested direction:** preserve upstream status classes where safe, expose typed domain errors, and make expected Headscale quirks explicit in one adapter layer.
### 15. OpenAPI polling interval has no disposal and returned interface exposes stale values
**Severity:** Medium
**Area:** Server lifecycle / API versioning
**Evidence:**
- `app/server/headscale/api/index.ts:241-250` starts a `setInterval` inside `createHeadscaleInterface`.
- `app/server/headscale/api/index.ts:252-272` returns `openapiHashes` and `apiVersion` as values captured at return time, while `clientHelpers.isAtleast` reads the mutable closure.
- No `dispose()` exists on the interface.
**Why it matters:** lifecycle leaks in dev/reload scenarios, and consumers reading `context.hsApi.apiVersion` can see stale data while helpers see updated data.
**Suggested direction:** add lifecycle, expose getters for mutable version state, or only detect once at startup.
### 16. Config patching is not safely serialized
**Severity:** Medium/High
**Area:** Config mutation / concurrency
**Evidence:**
- `app/server/headscale/config-loader.ts:63-127` mutates the YAML document before acquiring `writeLock`.
- `app/server/headscale/config-loader.ts:120-127` sets `writeLock = true` without `try/finally`; a failed write can leave the lock stuck.
- `app/server/headscale/config-dns.ts` has a similar write-lock pattern (see `writeLock` usage).
**Why it matters:** concurrent admin actions can interleave mutations, lose updates, or deadlock future writes after an exception.
**Suggested direction:** replace the boolean lock/spin loop with a promise queue or mutex; acquire before document mutation; release in `finally`; write atomically via temp file + rename.
### 17. DNS/config actions mutate shared config arrays and lack input validation
**Severity:** Medium
**Area:** Config mutation / validation
**Evidence:**
- `app/routes/dns/dns-actions.ts:103-118` pushes nameservers into arrays read from `context.hs.c`.
- `app/routes/dns/dns-actions.ts:147-164` pushes search domains into arrays read from `context.hs.c`.
- `app/routes/dns/dns-actions.ts:189-209` writes DNS record type/value from form data with minimal validation.
**Why it matters:** in-memory config can be mutated before persistence succeeds, and invalid values can be written directly to Headscale config.
**Suggested direction:** clone before modification, validate domain/IP/record types server-side, and return actionable typed errors.
### 18. Restrictions config actions fire integration restarts without awaiting them
**Severity:** Medium
**Area:** Config mutation / operational consistency
**Evidence:**
- `app/routes/settings/restrictions/actions.ts:51`, `:79`, `:100`, `:129`, `:150`, `:179` call `context.integration?.onConfigChange(api)` without `await`.
- `app/routes/dns/dns-actions.ts` generally awaits the same hook.
**Why it matters:** the response can report success while restart/reload fails in the background, and unhandled rejections can be lost.
**Suggested direction:** await the hook consistently or queue/retry restarts through an explicit background job with surfaced status.
## Server lifecycle and runtime findings
### 19. Process-lifetime services have no composition-root `stop()`
**Severity:** High
**Area:** Server lifecycle
**Evidence:**
- `app/server/context.ts:21-101` constructs DB, Headscale API, live store, auth service, OIDC, optional agent manager, and integration.
- `app/server/app.ts:38-40` starts auth pruning.
- `app/server/hp-agent.ts:319-359` starts an interval and child process with `dispose()`.
- `app/server/headscale/live-store.ts:182-191` has `dispose()`.
- `runtime/http.ts:192-200` handles SIGINT/SIGTERM by closing the HTTP server and exiting, but has no app context cleanup.
- `runtime/vite-plugin.ts:44-50` uses `ssrLoadModule` in dev; HMR can recreate modules/services without calling old disposers.
**Why it matters:** intervals, child processes, SSE listeners, and Undici agents can leak in dev and are force-killed in production instead of being drained.
**Suggested direction:** return an app runtime with `stop()` from the composition root; call `auth.stop()`, `hsLive.dispose()`, `agents.dispose()`, `hsApi.undiciAgent.close()`, and integration cleanup from production shutdown and Vite HMR dispose hooks.
### 20. Architecture docs describe patterns the code no longer fully follows
**Severity:** Medium
**Area:** Documentation / maintainability
**Evidence:**
- `docs/development/architecture.md:15-20` says all services are closure factories, no classes/globals.
- `app/server/headscale/config-loader.ts:21-213` uses a mutable class for Headscale config.
- `docs/development/architecture.md:162-218` describes `server/index.ts`, `AppRuntime`, and `context.runtime`, while current code uses `app/server/app.ts`, `createAppContext`, and direct `context.<service>`.
**Why it matters:** docs are important to this project. Stale architecture guidance makes future changes less consistent and harder for agents/contributors to follow.
**Suggested direction:** update the architecture docs to the current composition root and explicitly document exceptions such as config-file wrappers.
## Go agent and WebSSH findings
### 21. WebSSH WASM lacks a top-level disposal model and leaks JS functions
**Severity:** High
**Area:** WebSSH lifecycle / browser resources
**Evidence:**
- `app/routes/ssh/page.tsx:156-188` creates the WASM/IPN instance and cleanup only sets `cancelled = true`.
- `app/routes/ssh/wasm.client.ts:27-29` exposes `openTunnel` only; no `dispose()`.
- `cmd/hp_ssh/hp_ssh.go:16-80` creates JS functions but never releases them with `js.Func.Release()`.
- `internal/hp_ipn/ipnserver.go:107-133` starts backend/server work but exposes no shutdown API.
**Why it matters:** repeated SSH sessions can leave in-browser tsnet/backend resources and JS function handles alive until tab refresh.
**Suggested direction:** add top-level `dispose()` to the JS API, release Go `js.Func` handles, cancel contexts, close sessions/backend/server, and call dispose from React cleanup.
### 22. WebSSH disables SSH host key verification
**Severity:** Medium/High
**Area:** Security / WebSSH
**Evidence:**
- `internal/hp_ipn/ssh.go:59-63` returns `nil` from `HostKeyCallback`.
**Why it matters:** this accepts any host key and allows MITM within the network path. Tailscale identity reduces exposure, but SSH host identity is still bypassed.
**Suggested direction:** document the tradeoff clearly at minimum. Prefer known_hosts-style pinning, Tailscale SSH identity integration, or an explicit trust-on-first-use flow.
### 23. Go agent can panic on peers without Tailscale IPs
**Severity:** Medium
**Area:** Go agent robustness
**Evidence:**
- `internal/tsnet/peers.go:61` indexes `peer.TailscaleIPs[0]`.
- `internal/tsnet/peers.go:119` also indexes `peer.TailscaleIPs[0]` before checking `len(ip) == 0`.
**Why it matters:** transient or malformed peer state can crash host-info collection.
**Suggested direction:** check `len(peer.TailscaleIPs) > 0` before indexing in both paths.
### 24. Go agent preflight uses `http.Get` without timeout and does not close response bodies
**Severity:** Medium
**Area:** Go agent robustness
**Evidence:**
- `internal/config/preflight.go:40-56` calls `http.Get(testURL)` directly.
- `internal/config/preflight.go:47-54` never closes `resp.Body`.
**Why it matters:** startup can hang indefinitely on network issues, and response bodies leak.
**Suggested direction:** use `http.Client{Timeout: ...}` and `defer resp.Body.Close()`.
### 25. Go library-like code exits the process and logging is hand-rolled
**Severity:** Medium
**Area:** Go maintainability
**Evidence:**
- `internal/tsnet/server.go:22-72` uses `log.Fatal` inside `NewAgent`/`Connect` rather than returning errors.
- `internal/util/logger.go:25-29` defines `encoder` and `pool`, but `internal/util/logger.go:59-66` only writes plain stderr lines and exits on fatal.
**Why it matters:** callers cannot recover or return structured errors, and the logger has unused complexity while still lacking structured output.
**Suggested direction:** return errors from `NewAgent`/`Connect`, let `cmd/hp_agent` decide process exit, and simplify or replace the logger.
## Build, CI, and tooling findings
### 26. CI does not explicitly run typecheck or lint
**Severity:** Medium
**Area:** Tooling / quality gates
**Evidence:**
- `package.json` has `typecheck`, `lint`, and `format` scripts.
- `.github/workflows/build.yaml:32-37` runs `./build.sh --skip-pnpm-prune`, unit tests, and integration tests.
- `build.sh:157-173` runs `pnpm run build`, not `pnpm run typecheck` or `pnpm run lint`.
**Why it matters:** Vite/React Router builds transpile TypeScript but are not a substitute for `tsgo` typechecking. Lint-only issues can land despite local scripts existing.
**Suggested direction:** add `pnpm run typecheck` and `pnpm run lint` to CI. Consider `pnpm run format --check` if supported by oxfmt.
### 27. Build script leaves temporary `vendor/` behind if WASM build fails
**Severity:** Low/Medium
**Area:** Build hygiene
**Evidence:**
- `build.sh:143-154` runs `go mod vendor`, applies a patch, builds, then removes `vendor` only after success.
**Why it matters:** failed local builds can leave a large generated directory in the worktree, which is easy to accidentally inspect or commit around.
**Suggested direction:** add a trap around the vendoring step to remove `vendor` on failure.
## Fate/SPA/Vite+ migration assessment
### Updated direction: SPA first, not Void first
After comparing the shape of Headplane with Void's current platform/runtime direction, the better target is **not** a Void app. Headplane is a self-hosted local control-plane UI that needs a predictable Node process, local filesystem/config access, SQLite, child process/agent management, long-lived SSE, and a packaging story that can eventually compile into a Node static SEA.
The preferred target is now a one-way SPA cutover:
```text
Hono Node server + Vite SPA + TanStack Router + raw Fate
```
The important architectural choice is that **Fate becomes the data framework directly** while the app shell stays thin. The shell should own static assets, cookies/session plumbing, routing, and lifecycle. Fate should own reads, mutations/actions, normalized cache updates, and live object/list subscriptions. Do not build a Headplane-specific abstraction layer over Fate's live bus, view resolution, actions, or native HTTP handlers unless a concrete repeated problem appears after using the raw APIs.
Void may still be a useful reference implementation for Fate integration, but it should not drive Headplane's runtime architecture unless it later proves a first-class self-hosted Node mode that fits Headplane's install model.
### Current Fate fit
Fate is directly aimed at the pain Headplane is showing: declarative views, normalized cache, data masking, Async React, optimistic actions/mutations, and live views over SSE. Fate 1.0 says it now includes production-ready live views, Drizzle support, garbage collection, and native HTTP transport. The docs also still contain an alpha warning in the getting-started page, so I would treat the ecosystem as promising but still worth piloting behind a branch.
Headplane is already close on prerequisites:
- React is `19.2.5` and Fate requires React 19.2+.
- The app already uses Vite 8-era tooling, Vitest, Oxlint, Oxfmt, tsgo, and pnpm.
- Headplane already has Drizzle, but only for Headplane-local data (`users`, sessions, host info), while core tailnet data comes from the remote Headscale API.
### What Fate would improve
- Replace `LiveDataProvider` + `useRevalidator()` with object/list-level subscriptions (`useLiveView` / `useLiveListView`).
- Normalize `nodes`, `users`, pre-auth keys, and agent host info instead of passing large loader payloads around.
- Let components declare data needs near rendering rather than building large page-level loader DTOs.
- Make mutations return selected updated data and update dependent views without manual `context.hsLive.refresh(...)` calls.
### What Fate will not fix
- API-key-in-cookie session design.
- Self-linking authorization.
- Missing route/action capability checks.
- Headscale config-file write races.
- WebSSH/WASM lifecycle and SSH host-key verification.
- Go agent robustness issues.
These should be fixed before or alongside any framework migration.
### Headplane-specific adoption challenges
- **Remote API source:** Fate's Drizzle adapter helps for local DB rows, but `Machine`, `User`, `PreAuthKey`, ACL policy, and DNS/config data mostly come from Headscale REST/config files. A Headscale Fate source/adapter or custom native HTTP query layer would be needed.
- **Authorization:** Fate views must be scoped by the authenticated principal. Do not reproduce the current process-global cache or mutable API client.
- **Live events:** Headscale does not appear to push the exact object-level events Headplane needs, so Headplane may still need polling or mutation-triggered `live.update(...)` calls. The win is to send object/list updates to subscribed views, not to revalidate whole routes.
- **Server framework:** the desired end state is no longer another heavyweight metaframework. Hono is a good fit for the server shell because it uses the Fetch `Request`/`Response` model Fate already targets, while still running as a normal self-hosted Node process.
### Target runtime shape
```text
╭────────────────────────────────────────────╮
│ Node static SEA / Docker image │
│ - bundled server JS │
│ - embedded or adjacent Vite client assets │
╰───────────────────┬────────────────────────╯
╭────────────────────────────────────────────╮
│ Hono Node server │
│ - process lifecycle start/stop │
│ - static asset + SPA fallback serving │
│ - cookie/session middleware │
│ - /fate and /fate/live │
│ - /api, /events during transition │
╰───────────────────┬────────────────────────╯
╭────────────────────────────────────────────╮
│ Framework-neutral Headplane server core │
│ - auth/session/authorization │
│ - Headscale API/config adapters │
│ - agent manager │
│ - Fate live bus used directly │
╰───────────────────┬────────────────────────╯
╭────────────────────────────────────────────╮
│ Vite SPA │
│ - TanStack Router for navigation/search │
│ - raw Fate for all app data and mutations │
│ - no route loaders/actions/fetchers │
╰────────────────────────────────────────────╯
```
### Node SEA packaging implications
The SPA direction is a better fit for a Node static SEA than SSR framework mode because the runtime can become one server entry plus a finite static asset set. The server should be structured so that production can first serve assets from `build/client`, then later swap that out for an embedded asset manifest without changing application routing.
Practical constraints for the SEA target:
- keep one explicit production server entry instead of framework-generated adapter code;
- avoid dynamic runtime imports for route modules in production;
- make client assets addressable by a generated manifest rather than filesystem discovery;
- keep mutable data outside the SEA (`data_path`, Headscale config paths, SQLite, logs);
- preserve direct file serving for large WASM artifacts until we decide whether they should be embedded or adjacent assets.
### Raw Fate rule
Use Fate's public APIs directly:
- `createFateServer(...)` and the native HTTP handler for `/fate`;
- Fate's live bus directly for `live.update(...)`, `live.delete(...)`, and connection/list invalidations;
- Fate's context callback directly for request auth and Headscale API access;
- `FateClient`, `createClient` / `createHTTPTransport` while bootstrapping, then generated `createFateClient(...)` once the Fate Vite plugin has a real server module;
- `useRequest`, `useView`, `useLiveView`, `useLiveListView`, and Fate actions directly in React.
Do not add project-level wrappers such as `HeadplaneLivePublisher`, `HeadplaneDataContext`, or a custom Fate transport abstraction at the beginning. If raw Fate usage becomes repetitive, extract only the smallest local helper at the repetition site.
### Current scaffold on `tale/fate-spa`
- Removed the earlier `HeadplaneRuntime`, `HeadplaneDataContext`, and `HeadplaneLivePublisher` scaffolding.
- Added direct dependencies: `react-fate`, `@nkzw/fate`, `@tanstack/react-router`, `@tanstack/router-plugin`, and `@vitejs/plugin-react`.
- Added a plain Vite SPA `index.html` and `app/spa` entry with TanStack Router and the generated raw Fate client from `react-fate/client`.
- Moved the old React Router Vite config to `vite-old.config.ts` and replaced `vite.config.ts` with a clean SPA config.
- Added Hono and `@hono/node-server`, plus a minimal Hono server shell in `app/server/hono-app.ts`, `app/server/hono-dev.ts`, and `app/server/hono-main.ts`.
- Added `app/server/fate.ts`, which exports a raw Fate server and live bus. Hono mounts Fate's `createHonoFateHandler(fate)` at `/admin/fate` and `/admin/fate/*`, passing the existing app context through Hono variables.
- Wired the official `react-fate/vite` plugin to `app/server/fate.ts`, ignored generated `.fate/` output, and made `pnpm run typecheck` run `fate generate` before `tsgo` so generated `react-fate/client` typings exist from a clean checkout.
- Added the first real Fate read roots: `machines` and `users`. They use Fate `dataView(...)`, `list(...)`, and source executors directly, call the existing principal-scoped Headscale runtime API client, and enforce existing `read_machines` / `read_users` capabilities.
- Added minimal SPA `/machines` and `/users` routes that fetch with raw `useRequest(...)`, render records with `useLiveView(...)`, and subscribe to root list connections with `useLiveListView(...)`. These routes intentionally do not port filters, actions, or optimistic updates yet.
- Bridged existing `hsLive` resource changes directly to Fate connection invalidations: `nodes` invalidates the `machines` root connection and `users` invalidates the `users` root connection. This is a temporary seam so converted routes can exercise Fate live primitives before the old live store is deleted.
- Added the first raw Fate mutation, `machine.rename`. It reuses existing `canManageNode` authorization, calls the Headscale API, refreshes the transitional `hsLive` nodes resource, emits direct Fate entity/list live events, and returns the client-selected `Machine` view.
- `pnpm dev` now runs the Hono/Vite middleware shell with local `.data` storage for the example config; `pnpm build` now runs `vite build` for the SPA.
- Fate's Drizzle peer currently warns against the repo's Drizzle `1.0.0-beta.21`; avoid Fate's Drizzle adapter until that compatibility is resolved, and start with a direct Headscale source/resolver instead.
### Fate context decision
The current Fate request context should stay pragmatic rather than heavily decomposed:
- expose `api`, the principal-scoped Headscale runtime client, as the primary data access path for remote Headscale data;
- keep `principal` and `request` available for authorization and future audit/session needs;
- keep `app` available during the migration so resolvers can reuse the existing auth/config/agent services without inventing a new service layer first;
- do not pass an unstructured context into every helper by default once a data domain settles. If a `machines`, `users`, or `authKeys` module becomes large, give that module explicit functions that accept the concrete pieces it uses.
In other words: full app context is acceptable as migration scaffolding, but the resolver code should prefer the smallest direct dependency (`ctx.api`, `ctx.app.auth`, etc.) and should not become a new `HeadplaneRuntime` abstraction.
### Recommended migration sequence
1. **Remove transitional abstractions** and make the branch clearly one-way toward SPA + raw Fate.
2. **Install the direct dependencies**: `react-fate`, `@nkzw/fate`, `@tanstack/react-router`, `@tanstack/router-plugin`, and the plain Vite React plugin if the React Router plugin is removed.
3. **Replace the build/dev entry shape**:
- add a Vite SPA entry and TanStack route tree;
- stop generating new React Router route types;
- keep the existing Node server entry as the self-hosted process.
4. **Mount raw Fate endpoints** in the current Node request path:
- `/fate` for native RPC;
- `/fate/live` for SSE and subscription control;
- request context resolves auth using the existing auth service and calls `context.hsApi.getRuntimeClient(...)` directly.
5. **Convert the machines page first** using raw Fate views and live list/view hooks.
6. **Add live updates to the converted lists** by publishing Fate list/entity invalidations from the existing polling/mutation seams. Keep this raw Fate live bus usage, not a Headplane live wrapper.
7. **Port one mutation at a time**, starting with a low-risk machine mutation such as rename. The mutation should call the existing Headscale API, return the selected entity, and emit the relevant Fate live event.
8. **Delete the old React Router loader/action/SSE path for converted data**, rather than running duplicate data models side-by-side.
9. **Fix critical auth/session issues early**: self-linking, raw API-key cookie, agent-route authz.
10. **Keep runtime changes minimal** until the SPA actually needs them: Hono routes, static SPA fallback, Fate routes, and later SEA asset serving.
### Vite+ / VoidZero tooling assessment
Vite+ is a unified CLI (`vp`) for Vite, Vitest, Oxlint, Oxfmt, Rolldown, tsdown, type checking, package-manager/runtime management, and task caching. Headplane already uses most of these tools separately, so Vite+ would mostly consolidate tooling and improve task ergonomics; it will not solve the data-layer problems by itself.
Recommended Vite+ approach:
- Try `vp migrate` in a separate branch only after adding CI typecheck/lint gates.
- Expect manual work around the custom React Router SSR entry and `runtime/vite-plugin.ts`.
- Do not combine Vite+ migration with Fate/Void/router migration in the same PR.
## Suggested immediate backlog
1. Replace the temporary `hsLive` bridge with direct Fate events from converted mutations and, if needed, a principal-safe polling source.
2. Move the machine rename UI out of the throwaway table row controls once the permanent SPA machines page layout exists.
3. Port the next machine mutations: expire/delete/tags/routes, one at a time, each returning selected data or deleting/updating the normalized cache explicitly.
4. Fix OIDC self-linking authorization.
5. Make API-key sessions opaque/server-side and set explicit auth cookie flags.
6. Add capability checks to `/settings/agent` loader/action.
7. Fix live-data pause cleanup/refcounting for unconverted React Router routes.
8. Make `hsLive` use a stable server credential or principal-scoped cache while it still exists.
9. Serialize config patches with a real mutex/queue and clone config arrays before editing.
10. Add CI `pnpm run typecheck` and `pnpm run lint`.
11. Add WebSSH top-level dispose and release Go `js.Func` values.
-7
View File
@@ -57,11 +57,6 @@ COPY --from=go-base /bin/hp_healthcheck /bin/hp_healthcheck
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD ["/bin/hp_healthcheck"]
# Tells Headplane to publish its loopback healthcheck URL to this
# file on startup; `hp_healthcheck` reads it. Docker-only — native
# installs don't ship a consumer.
ENV HEADPLANE_LISTEN_FILE=/tmp/headplane-listen
WORKDIR /app
CMD [ "/app/build/server/index.js" ]
@@ -78,7 +73,5 @@ COPY --from=go-base /bin/hp_healthcheck /bin/hp_healthcheck
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD ["/bin/hp_healthcheck"]
ENV HEADPLANE_LISTEN_FILE=/tmp/headplane-listen
WORKDIR /app
CMD [ "node", "/app/build/server/index.js" ]
+3 -10
View File
@@ -58,8 +58,7 @@ function Panel(props: DialogPanelProps) {
return (
<AlertDialog.Popup
className={cn(
"flex w-full max-w-lg flex-col rounded-xl p-4",
"max-h-[90dvh]",
"w-full max-w-lg rounded-xl p-4",
"outline-hidden",
"bg-white dark:bg-mist-900",
"border border-mist-200 dark:border-mist-800",
@@ -68,7 +67,6 @@ function Panel(props: DialogPanelProps) {
>
<Form
method={method ?? "POST"}
className="flex min-h-0 flex-1 flex-col"
onSubmit={(event) => {
if (onSubmit) {
onSubmit(event);
@@ -79,13 +77,8 @@ function Panel(props: DialogPanelProps) {
}
}}
>
{/* px-1 -mx-1 gives focus rings on inputs/buttons room to render
without being clipped by overflow-y-auto (which implicitly forces
overflow-x: auto per CSS spec). */}
<div className="-mx-1 flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-1">
{children}
</div>
<div className="mt-5 flex shrink-0 justify-end gap-3">
<div className="flex flex-col gap-4">{children}</div>
<div className="mt-5 flex justify-end gap-3">
{variant === "unactionable" ? (
<AlertDialog.Close render={<Button>Close</Button>} />
) : (
+1 -1
View File
@@ -24,7 +24,7 @@ export default function Tooltip({ children, content, className }: TooltipProps)
{children}
</BaseTooltip.Trigger>
<BaseTooltip.Portal>
<BaseTooltip.Positioner side="top" sideOffset={4} collisionPadding={8}>
<BaseTooltip.Positioner sideOffset={4}>
<BaseTooltip.Popup
className={cn(
"z-50 rounded-lg p-3 text-sm w-48",
+6 -3
View File
@@ -31,7 +31,10 @@ export const shouldRevalidate: ShouldRevalidateFunction = ({
export async function loader({ request, context }: Route.LoaderArgs) {
try {
const { principal, api } = await context.apiForRequest(request);
const principal = await context.auth.require(request);
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
const user =
principal.kind === "oidc"
@@ -45,10 +48,10 @@ export async function loader({ request, context }: Route.LoaderArgs) {
: { name: principal.displayName, subject: "api_key" };
// MARK: The session should stay valid if Headscale isn't healthy
const isHealthy = await context.headscale.health();
const isHealthy = await api.isHealthy();
if (isHealthy) {
try {
await api.apiKeys.list();
await api.getApiKeys();
} catch (error) {
if (isDataUnauthorizedError(error)) {
const displayName =
+6
View File
@@ -0,0 +1,6 @@
{
"0.26.1": ["0.26.0", "0.26.1"],
"0.27.0": ["0.27.0"],
"0.27.1": ["0.27.1"],
"0.28.0": ["0.28.0"]
}
+135
View File
@@ -0,0 +1,135 @@
{
"0.26.0": {
"GET /api/v1/apikey": "efe31b6dc980e158",
"POST /api/v1/apikey": "39953a96c1da5312",
"POST /api/v1/apikey/expire": "ca56add866802f17",
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
"GET /api/v1/node": "8bb18b8c7cfb4f20",
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
"POST /api/v1/node/register": "539f7cb3a84d43d4",
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
"POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784",
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
"GET /api/v1/policy": "d6c639be304cd3c0",
"PUT /api/v1/policy": "6cbe80bde771a388",
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
"GET /api/v1/user": "228831b58ccc5a17",
"POST /api/v1/user": "a4e1d889d7962da5",
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
},
"0.26.1": {
"GET /api/v1/apikey": "efe31b6dc980e158",
"POST /api/v1/apikey": "39953a96c1da5312",
"POST /api/v1/apikey/expire": "ca56add866802f17",
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
"GET /api/v1/node": "8bb18b8c7cfb4f20",
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
"POST /api/v1/node/register": "539f7cb3a84d43d4",
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
"POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784",
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
"GET /api/v1/policy": "d6c639be304cd3c0",
"PUT /api/v1/policy": "6cbe80bde771a388",
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
"GET /api/v1/user": "228831b58ccc5a17",
"POST /api/v1/user": "a4e1d889d7962da5",
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
},
"0.27.0": {
"GET /api/v1/apikey": "efe31b6dc980e158",
"POST /api/v1/apikey": "39953a96c1da5312",
"POST /api/v1/apikey/expire": "ca56add866802f17",
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
"GET /api/v1/health": "5e447272e72b2e5f",
"GET /api/v1/node": "8bb18b8c7cfb4f20",
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
"POST /api/v1/node/register": "539f7cb3a84d43d4",
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
"POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784",
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
"GET /api/v1/policy": "d6c639be304cd3c0",
"PUT /api/v1/policy": "6cbe80bde771a388",
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
"GET /api/v1/user": "228831b58ccc5a17",
"POST /api/v1/user": "a4e1d889d7962da5",
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
},
"0.27.1": {
"GET /api/v1/apikey": "efe31b6dc980e158",
"POST /api/v1/apikey": "39953a96c1da5312",
"POST /api/v1/apikey/expire": "ca56add866802f17",
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
"GET /api/v1/health": "5e447272e72b2e5f",
"GET /api/v1/node": "8bb18b8c7cfb4f20",
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
"POST /api/v1/node/register": "539f7cb3a84d43d4",
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
"POST /api/v1/node/{nodeId}/expire": "53efc8e2017c16ae",
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
"GET /api/v1/policy": "d6c639be304cd3c0",
"PUT /api/v1/policy": "6cbe80bde771a388",
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
"GET /api/v1/user": "228831b58ccc5a17",
"POST /api/v1/user": "a4e1d889d7962da5",
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
},
"0.28.0": {
"GET /api/v1/apikey": "efe31b6dc980e158",
"POST /api/v1/apikey": "39953a96c1da5312",
"POST /api/v1/apikey/expire": "ca56add866802f17",
"DELETE /api/v1/apikey/{prefix}": "b10ca7d2750405b2",
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
"GET /api/v1/health": "5e447272e72b2e5f",
"GET /api/v1/node": "8bb18b8c7cfb4f20",
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
"POST /api/v1/node/register": "539f7cb3a84d43d4",
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
"POST /api/v1/node/{nodeId}/expire": "53efc8e2017c16ae",
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
"GET /api/v1/policy": "d6c639be304cd3c0",
"PUT /api/v1/policy": "6cbe80bde771a388",
"GET /api/v1/preauthkey": "8428b44e3a821e9e",
"DELETE /api/v1/preauthkey": "f05ea1bc8ad89a09",
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
"GET /api/v1/user": "228831b58ccc5a17",
"POST /api/v1/user": "a4e1d889d7962da5",
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
}
}
+65 -22
View File
@@ -26,9 +26,10 @@ export async function aclAction({ request, context }: Route.ActionArgs) {
});
}
const { api } = await context.apiForRequest(request);
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
try {
const { policy, updatedAt } = await api.policy.set(policyData);
const { policy, updatedAt } = await api.setPolicy(policyData);
return data({
success: true,
error: undefined,
@@ -54,29 +55,71 @@ export async function aclAction({ request, context }: Route.ActionArgs) {
throw error;
}
// Policy parse errors carry one of two prefixes (HuJSON syntax vs.
// structural unmarshal). Headscale 0.27.0+ uses these forms; older
// releases are no longer supported.
if (message.includes("parsing HuJSON:")) {
const cutIndex = message.indexOf("parsing HuJSON:");
const trimmed =
cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 16).trim()}` : message;
// Starting in Headscale 0.27.0 the ACLs parsing was changed meaning
// we need to reference other error messages based on API version.
if (context.hsApi.clientHelpers.isAtleast("0.27.0")) {
if (message.includes("parsing HuJSON:")) {
const cutIndex = message.indexOf("parsing HuJSON:");
const trimmed =
cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 16).trim()}` : message;
return data(
{ success: false, error: trimmed, policy: undefined, updatedAt: undefined },
400,
);
}
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
if (message.includes("parsing policy from bytes:")) {
const cutIndex = message.indexOf("parsing policy from bytes:");
const trimmed =
cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 26).trim()}` : message;
if (message.includes("parsing policy from bytes:")) {
const cutIndex = message.indexOf("parsing policy from bytes:");
const trimmed =
cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 26).trim()}` : message;
return data(
{ success: false, error: trimmed, policy: undefined, updatedAt: undefined },
400,
);
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
} else {
// Pre-0.27.0 error messages
if (message.includes("parsing hujson")) {
const cutIndex = message.indexOf("err: hujson:");
const trimmed = cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 12)}` : message;
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
if (message.includes("unmarshalling policy")) {
const cutIndex = message.indexOf("err:");
const trimmed = cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 5)}` : message;
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
}
}
+3 -2
View File
@@ -29,9 +29,10 @@ export async function aclLoader({ request, context }: Route.LoaderArgs) {
};
// Try to load the ACL policy from the API.
const { api } = await context.apiForRequest(request);
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
try {
const { policy, updatedAt } = await api.policy.get();
const { policy, updatedAt } = await api.getPolicy();
flags.writable = updatedAt !== null;
flags.policy = policy;
return flags;
+2 -4
View File
@@ -33,11 +33,9 @@ export async function loginAction({ request, context }: Route.LoaderArgs) {
};
}
// Build a client with the candidate API key the user just submitted, so the
// GET /api/v1/apikey call below validates the key against Headscale itself.
const api = context.headscale.client(apiKey);
const api = context.hsApi.getRuntimeClient(apiKey);
try {
const apiKeys = await api.apiKeys.list();
const apiKeys = await api.getApiKeys();
// We don't need to check for 0 API keys because this request cannot
// be authenticated correctly without an API key
+2 -7
View File
@@ -24,7 +24,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
const qp = new URL(request.url).searchParams;
const urlState = qp.get("s") ?? undefined;
const oidcService = context.oidc.state === "enabled" ? context.oidc.value : undefined;
const oidcService = context.oidc?.service;
const oidcStatus = oidcService
? await oidcService.discover().then(
(r) => (r.ok ? oidcService.status() : oidcService.status()),
@@ -32,12 +32,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
)
: undefined;
if (
oidcService &&
context.config.oidc?.disable_api_key_login &&
oidcStatus?.state === "ready" &&
urlState !== "logout"
) {
if (context.oidc?.disableApiKeyLogin && oidcStatus?.state === "ready" && urlState !== "logout") {
return redirect("/oidc/start");
}
+4 -9
View File
@@ -23,20 +23,15 @@ export async function action({ request, context }: ActionFunctionArgs<AppContext
// ended. Disabled by default because the post_logout_redirect_uri must be
// pre-registered on the IdP — turning this on without registering it would
// strand users on the IdP's error page.
if (
principal?.kind === "oidc" &&
context.oidc.state === "enabled" &&
context.config.oidc?.use_end_session
) {
const service = context.oidc.value;
const status = service.status();
if (principal?.kind === "oidc" && context.oidc?.useEndSession && context.oidc.service) {
const status = context.oidc.service.status();
if (status.state !== "ready") {
// Trigger discovery if it hasn't happened yet so we can find the
// end_session_endpoint without forcing a re-login.
await service.discover();
await context.oidc.service.discover();
}
const endSessionUrl = service.buildEndSessionUrl(principal.idToken);
const endSessionUrl = context.oidc.service.buildEndSessionUrl(principal.idToken);
if (endSessionUrl) {
url = endSessionUrl;
}
+6 -9
View File
@@ -7,10 +7,10 @@ import { createOidcStateCookie } from "~/utils/oidc-state";
import type { Route } from "./+types/oidc-callback";
export async function loader({ request, context }: Route.LoaderArgs) {
if (context.oidc.state !== "enabled") {
throw data(`OIDC is unavailable: ${context.oidc.reason}`, { status: 501 });
const service = context.oidc?.service;
if (!service) {
throw data("OIDC is not enabled or misconfigured", { status: 501 });
}
const service = context.oidc.value;
const url = new URL(request.url);
if (url.searchParams.toString().length === 0) {
@@ -56,11 +56,8 @@ export async function loader({ request, context }: Route.LoaderArgs) {
});
try {
// Looks up the Headscale user that matches this OIDC identity. We use
// the configured admin API key here — not a per-request one — because
// there is no per-request key yet (the session is being created).
const hsApi = context.headscale.client(context.headscaleApiKey!);
const hsUsers = await hsApi.users.list();
const hsApi = context.hsApi.getRuntimeClient(context.headscaleApiKey!);
const hsUsers = await hsApi.getUsers();
const hsUser = findHeadscaleUserBySubject(hsUsers, identity.subject, identity.email);
if (hsUser) {
await context.auth.linkHeadscaleUser(userId, hsUser.id);
@@ -71,7 +68,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
// Only persist the id_token when RP-initiated logout is enabled — otherwise
// we'd be storing a credential we never use.
const idToken = context.config.oidc?.use_end_session ? identity.idToken : undefined;
const idToken = context.oidc?.useEndSession ? identity.idToken : undefined;
return redirect("/", {
headers: {
+3 -3
View File
@@ -10,10 +10,10 @@ export async function loader({ request, context }: Route.LoaderArgs) {
return redirect("/");
} catch {}
if (context.oidc.state !== "enabled") {
throw data(`OIDC is unavailable: ${context.oidc.reason}`, { status: 501 });
const service = context.oidc?.service;
if (!service) {
throw data("OIDC is not enabled or misconfigured", { status: 501 });
}
const service = context.oidc.value;
const result = await service.startFlow();
if (!result.ok) {
+12 -9
View File
@@ -16,6 +16,9 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
return data({ success: false }, 403);
}
// We only need it for health checks which don't require auth
const api = context.hsApi.getRuntimeClient("fake-api-key");
const formData = await request.formData();
const action = formData.get("action_id")?.toString();
if (!action) {
@@ -36,7 +39,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
},
]);
await context.integration?.onConfigChange(context.headscale);
await context.integration?.onConfigChange(api);
return { message: "Tailnet renamed successfully" };
}
case "toggle_magic": {
@@ -52,7 +55,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
},
]);
await context.integration?.onConfigChange(context.headscale);
await context.integration?.onConfigChange(api);
return { message: "Magic DNS state updated successfully" };
}
case "remove_ns": {
@@ -85,7 +88,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
]);
}
await context.integration?.onConfigChange(context.headscale);
await context.integration?.onConfigChange(api);
return { message: "Nameserver removed successfully" };
}
case "add_ns": {
@@ -120,7 +123,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
]);
}
await context.integration?.onConfigChange(context.headscale);
await context.integration?.onConfigChange(api);
return { message: "Nameserver added successfully" };
}
case "remove_domain": {
@@ -138,7 +141,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
},
]);
await context.integration?.onConfigChange(context.headscale);
await context.integration?.onConfigChange(api);
return { message: "Domain removed successfully" };
}
case "add_domain": {
@@ -158,7 +161,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
},
]);
await context.integration?.onConfigChange(context.headscale);
await context.integration?.onConfigChange(api);
return { message: "Domain added successfully" };
}
case "remove_record": {
@@ -180,7 +183,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
return;
}
await context.integration?.onConfigChange(context.headscale);
await context.integration?.onConfigChange(api);
return { message: "DNS record removed successfully" };
}
case "add_record": {
@@ -202,7 +205,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
return;
}
await context.integration?.onConfigChange(context.headscale);
await context.integration?.onConfigChange(api);
return { message: "DNS record added successfully" };
}
case "override_dns": {
@@ -219,7 +222,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
},
]);
await context.integration?.onConfigChange(context.headscale);
await context.integration?.onConfigChange(api);
return { message: "DNS override updated successfully" };
}
default:
+5 -3
View File
@@ -24,11 +24,12 @@ export async function loader({ request, context }: Route.LoaderArgs) {
// Unclaimed users they can pick from before anything else.
let unlinked = false;
if (
context.oidc.state === "enabled" &&
typeof context.oidc === "object" &&
principal.kind === "oidc" &&
!principal.user.headscaleUserId
) {
const { api } = await context.apiForRequest(request);
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
let headscaleUsers: { id: string; name: string }[] = [];
try {
@@ -63,7 +64,8 @@ export async function loader({ request, context }: Route.LoaderArgs) {
}
// No UI access — show the download/connect page
const { api } = await context.apiForRequest(request);
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
let linkedUserName: string | undefined;
if (principal.kind === "oidc" && principal.user.headscaleUserId) {
+1 -3
View File
@@ -52,9 +52,7 @@ export default function NewMachine(data: NewMachineProps) {
onValueChange={(v) => form.setValue("user", v)}
placeholder="Select a user"
items={data.users.map((user) => ({
// Headscale's v1/node/register endpoint resolves the owner by
// username via GetUserByName, so we must pass user.name (not id).
value: user.name,
value: user.id,
label: getUserDisplayName(user),
}))}
/>
+10 -14
View File
@@ -7,9 +7,10 @@ import { Capabilities } from "~/server/web/roles";
import type { Route } from "./+types/machine";
export async function machineAction({ request, context }: Route.ActionArgs) {
const { principal, api } = await context.apiForRequest(request);
const principal = await context.auth.require(request);
const formData = await request.formData();
const api = context.hsApi.getRuntimeClient(context.auth.getHeadscaleApiKey(principal));
const action = formData.get("action_id")?.toString();
if (!action) {
@@ -40,7 +41,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
});
}
const node = await api.nodes.register(user, registrationKey);
const node = await api.registerNode(user, registrationKey);
await context.hsLive.refresh(nodesResource, api);
return redirect(`/machines/${node.id}`);
}
@@ -53,7 +54,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
});
}
const node = await api.nodes.get(nodeId);
const node = await api.getNode(nodeId);
if (!node) {
throw data(`Machine with ID ${nodeId} not found`, {
status: 404,
@@ -76,19 +77,19 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
}
const name = String(formData.get("name"));
await api.nodes.rename(nodeId, name);
await api.renameNode(nodeId, name);
await context.hsLive.refresh(nodesResource, api);
return { message: "Machine renamed" };
}
case "delete": {
await api.nodes.delete(nodeId);
await api.deleteNode(nodeId);
await context.hsLive.refresh(nodesResource, api);
return redirect("/machines");
}
case "expire": {
await api.nodes.expire(nodeId);
await api.expireNode(nodeId);
await context.hsLive.refresh(nodesResource, api);
return { message: "Machine expired" };
}
@@ -102,7 +103,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
}
try {
await api.nodes.setTags(
await api.setNodeTags(
nodeId,
tags.map((tag) => tag.trim()).filter((tag) => tag !== ""),
);
@@ -171,7 +172,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
}
}
await api.nodes.approveRoutes(nodeId, newApproved);
await api.approveNodeRoutes(nodeId, newApproved);
await context.hsLive.refresh(nodesResource, api);
return { message: "Routes updated" };
}
@@ -184,12 +185,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
});
}
if (!api.nodes.reassignUser) {
throw data("Reassigning a node owner is no longer supported on this Headscale version.", {
status: 400,
});
}
await api.nodes.reassignUser(nodeId, user);
await api.setNodeUser(nodeId, user);
await context.hsLive.refresh(nodesResource, api);
return { message: "Machine reassigned" };
}
+6 -6
View File
@@ -22,6 +22,7 @@ import Routes from "./dialogs/routes";
import { machineAction } from "./machine-actions";
export async function loader({ request, params, context }: Route.LoaderArgs) {
const principal = await context.auth.require(request);
if (!params.id) {
throw new Error("No machine ID provided");
}
@@ -37,7 +38,7 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
}
}
const { api } = await context.apiForRequest(request);
const api = context.hsApi.getRuntimeClient(context.auth.getHeadscaleApiKey(principal));
const [nodesSnap, usersSnap] = await Promise.all([
context.hsLive.get(nodesResource, api),
context.hsLive.get(usersResource, api),
@@ -49,19 +50,18 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
throw data(null, { status: 404 });
}
const agents = context.agents.state === "enabled" ? context.agents.value : undefined;
const lookup = await agents?.lookup([node.nodeKey]);
const lookup = await context.agents?.lookup([node.nodeKey]);
const [enhancedNode] = mapNodes([node], lookup);
const tags = [...node.tags].toSorted();
const supportsNodeOwnerChange = !context.headscale.capabilities.nodeOwnerIsImmutable;
const agentSync = agents?.lastSync();
const supportsNodeOwnerChange = !context.hsApi.clientHelpers.isAtleast("0.28.0");
const agentSync = context.agents?.lastSync();
return {
agent: agentSync
? {
syncedAt: agentSync.syncedAt?.toISOString() ?? null,
nodeCount: agentSync.nodeCount,
nodeKey: agents?.agentNodeKey(),
nodeKey: context.agents?.agentNodeKey(),
}
: undefined,
existingTags: sortNodeTags(nodes),
+5 -6
View File
@@ -30,7 +30,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
const writablePermission = context.auth.can(principal, Capabilities.write_machines);
const { api } = await context.apiForRequest(request);
const api = context.hsApi.getRuntimeClient(context.auth.getHeadscaleApiKey(principal));
const [nodesSnap, usersSnap] = await Promise.all([
context.hsLive.get(nodesResource, api),
context.hsLive.get(usersResource, api),
@@ -45,18 +45,17 @@ export async function loader({ request, context }: Route.LoaderArgs) {
}
}
const agents = context.agents.state === "enabled" ? context.agents.value : undefined;
const stats = await agents?.lookup(nodes.map((node) => node.nodeKey));
const stats = await context.agents?.lookup(nodes.map((node) => node.nodeKey));
const populatedNodes = mapNodes(nodes, stats);
const supportsNodeOwnerChange = !context.headscale.capabilities.nodeOwnerIsImmutable;
const agentSync = agents?.lastSync();
const supportsNodeOwnerChange = !context.hsApi.clientHelpers.isAtleast("0.28.0");
const agentSync = context.agents?.lastSync();
return {
agent: agentSync
? {
syncedAt: agentSync.syncedAt?.toISOString() ?? null,
nodeCount: agentSync.nodeCount,
nodeKey: agents?.agentNodeKey(),
nodeKey: context.agents?.agentNodeKey(),
}
: undefined,
headscaleUserId: principal.kind === "oidc" ? principal.user.headscaleUserId : undefined,
+9 -9
View File
@@ -11,13 +11,13 @@ import { formatTimeDelta } from "~/utils/time";
import type { Route } from "./+types/agent";
export async function loader({ request, context }: Route.LoaderArgs) {
await context.auth.require(request);
const principal = await context.auth.require(request);
if (context.agents.state !== "enabled") {
return { enabled: false as const, reason: context.agents.reason };
if (!context.agents) {
return { enabled: false as const };
}
const sync = context.agents.value.lastSync();
const sync = context.agents.lastSync();
return {
enabled: true as const,
syncedAt: sync.syncedAt?.toISOString() ?? null,
@@ -29,12 +29,12 @@ export async function loader({ request, context }: Route.LoaderArgs) {
export async function action({ request, context }: Route.ActionArgs) {
await context.auth.require(request);
if (context.agents.state !== "enabled") {
return { success: false, error: context.agents.reason };
if (!context.agents) {
return { success: false, error: "Agent is not enabled" };
}
await context.agents.value.triggerSync();
const sync = context.agents.value.lastSync();
await context.agents.triggerSync();
const sync = context.agents.lastSync();
return { success: !sync.error, error: sync.error };
}
@@ -47,7 +47,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
<div className="flex max-w-(--breakpoint-lg) flex-col gap-8">
<Title>Headplane Agent</Title>
<Notice title="Agent Not Enabled">
{loaderData.reason}. To learn how to set up the agent, visit the{" "}
The Headplane Agent is not enabled. To learn how to set up the agent, visit the{" "}
<Link external styled to="https://headplane.dev/docs/agent">
documentation
</Link>
+11 -17
View File
@@ -7,7 +7,9 @@ import type { PreAuthKey } from "~/types";
import type { Route } from "./+types/overview";
export async function authKeysAction({ request, context }: Route.ActionArgs) {
const { principal, api } = await context.apiForRequest(request);
const principal = await context.auth.require(request);
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
const canGenerateAny = context.auth.can(principal, Capabilities.generate_authkeys);
const canGenerateOwn = context.auth.can(principal, Capabilities.generate_own_authkeys);
@@ -20,7 +22,7 @@ export async function authKeysAction({ request, context }: Route.ActionArgs) {
async function checkSelfServiceOwnership(userId: string) {
if (canGenerateAny || !canGenerateOwn) return;
const [targetUser] = await api.users.list({ id: userId });
const [targetUser] = await api.getUsers(userId);
if (!targetUser) {
throw data("User not found.", { status: 404 });
}
@@ -84,13 +86,13 @@ export async function authKeysAction({ request, context }: Route.ActionArgs) {
const date = new Date();
date.setDate(date.getDate() + day);
const key = await api.preAuthKeys.create({
const key = await api.createPreAuthKey(
user,
ephemeral: ephemeral === "on",
reusable: reusable === "on",
expiration: date,
aclTags: aclTags.length > 0 ? aclTags : null,
});
ephemeral === "on",
reusable === "on",
date,
aclTags.length > 0 ? aclTags : null,
);
return data({ success: true as const, key: key.key });
}
@@ -112,15 +114,7 @@ export async function authKeysAction({ request, context }: Route.ActionArgs) {
}
await checkSelfServiceOwnership(user);
// `user` here is the Headscale numeric user id (form field is wired
// from User.id). Pre-0.28 expire posts a uint64 `user` field, which
// the API layer reads from `key.user?.id`. Headscale 0.28+ only
// looks at `key.id` (the stable preauthkey id).
await api.preAuthKeys.expire({
id: keyId,
key,
user: { id: user },
} as unknown as PreAuthKey);
await api.expirePreAuthKey(user, { id: keyId, key } as unknown as PreAuthKey);
return data("Pre-auth key expired");
}
+8 -8
View File
@@ -19,7 +19,9 @@ import AuthKeyRow from "./auth-key-row";
import AddAuthKey from "./dialogs/add-auth-key";
export async function loader({ request, context }: Route.LoaderArgs) {
const { principal, api } = await context.apiForRequest(request);
const principal = await context.auth.require(request);
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
const usersSnap = await context.hsLive.get(usersResource, api);
const users = usersSnap.data;
@@ -29,12 +31,10 @@ export async function loader({ request, context }: Route.LoaderArgs) {
// Try fetching all keys at once (Headscale 0.28+), fall back to per-user
let allKeys: PreAuthKey[] | null = null;
if (api.preAuthKeys.listAll) {
try {
allKeys = await api.preAuthKeys.listAll();
} catch {
// Treat any failure as "no global list available" and fall through.
}
try {
allKeys = await api.getAllPreAuthKeys();
} catch {
// Older versions don't support this endpoint
}
if (allKeys !== null) {
@@ -67,7 +67,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
.filter((u) => u.id?.length > 0)
.map(async (user) => {
try {
const preAuthKeys = await api.preAuthKeys.listForUser(user.id);
const preAuthKeys = await api.getPreAuthKeys(user.id);
return { preAuthKeys, success: true as const, user };
} catch (error) {
log.error("api", "GET /v1/preauthkey for %s: %o", user.name, error);
+1 -2
View File
@@ -8,8 +8,7 @@ import type { Route } from "./+types/overview";
export async function loader({ context }: Route.LoaderArgs) {
return {
config: context.hs.writable(),
isOidcEnabled:
context.oidc.state === "enabled" && context.oidc.value.status().state === "ready",
isOidcEnabled: context.oidc?.service.status().state === "ready",
};
}
+8 -6
View File
@@ -28,6 +28,8 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
});
}
// We only need healthchecks which don't rely on an API key
const api = context.hsApi.getRuntimeClient("fake-api-key");
switch (action) {
case "add_domain": {
const domain = formData.get("domain")?.toString()?.trim();
@@ -46,7 +48,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
},
]);
context.integration?.onConfigChange(context.headscale);
context.integration?.onConfigChange(api);
return data("Domain added successfully.");
}
@@ -74,7 +76,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
value: domains,
},
]);
context.integration?.onConfigChange(context.headscale);
context.integration?.onConfigChange(api);
return data("Domain removed successfully.");
}
@@ -95,7 +97,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
},
]);
context.integration?.onConfigChange(context.headscale);
context.integration?.onConfigChange(api);
return data("Group added successfully.");
}
@@ -124,7 +126,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
},
]);
context.integration?.onConfigChange(context.headscale);
context.integration?.onConfigChange(api);
return data("Group removed successfully.");
}
@@ -145,7 +147,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
},
]);
context.integration?.onConfigChange(context.headscale);
context.integration?.onConfigChange(api);
return data("User added successfully.");
}
@@ -174,7 +176,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
},
]);
context.integration?.onConfigChange(context.headscale);
context.integration?.onConfigChange(api);
return data("User removed successfully.");
}
+14 -11
View File
@@ -37,19 +37,22 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
throw data(sshErrors.wasm_missing, 405);
}
if (context.agents.state !== "enabled") {
if (context.agents == null) {
throw data(sshErrors.agent_required, 400);
}
const { principal, api } = await context.apiForRequest(request);
const principal = await context.auth.require(request);
if (principal.kind === "api_key") {
throw data(sshErrors.oidc_required, 403);
}
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
const hostname = params.id;
const username = new URL(request.url).searchParams.get("user") || undefined;
const nodes = await api.nodes.list();
const nodes = await api.getNodes();
const node = nodes.find((n) => n.givenName === hostname);
if (!node) {
throw data(sshErrors.node_not_found(hostname), 404);
@@ -64,20 +67,20 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
}
// The user must exist within Headscale to generate a pre-auth key
const users = await api.users.list();
const users = await api.getUsers();
const hsUser = findHeadscaleUserBySubject(users, principal.user.subject, principal.profile.email);
if (!hsUser) {
throw data(sshErrors.user_not_linked, 404);
}
const preAuthKey = await api.preAuthKeys.create({
user: hsUser.id,
ephemeral: true,
reusable: false,
expiration: new Date(Date.now() + 60 * 1000), // 1 minute expiry
aclTags: null,
});
const preAuthKey = await api.createPreAuthKey(
hsUser.id,
true,
false,
new Date(Date.now() + 60 * 1000), // 1 minute expiry
null,
);
const controlURL = context.config.headscale.public_url ?? context.config.headscale.url;
return {
@@ -1,58 +0,0 @@
import { Ellipsis } from "lucide-react";
import { useState } from "react";
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "~/components/menu";
import Delete from "../dialogs/delete-user";
import Rename from "../dialogs/rename-user";
import type { UnlinkedHeadscaleUser } from "../overview";
interface HeadscaleUserMenuProps {
user: UnlinkedHeadscaleUser;
}
type Modal = "rename" | "delete" | null;
export default function HeadscaleUserMenu({ user }: HeadscaleUserMenuProps) {
const [modal, setModal] = useState<Modal>(null);
// Headscale-managed OIDC users cannot be renamed via the API.
const canRename = user.provider !== "oidc";
return (
<>
{modal === "rename" && canRename && (
<Rename
isOpen={modal === "rename"}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
user={user}
/>
)}
{modal === "delete" && (
<Delete
isOpen={modal === "delete"}
machines={user.machines}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
user={user}
/>
)}
<Menu>
<MenuTrigger className="w-10 rounded-full bg-transparent p-1 py-0.5 hover:bg-mist-100 dark:hover:bg-mist-800">
<Ellipsis className="h-5" />
</MenuTrigger>
<MenuContent>
{canRename && <MenuItem onClick={() => setModal("rename")}>Rename</MenuItem>}
{canRename && <MenuSeparator />}
<MenuItem variant="danger" onClick={() => setModal("delete")}>
Delete
</MenuItem>
</MenuContent>
</Menu>
</>
);
}
@@ -4,14 +4,12 @@ import StatusCircle from "~/components/status-circle";
import cn from "~/utils/cn";
import type { UnlinkedHeadscaleUser } from "../overview";
import HeadscaleUserMenu from "./headscale-user-menu";
interface HeadscaleUserRowProps {
user: UnlinkedHeadscaleUser;
writable?: boolean;
}
export default function HeadscaleUserRow({ user, writable }: HeadscaleUserRowProps) {
export default function HeadscaleUserRow({ user }: HeadscaleUserRowProps) {
const isOnline = user.machines.some((machine) => machine.online);
const lastSeen = user.machines.reduce(
(acc, machine) => Math.max(acc, new Date(machine.lastSeen).getTime()),
@@ -56,7 +54,9 @@ export default function HeadscaleUserRow({ user, writable }: HeadscaleUserRowPro
<p className="text-sm text-mist-600 dark:text-mist-300">No machines</p>
)}
</td>
<td className="py-2 pr-0.5">{writable ? <HeadscaleUserMenu user={user} /> : null}</td>
<td className="py-2 pr-0.5">
{/* Unlinked users only get basic Headscale operations (rename, delete) */}
</td>
</tr>
);
}
+3 -3
View File
@@ -54,24 +54,24 @@ export default function UserMenu({
{modal === "reassign" && (
<Reassign
displayName={displayName}
headplaneUserId={user.id}
isOpen={modal === "reassign"}
role={user.role}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
userId={user.linkedHeadscaleUser?.id ?? user.id}
/>
)}
{modal === "link" && (
<LinkUser
currentLink={currentLink}
displayName={displayName}
headplaneUserId={user.id}
headscaleUsers={linkableUsers}
isOpen={modal === "link"}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
userId={user.linkedHeadscaleUser?.id ?? user.id}
/>
)}
{modal === "transfer" && (
@@ -81,7 +81,7 @@ export default function UserMenu({
if (!isOpen) setModal(null);
}}
targetDisplayName={displayName}
targetHeadplaneUserId={user.id}
targetUserId={user.linkedHeadscaleUser?.id ?? user.id}
/>
)}
+1 -1
View File
@@ -34,7 +34,7 @@ export default function DeleteUser({ user, machines, isOpen, setIsOpen }: Delete
</Text>
)}
<input name="action_id" type="hidden" value="delete_user" />
<input name="headscale_user_id" type="hidden" value={user.id} />
<input name="user_id" type="hidden" value={user.id} />
</DialogPanel>
</Dialog>
);
+3 -3
View File
@@ -5,7 +5,7 @@ import Title from "~/components/title";
import cn from "~/utils/cn";
interface LinkUserProps {
headplaneUserId: string;
userId: string;
displayName: string;
headscaleUsers: { id: string; name: string }[];
currentLink?: string;
@@ -14,7 +14,7 @@ interface LinkUserProps {
}
export default function LinkUser({
headplaneUserId,
userId,
displayName,
headscaleUsers,
currentLink,
@@ -34,7 +34,7 @@ export default function LinkUser({
) : (
<>
<input name="action_id" type="hidden" value="link_user" />
<input name="headplane_user_id" type="hidden" value={headplaneUserId} />
<input name="user_id" type="hidden" value={userId} />
<select
className={cn(
"w-full rounded-lg border p-2",
+3 -3
View File
@@ -8,7 +8,7 @@ import { Roles } from "~/server/web/roles";
import type { Role } from "~/server/web/roles";
interface ReassignProps {
headplaneUserId: string;
userId: string;
displayName: string;
role: Role;
isOpen: boolean;
@@ -16,7 +16,7 @@ interface ReassignProps {
}
export default function ReassignUser({
headplaneUserId,
userId,
displayName,
role,
isOpen,
@@ -38,7 +38,7 @@ export default function ReassignUser({
) : (
<>
<input name="action_id" type="hidden" value="reassign_user" />
<input name="headplane_user_id" type="hidden" value={headplaneUserId} />
<input name="user_id" type="hidden" value={userId} />
<RadioGroup className="gap-4" defaultValue={role} label="Role" name="new_role">
{Object.keys(Roles)
.filter((r) => r !== "owner")
+1 -1
View File
@@ -21,7 +21,7 @@ export default function RenameUser({ user, isOpen, setIsOpen }: RenameProps) {
update any ACL policies that may refer to this user by their old username.
</Text>
<input name="action_id" type="hidden" value="rename_user" />
<input name="headscale_user_id" type="hidden" value={user.id} />
<input name="user_id" type="hidden" value={user.id} />
<Input
defaultValue={user.name}
required
@@ -4,14 +4,14 @@ import Text from "~/components/text";
import Title from "~/components/title";
interface TransferOwnershipProps {
targetHeadplaneUserId: string;
targetUserId: string;
targetDisplayName: string;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
export default function TransferOwnership({
targetHeadplaneUserId,
targetUserId,
targetDisplayName,
isOpen,
setIsOpen,
@@ -29,7 +29,7 @@ export default function TransferOwnership({
ownership.
</Notice>
<input name="action_id" type="hidden" value="transfer_ownership" />
<input name="headplane_user_id" type="hidden" value={targetHeadplaneUserId} />
<input name="user_id" type="hidden" value={targetUserId} />
</DialogPanel>
</Dialog>
);
+3 -2
View File
@@ -54,7 +54,8 @@ export async function loader({ request, context }: Route.LoaderArgs) {
let apiError: string | undefined;
try {
const { api } = await context.apiForRequest(request);
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
const [nodesSnap, usersSnap] = await Promise.all([
context.hsLive.get(nodesResource, api),
context.hsLive.get(usersResource, api),
@@ -234,7 +235,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
)}
>
{loaderData.unlinkedHeadscaleUsers.map((user) => (
<HeadscaleUserRow key={user.id} user={user} writable={loaderData.writable} />
<HeadscaleUserRow key={user.id} user={user} />
))}
</tbody>
</table>
+62 -24
View File
@@ -1,6 +1,7 @@
import { data } from "react-router";
import { usersResource } from "~/server/headscale/live-store";
import { getOidcSubject } from "~/server/web/headscale-identity";
import { Capabilities } from "~/server/web/roles";
import type { Role } from "~/server/web/roles";
@@ -23,7 +24,8 @@ export async function userAction({ request, context }: Route.ActionArgs) {
});
}
const { api } = await context.apiForRequest(request);
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
switch (action) {
case "create_user": {
const name = formData.get("username")?.toString();
@@ -36,33 +38,33 @@ export async function userAction({ request, context }: Route.ActionArgs) {
});
}
await api.users.create({ name, email, displayName });
await api.createUser(name, email, displayName);
await context.hsLive.refresh(usersResource, api);
return { message: "User created successfully" };
}
case "delete_user": {
const headscaleUserId = formData.get("headscale_user_id")?.toString();
if (!headscaleUserId) {
throw data("Missing `headscale_user_id` in the form data.", {
const userId = formData.get("user_id")?.toString();
if (!userId) {
throw data("Missing `user_id` in the form data.", {
status: 400,
});
}
await api.users.delete(headscaleUserId);
await api.deleteUser(userId);
await context.hsLive.refresh(usersResource, api);
return { message: "User deleted successfully" };
}
case "rename_user": {
const headscaleUserId = formData.get("headscale_user_id")?.toString();
const userId = formData.get("user_id")?.toString();
const newName = formData.get("new_name")?.toString();
if (!headscaleUserId || !newName) {
if (!userId || !newName) {
return data({ success: false }, 400);
}
const users = await api.users.list({ id: headscaleUserId });
const user = users.find((user) => user.id === headscaleUserId);
const users = await api.getUsers(userId);
const user = users.find((user) => user.id === userId);
if (!user) {
throw data(`No user found with id: ${headscaleUserId}`, { status: 400 });
throw data(`No user found with id: ${userId}`, { status: 400 });
}
if (user.provider === "oidc") {
@@ -72,20 +74,34 @@ export async function userAction({ request, context }: Route.ActionArgs) {
});
}
await api.users.rename(headscaleUserId, newName);
await api.renameUser(userId, newName);
await context.hsLive.refresh(usersResource, api);
return { message: "User renamed successfully" };
}
case "reassign_user": {
const headplaneUserId = formData.get("headplane_user_id")?.toString();
const userId = formData.get("user_id")?.toString();
const newRole = formData.get("new_role")?.toString();
if (!headplaneUserId || !newRole) {
throw data("Missing `headplane_user_id` or `new_role` in the form data.", {
if (!userId || !newRole) {
throw data("Missing `user_id` or `new_role` in the form data.", {
status: 400,
});
}
const result = await context.auth.reassignUser(headplaneUserId, newRole as Role);
const users = await api.getUsers(userId);
const user = users.find((user) => user.id === userId);
if (!user) {
throw data("Specified user not found", {
status: 400,
});
}
const subject = getOidcSubject(user);
if (!subject) {
throw data("Specified user is not an OIDC user or has no subject.", { status: 400 });
}
const result = await context.auth.reassignSubject(subject, newRole as Role);
if (!result) {
throw data("Failed to reassign user role.", { status: 500 });
}
@@ -97,12 +113,23 @@ export async function userAction({ request, context }: Route.ActionArgs) {
throw data("Only the owner can transfer ownership.", { status: 403 });
}
const headplaneUserId = formData.get("headplane_user_id")?.toString();
if (!headplaneUserId) {
throw data("Missing `headplane_user_id` in the form data.", { status: 400 });
const userId = formData.get("user_id")?.toString();
if (!userId) {
throw data("Missing `user_id` in the form data.", { status: 400 });
}
const result = await context.auth.transferOwnership(principal.user.id, headplaneUserId);
const users = await api.getUsers(userId);
const user = users.find((user) => user.id === userId);
if (!user) {
throw data("Specified user not found", { status: 400 });
}
const targetSubject = getOidcSubject(user);
if (!targetSubject) {
throw data("Target user is not an OIDC user or has no subject.", { status: 400 });
}
const result = await context.auth.transferOwnership(principal.user.subject, targetSubject);
if (!result) {
throw data("Failed to transfer ownership.", { status: 500 });
}
@@ -110,15 +137,26 @@ export async function userAction({ request, context }: Route.ActionArgs) {
return { message: "Ownership transferred successfully" };
}
case "link_user": {
const headplaneUserId = formData.get("headplane_user_id")?.toString();
const userId = formData.get("user_id")?.toString();
const headscaleUserId = formData.get("headscale_user_id")?.toString();
if (!headplaneUserId || !headscaleUserId) {
throw data("Missing `headplane_user_id` or `headscale_user_id` in the form data.", {
if (!userId || !headscaleUserId) {
throw data("Missing `user_id` or `headscale_user_id` in the form data.", {
status: 400,
});
}
const linked = await context.auth.linkHeadscaleUser(headplaneUserId, headscaleUserId);
const users = await api.getUsers(userId);
const user = users.find((user) => user.id === userId);
if (!user) {
throw data("Specified user not found", { status: 400 });
}
const subject = getOidcSubject(user);
if (!subject) {
throw data("Specified user is not an OIDC user or has no subject.", { status: 400 });
}
const linked = await context.auth.linkHeadscaleUserBySubject(subject, headscaleUserId);
if (!linked) {
throw data("That Headscale user is already linked to another account.", { status: 409 });
}
+3 -1
View File
@@ -1,7 +1,9 @@
import type { Route } from "./+types/healthz";
export async function loader({ context }: Route.LoaderArgs) {
const healthy = await context.headscale.health();
// Use a fake API key for healthcheck
const api = context.hsApi.getRuntimeClient("fake-api-key");
const healthy = await api.isHealthy();
return new Response(JSON.stringify({ status: healthy ? "OK" : "ERROR" }), {
status: healthy ? 200 : 500,
+4 -2
View File
@@ -34,12 +34,14 @@ export async function loader({ request, context }: Route.LoaderArgs) {
);
}
const healthy = await context.headscale.health();
// Use a fake API key for healthcheck
const api = context.hsApi.getRuntimeClient("fake-api-key");
const healthy = await api.isHealthy();
const body = {
status: healthy ? "healthy" : "unhealthy",
headplane_version: __VERSION__,
headscale_canonical_version: healthy ? context.headscale.version.raw : "unknown",
headscale_canonical_version: healthy ? context.hsApi.apiVersion : "unknown",
internal_versions: {
node: versions.node,
v8: versions.v8,
+3 -1
View File
@@ -4,7 +4,9 @@ import log from "~/utils/log";
import type { Route } from "./+types/live";
export async function loader({ request, context }: Route.LoaderArgs) {
const { api } = await context.apiForRequest(request);
const principal = await context.auth.require(request);
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
// Ensure resources are loaded before streaming
await Promise.all([
+1 -1
View File
@@ -63,7 +63,7 @@ constructs everything that needs to live for the lifetime of the
process:
- the SQLite client (`db`)
- the Headscale REST interface (`headscale`)
- the Headscale REST interface (`hsApi`)
- the optional Headplane agent manager (`agents`)
- the auth service (`auth`)
- the optional OIDC service (`oidc`)
+1 -18
View File
@@ -35,28 +35,11 @@ try {
exit(1);
}
if ((config.server.tls_cert_path || config.server.tls_key_path) && !config.server.cookie_secure) {
log.warn(
"server",
"TLS is enabled but `server.cookie_secure` is false; forcing it to true (browsers reject Secure-less cookies over HTTPS)",
);
config.server.cookie_secure = true;
}
const ctx = await createAppContext(config);
ctx.startServices();
ctx.auth.start();
export { config };
/**
* Disposes the per-process context. Invoked by the production
* supervisor on SIGTERM/SIGINT and by the dev Vite plugin on HMR
* reload.
*/
export async function dispose(): Promise<void> {
await ctx.dispose();
}
// TODO: `getLoadContext` is the right place to handle reverse proxy
// translation — better than doing it in the OIDC client because it
// applies to all requests, not just OIDC ones.
-9
View File
@@ -42,12 +42,6 @@ const serverConfig = type({
cookie_secure: "boolean = true",
cookie_domain: "string.lower?",
cookie_max_age: "number.integer = 86400",
// TLS termination. When both `tls_cert_path` and `tls_key_path`
// are provided, Headplane serves HTTPS on `server.port`. When
// either is set, `cookie_secure` is forced to `true`.
tls_cert_path: "string?",
tls_key_path: "string?",
});
const partialServerConfig = type({
@@ -61,9 +55,6 @@ const partialServerConfig = type({
cookie_secure: "boolean?",
cookie_domain: "string.lower?",
cookie_max_age: "number.integer?",
tls_cert_path: "string?",
tls_key_path: "string?",
});
const headscaleConfig = type({
+2 -2
View File
@@ -1,4 +1,4 @@
import type { Headscale } from "~/server/headscale/api";
import type { RuntimeApiClient } from "~/server/headscale/api/endpoints";
export abstract class Integration<T> {
protected context: NonNullable<T>;
@@ -11,6 +11,6 @@ export abstract class Integration<T> {
}
abstract isAvailable(): Promise<boolean> | boolean;
abstract onConfigChange(headscale: Headscale): Promise<void> | void;
abstract onConfigChange(client: RuntimeApiClient): Promise<void> | void;
abstract get name(): string;
}
+3 -3
View File
@@ -4,7 +4,7 @@ import { setTimeout } from "node:timers/promises";
import { type } from "arktype";
import { Client } from "undici";
import type { Headscale } from "~/server/headscale/api";
import type { RuntimeApiClient } from "~/server/headscale/api/endpoints";
import log from "~/utils/log";
import { Integration } from "./abstract";
@@ -255,7 +255,7 @@ export default class DockerIntegration extends Integration<typeof configSchema.f
return this.client !== undefined && this.containerId !== undefined;
}
async onConfigChange(headscale: Headscale) {
async onConfigChange(client: RuntimeApiClient) {
if (!this.client) {
return;
}
@@ -290,7 +290,7 @@ export default class DockerIntegration extends Integration<typeof configSchema.f
while (attempts <= this.maxAttempts) {
try {
log.debug("config", "Checking Headscale status (attempt %d)", attempts);
const status = await headscale.health();
const status = await client.isHealthy();
if (status === false) {
throw new Error("Headscale is not running");
}
+3 -3
View File
@@ -5,7 +5,7 @@ import { join } from "node:path";
import { CoreV1Api, KubeConfig } from "@kubernetes/client-node";
import { type } from "arktype";
import type { Headscale } from "~/server/headscale/api";
import type { RuntimeApiClient } from "~/server/headscale/api/endpoints";
import log from "~/utils/log";
import { Integration } from "./abstract";
@@ -154,12 +154,12 @@ export default class KubernetesIntegration extends Integration<typeof configSche
}
}
async onConfigChange(headscale: Headscale) {
async onConfigChange(client: RuntimeApiClient) {
if (!this.pid) {
return;
}
await signalAndWaitHealthy(headscale, {
await signalAndWaitHealthy(client, {
pid: this.pid,
signal: "SIGHUP",
});
+4 -4
View File
@@ -3,7 +3,7 @@ import { join } from "node:path";
import { kill } from "node:process";
import { setTimeout } from "node:timers/promises";
import type { Headscale } from "~/server/headscale/api";
import type { RuntimeApiClient } from "~/server/headscale/api/endpoints";
import log from "~/utils/log";
/**
@@ -75,12 +75,12 @@ export interface SignalHeadscaleOptions {
/**
* Sends a signal to the headscale process and waits for it to become healthy.
* @param headscale The Headscale instance to health-check
* @param client The RuntimeApiClient to check health
* @param options Options for signaling and waiting
* @returns True if headscale became healthy, false otherwise
*/
export async function signalAndWaitHealthy(
headscale: Headscale,
client: RuntimeApiClient,
options: SignalHeadscaleOptions,
): Promise<boolean> {
const { pid, signal = "SIGHUP", maxAttempts = 10, retryDelayMs = 1000 } = options;
@@ -96,7 +96,7 @@ export async function signalAndWaitHealthy(
await setTimeout(retryDelayMs);
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const healthy = await headscale.health();
const healthy = await client.isHealthy();
if (healthy) {
log.info("config", "Headscale is healthy after restart");
return true;
+3 -3
View File
@@ -2,7 +2,7 @@ import { platform } from "node:os";
import { type } from "arktype";
import type { Headscale } from "~/server/headscale/api";
import type { RuntimeApiClient } from "~/server/headscale/api/endpoints";
import log from "~/utils/log";
import { Integration } from "./abstract";
@@ -51,12 +51,12 @@ export default class ProcIntegration extends Integration<typeof configSchema.ful
}
}
async onConfigChange(headscale: Headscale) {
async onConfigChange(client: RuntimeApiClient) {
if (!this.pid) {
return;
}
await signalAndWaitHealthy(headscale, {
await signalAndWaitHealthy(client, {
pid: this.pid,
signal: "SIGHUP",
});
+63 -128
View File
@@ -5,13 +5,13 @@ import log from "~/utils/log";
import type { HeadplaneConfig } from "./config/config-schema";
import { loadIntegration } from "./config/integration";
import { createDbClient } from "./db/client.server";
import { disabled, enabled, type Feature } from "./feature";
import { createHeadscale, type HeadscaleClient } from "./headscale/api";
import { live as fateLive } from "./fate";
import { createHeadscaleInterface } from "./headscale/api";
import { loadHeadscaleConfig } from "./headscale/config-loader";
import { createLiveStore, nodesResource, usersResource } from "./headscale/live-store";
import { type AgentManager, createAgentManager } from "./hp-agent";
import { createOidcService, type OidcService } from "./oidc/provider";
import { createAuthService, type Principal } from "./web/auth";
import { createAgentManager } from "./hp-agent";
import { createOidcService } from "./oidc/provider";
import { createAuthService } from "./web/auth";
export type AppContext = Awaited<ReturnType<typeof createAppContext>>;
@@ -21,21 +21,27 @@ declare module "react-router" {
export async function createAppContext(config: HeadplaneConfig) {
const db = await createDbClient(join(config.server.data_path, "hp_persist.db"));
const headscale = await createHeadscale({
url: config.headscale.url,
certPath: config.headscale.tls_cert_path,
});
const hsApi = await createHeadscaleInterface(
config.headscale.url,
config.headscale.tls_cert_path,
);
// Resolve the Headscale API key: headscale.api_key takes precedence,
// falling back to the deprecated oidc.headscale_api_key for compatibility.
const headscaleApiKey = config.headscale.api_key ?? config.oidc?.headscale_api_key;
const agents = await buildAgents(
config,
headscale.capabilities.preAuthKeysHaveStableIds,
headscaleApiKey ? headscale.client(headscaleApiKey) : undefined,
db,
);
let agents;
if (headscaleApiKey) {
agents = await createAgentManager(
config.integration?.agent,
config.headscale.url,
hsApi.getRuntimeClient(headscaleApiKey),
hsApi.clientHelpers.isAtleast("0.28.0"),
db,
);
} else if (config.integration?.agent?.enabled) {
log.warn("agent", "Agent is enabled but no headscale.api_key is configured");
}
const auth = createAuthService({
secret: config.server.cookie_secret,
@@ -49,130 +55,59 @@ export async function createAppContext(config: HeadplaneConfig) {
},
});
const oidc = buildOidc(config, headscaleApiKey);
const oidc =
config.oidc && config.oidc.enabled !== false && headscaleApiKey
? {
service: createOidcService({
issuer: config.oidc.issuer,
clientId: config.oidc.client_id,
clientSecret: config.oidc.client_secret,
baseUrl: config.server.base_url ?? "",
authorizationEndpoint: config.oidc.authorization_endpoint,
tokenEndpoint: config.oidc.token_endpoint,
userinfoEndpoint: config.oidc.userinfo_endpoint,
endSessionEndpoint: config.oidc.end_session_endpoint,
tokenEndpointAuthMethod:
config.oidc.token_endpoint_auth_method === "client_secret_jwt"
? undefined
: config.oidc.token_endpoint_auth_method,
usePkce: config.oidc.use_pkce,
scope: config.oidc.scope,
subjectClaims: config.oidc.subject_claims,
allowWeakRsaKeys: config.oidc.allow_weak_rsa_keys,
extraParams: config.oidc.extra_params,
profilePictureSource: config.oidc.profile_picture_source,
postLogoutRedirectUri: config.oidc.post_logout_redirect_uri,
}),
disableApiKeyLogin: config.oidc.disable_api_key_login,
useEndSession: config.oidc.use_end_session,
}
: undefined;
const hsLive = createLiveStore([nodesResource, usersResource]);
const hs = await loadHeadscaleConfig(
config.headscale.config_path,
config.headscale.config_strict,
config.headscale.dns_records_path,
);
const integration = await loadIntegration(config.integration);
// Disposers run in reverse-registration order on shutdown.
const disposers: Array<() => Promise<void> | void> = [
() => auth.stop(),
() => hsLive.dispose(),
() => headscale.dispose(),
];
if (agents.state === "enabled") {
disposers.push(() => agents.value.dispose());
}
async function apiForRequest(
request: Request,
): Promise<{ principal: Principal; api: HeadscaleClient }> {
const principal = await auth.require(request);
const apiKey = auth.getHeadscaleApiKey(principal);
return { principal, api: headscale.client(apiKey) };
}
function startServices() {
auth.start();
}
async function dispose() {
for (const d of [...disposers].reverse()) {
try {
await d();
} catch (error) {
log.warn("server", "Error during shutdown: %s", String(error));
}
hsLive.subscribe((resource, version) => {
const eventId = `${resource}:${version}`;
if (resource === nodesResource.key) {
fateLive.connection("machines").invalidate({ eventId });
} else if (resource === usersResource.key) {
fateLive.connection("users").invalidate({ eventId });
}
}
});
return {
config,
db,
headscale,
hsApi,
headscaleApiKey,
agents,
auth,
oidc,
hsLive,
hs,
integration,
apiForRequest,
startServices,
dispose,
hs: await loadHeadscaleConfig(
config.headscale.config_path,
config.headscale.config_strict,
config.headscale.dns_records_path,
),
integration: await loadIntegration(config.integration),
};
}
function buildOidc(
config: HeadplaneConfig,
headscaleApiKey: string | undefined,
): Feature<OidcService> {
if (!config.oidc) {
return disabled("OIDC is not configured");
}
if (config.oidc.enabled === false) {
return disabled("OIDC is disabled in the configuration");
}
if (!headscaleApiKey) {
return disabled("OIDC requires headscale.api_key to be configured");
}
return enabled(
createOidcService({
issuer: config.oidc.issuer,
clientId: config.oidc.client_id,
clientSecret: config.oidc.client_secret,
baseUrl: config.server.base_url ?? "",
authorizationEndpoint: config.oidc.authorization_endpoint,
tokenEndpoint: config.oidc.token_endpoint,
userinfoEndpoint: config.oidc.userinfo_endpoint,
endSessionEndpoint: config.oidc.end_session_endpoint,
tokenEndpointAuthMethod:
config.oidc.token_endpoint_auth_method === "client_secret_jwt"
? undefined
: config.oidc.token_endpoint_auth_method,
usePkce: config.oidc.use_pkce,
scope: config.oidc.scope,
subjectClaims: config.oidc.subject_claims,
allowWeakRsaKeys: config.oidc.allow_weak_rsa_keys,
extraParams: config.oidc.extra_params,
profilePictureSource: config.oidc.profile_picture_source,
postLogoutRedirectUri: config.oidc.post_logout_redirect_uri,
}),
);
}
async function buildAgents(
config: HeadplaneConfig,
supportsTagOnlyKeys: boolean,
apiClient: HeadscaleClient | undefined,
db: Awaited<ReturnType<typeof createDbClient>>,
): Promise<Feature<AgentManager>> {
const agentConfig = config.integration?.agent;
if (!agentConfig?.enabled) {
return disabled("Agent is not enabled in the configuration");
}
if (!apiClient) {
return disabled("Agent requires headscale.api_key to be configured");
}
if (!supportsTagOnlyKeys) {
return disabled("Agent requires Headscale 0.28 or newer");
}
const manager = await createAgentManager(
agentConfig,
config.headscale.url,
apiClient,
supportsTagOnlyKeys,
db,
);
if (!manager) {
return disabled("Agent failed to initialize (see logs)");
}
return enabled(manager);
}
+378
View File
@@ -0,0 +1,378 @@
import {
createFateServer,
createLiveEventBus,
dataView,
FateRequestError,
list,
resolveSourceById,
type Entity,
type SourceDefinition,
type SourceRegistry,
} from "@nkzw/fate/server";
import type { Context } from "hono";
import type { Machine as HeadscaleMachine, User as HeadscaleUser } from "~/types";
import type { AppContext } from "./context";
import type { RuntimeApiClient } from "./headscale/api/endpoints";
import { isConnectionError, isDataWithApiError } from "./headscale/api/error-client";
import { nodesResource } from "./headscale/live-store";
import type { Principal } from "./web/auth";
import { Capabilities } from "./web/roles";
export interface HonoFateEnv {
Variables: {
appContext: AppContext;
};
}
interface FateContext {
api: RuntimeApiClient;
app: AppContext;
principal: Principal;
request: Request;
}
type FateAdapterContext = Context<HonoFateEnv>;
export const live = createLiveEventBus();
type UserRecord = HeadscaleUser & Record<string, unknown>;
type MachineRecord = Omit<HeadscaleMachine, "user"> & {
user?: UserRecord;
} & Record<string, unknown>;
export const UserDataView = dataView<UserRecord>("User")({
createdAt: true,
displayName: true,
email: true,
id: true,
name: true,
profilePicUrl: true,
provider: true,
providerId: true,
});
export const MachineDataView = dataView<MachineRecord>("Machine")({
approvedRoutes: true,
availableRoutes: true,
createdAt: true,
discoKey: true,
expiry: true,
givenName: true,
id: true,
ipAddresses: true,
lastSeen: true,
machineKey: true,
name: true,
nodeKey: true,
online: true,
registerMethod: true,
subnetRoutes: true,
tags: true,
user: UserDataView,
});
export type User = Entity<typeof UserDataView, "User">;
export type Machine = Entity<
typeof MachineDataView,
"Machine",
{
user?: User;
}
>;
export type FateUser = User;
export type FateMachine = Machine;
const userSource = {
id: "id",
view: UserDataView,
} satisfies SourceDefinition<UserRecord>;
const machineSource = {
id: "id",
view: MachineDataView,
} satisfies SourceDefinition<MachineRecord>;
const machineList = list(MachineDataView, { orderBy: { givenName: "asc" } });
const userList = list(UserDataView, { orderBy: { name: "asc" } });
export const Root = {
machines: machineList,
users: userList,
};
export const roots = Root;
const queries = {};
const lists = {};
function apiFailureToFateError(error: unknown, fallback: string): FateRequestError {
if (error instanceof FateRequestError) {
return error;
}
if (isDataWithApiError(error)) {
const status = error.data.statusCode;
if (status === 401) {
return new FateRequestError("UNAUTHORIZED", "Headscale rejected the current API key.");
}
if (status === 403) {
return new FateRequestError("FORBIDDEN", "Headscale refused this operation.");
}
if (status === 404) {
return new FateRequestError("NOT_FOUND", "The requested Headscale resource was not found.");
}
return new FateRequestError(
"INTERNAL_ERROR",
`Headscale API request failed with status ${status}.`,
{ status: 502 },
);
}
const data = error && typeof error === "object" && "data" in error ? error.data : undefined;
if (isConnectionError(data)) {
return new FateRequestError(
"INTERNAL_ERROR",
`Unable to reach Headscale: ${data.errorMessage}`,
{ status: 502 },
);
}
return new FateRequestError("INTERNAL_ERROR", fallback);
}
interface RenameMachineInput {
id: string;
name: string;
}
const renameMachineInput = {
parse(input: unknown): RenameMachineInput {
if (!input || typeof input !== "object") {
throw new FateRequestError("VALIDATION_ERROR", "Machine rename input is required.");
}
const id = (input as Record<string, unknown>).id;
const name = (input as Record<string, unknown>).name;
if (typeof id !== "string" || id.trim() === "") {
throw new FateRequestError("VALIDATION_ERROR", "Machine ID is required.");
}
if (typeof name !== "string" || name.trim() === "") {
throw new FateRequestError("VALIDATION_ERROR", "Machine name is required.");
}
return {
id,
name: name.trim(),
};
},
};
const mutations = {
"machine.rename": {
input: renameMachineInput,
resolve: async ({
ctx,
input,
select,
}: {
ctx: FateContext;
input: RenameMachineInput;
select: Array<string>;
}): Promise<Machine | null> => {
let node;
try {
node = await ctx.api.getNode(input.id);
} catch (error) {
throw apiFailureToFateError(error, "Unable to load this machine.");
}
if (!ctx.app.auth.canManageNode(ctx.principal, node)) {
throw new FateRequestError(
"FORBIDDEN",
"You do not have permission to rename this machine.",
);
}
try {
await ctx.api.renameNode(input.id, input.name);
} catch (error) {
throw apiFailureToFateError(error, "Unable to rename this machine.");
}
void ctx.app.hsLive.refresh(nodesResource, ctx.api).catch(() => undefined);
const eventId = `machine:${input.id}:rename:${Date.now()}`;
live.update("Machine", input.id, { changed: ["givenName", "name"], eventId });
live.connection("machines").invalidate({ eventId });
return (await resolveSourceById({
ctx,
id: input.id,
input: { select },
registry,
source: machineSource,
})) as Machine | null;
},
type: "Machine",
},
};
type SourceByIdsOptions = {
ctx: FateContext;
ids: Array<string>;
};
type SourceConnectionOptions = {
ctx: FateContext;
cursor?: string;
direction: "backward" | "forward";
take: number;
};
function requireCapability(ctx: FateContext, capability: Capabilities) {
if (!ctx.app.auth.can(ctx.principal, capability)) {
throw new FateRequestError("FORBIDDEN", "You do not have permission to view this data.");
}
}
function compareText(a: string | undefined, b: string | undefined) {
return (a ?? "").localeCompare(b ?? "", undefined, { numeric: true, sensitivity: "base" });
}
function byRequestedId<T extends { id: string }>(items: Array<T>, ids: Array<string>) {
const byId = new Map(items.map((item) => [item.id, item]));
return ids.flatMap((id) => {
const item = byId.get(id);
return item ? [item] : [];
});
}
function pageByCursor<T extends { id: string }>(
items: Array<T>,
{ cursor, direction, take }: Omit<SourceConnectionOptions, "ctx">,
) {
if (!cursor) {
return direction === "backward"
? items.slice(Math.max(0, items.length - take))
: items.slice(0, take);
}
const cursorIndex = items.findIndex((item) => item.id === cursor);
if (cursorIndex < 0) {
return direction === "backward"
? items.slice(Math.max(0, items.length - take))
: items.slice(0, take);
}
if (direction === "backward") {
return items.slice(Math.max(0, cursorIndex - take), cursorIndex);
}
return items.slice(cursorIndex + 1, cursorIndex + 1 + take);
}
async function getMachineRecords(ctx: FateContext): Promise<Array<MachineRecord>> {
requireCapability(ctx, Capabilities.read_machines);
try {
return (await ctx.api.getNodes())
.map((machine) => machine as MachineRecord)
.sort(
(a, b) =>
compareText(a.givenName || a.name, b.givenName || b.name) || compareText(a.id, b.id),
);
} catch (error) {
throw apiFailureToFateError(error, "Unable to load machines from Headscale.");
}
}
async function getUserRecords(ctx: FateContext): Promise<Array<UserRecord>> {
requireCapability(ctx, Capabilities.read_users);
try {
return (await ctx.api.getUsers())
.map((user) => user as UserRecord)
.sort((a, b) => compareText(a.name, b.name) || compareText(a.id, b.id));
} catch (error) {
throw apiFailureToFateError(error, "Unable to load users from Headscale.");
}
}
const registry = new Map() as SourceRegistry<FateContext>;
registry.set(machineSource as SourceDefinition, {
byIds: async ({ ctx, ids }: SourceByIdsOptions) =>
byRequestedId(await getMachineRecords(ctx), ids),
connection: async ({ ctx, cursor, direction, take }: SourceConnectionOptions) =>
pageByCursor(await getMachineRecords(ctx), { cursor, direction, take }),
});
registry.set(userSource as SourceDefinition, {
byIds: async ({ ctx, ids }: SourceByIdsOptions) => byRequestedId(await getUserRecords(ctx), ids),
connection: async ({ ctx, cursor, direction, take }: SourceConnectionOptions) =>
pageByCursor(await getUserRecords(ctx), { cursor, direction, take }),
});
const sourceByView = new Map<unknown, SourceDefinition>([
[MachineDataView, machineSource as SourceDefinition],
[machineList, machineSource as SourceDefinition],
[UserDataView, userSource as SourceDefinition],
[userList, userSource as SourceDefinition],
]);
function isSourceDefinition(target: unknown): target is SourceDefinition {
return target !== null && typeof target === "object" && "view" in target;
}
export const fate = createFateServer<
FateContext,
typeof roots,
typeof queries,
typeof lists,
typeof mutations,
FateAdapterContext
>({
context: async ({ adapterContext, request }) => {
if (!adapterContext) {
throw new Error("Fate request is missing Hono context");
}
const app = adapterContext.get("appContext");
let principal;
try {
principal = await app.auth.require(request);
} catch {
throw new FateRequestError("UNAUTHORIZED", "Authentication required.");
}
const api = app.hsApi.getRuntimeClient(app.auth.getHeadscaleApiKey(principal));
return {
api,
app,
principal,
request,
};
},
live,
mutations,
roots,
sources: {
registry,
getSource(target) {
if (isSourceDefinition(target)) {
return target;
}
const source = sourceByView.get(target);
if (!source) {
throw new Error("No Fate source registered for data view");
}
return source;
},
},
});
export type { FateAdapterContext, FateContext };
-20
View File
@@ -1,20 +0,0 @@
// MARK: Feature<T>
//
// A two-state tagged union used in place of `T | undefined` for
// optional features on the AppContext. Carries a human-readable
// reason when the feature is disabled so loaders can surface it (or
// choose to ignore it).
//
// This is deliberately *not* a Result/Either. It does not represent
// async readiness or retryable failure — it represents "is this
// feature wired up at all." Services that have their own runtime
// status (e.g. OIDC discovery) keep that on the service itself.
export type Feature<T> = { state: "enabled"; value: T } | { state: "disabled"; reason: string };
export const enabled = <T>(value: T): Feature<T> => ({ state: "enabled", value });
export const disabled = (reason: string): Feature<never> => ({
state: "disabled",
reason,
});
-47
View File
@@ -1,47 +0,0 @@
// MARK: Headscale Capabilities
//
// Behavioural facts about the connected Headscale server, derived
// once from `ServerVersion` at boot. Capabilities are named for *what
// changed* — not for the version number it changed in — so a reader
// who has never seen Headscale can tell what each flag controls
// without consulting a release note.
//
// Add a capability here when you find yourself reaching for a raw
// version comparison in endpoint or route code. Adding a capability
// is also the right answer when a new Headscale release changes wire
// format or removes an endpoint.
import { gte, type ServerVersion } from "./server-version";
export interface Capabilities {
/**
* Pre-auth keys have stable IDs. `GET /api/v1/preauthkey` (no
* user filter) returns every key in the system, and
* `POST /api/v1/preauthkey/expire` takes `{ id }` instead of
* `{ user, key }`. Tag-only pre-auth keys (no owning user) are
* supported. Introduced in 0.28.0.
*/
readonly preAuthKeysHaveStableIds: boolean;
/**
* Node tags are a flat `tags: string[]` field on the wire.
* Pre-0.28 returned `forcedTags` / `validTags` / `invalidTags`
* that the client had to union itself. Introduced in 0.28.0.
*/
readonly nodeTagsAreFlat: boolean;
/**
* A node's owning user is immutable after creation;
* `POST /api/v1/node/{id}/user` no longer reassigns. Effective in
* 0.28.0+.
*/
readonly nodeOwnerIsImmutable: boolean;
}
export function capabilitiesFor(version: ServerVersion): Capabilities {
return {
preAuthKeysHaveStableIds: gte(version, "0.28.0"),
nodeTagsAreFlat: gte(version, "0.28.0"),
nodeOwnerIsImmutable: gte(version, "0.28.0"),
};
}
@@ -0,0 +1,20 @@
import type { Key } from "~/types";
import { defineApiEndpoints } from "../factory";
export interface ApiKeyEndpoints {
/**
* Retrieves all API keys from the Headscale instance.
*
* @returns An array of `Key` objects representing the API keys.
*/
getApiKeys(): Promise<Key[]>;
}
export default defineApiEndpoints<ApiKeyEndpoints>((client, apiKey) => ({
getApiKeys: async () => {
const { apiKeys } = await client.apiFetch<{ apiKeys: Key[] }>("GET", "v1/apikey", apiKey);
return apiKeys;
},
}));
@@ -0,0 +1,75 @@
import {
composeEndpoints,
defineApiEndpoints,
type ExtractApiEndpoints,
type UnionToIntersection,
} from "../factory";
import type { HeadscaleApiInterface } from "../index";
import apiKeyEndpoints from "./api-keys";
import nodeEndpoints from "./nodes";
import policyEndpoints from "./policy";
import preAuthKeyEndpoints from "./pre-auth-keys";
import userEndpoints from "./users";
interface HealthcheckEndpoint {
/**
* Checks if the Headscale instance is healthy.
*
* @returns A boolean indicating if the instance is healthy.
*/
isHealthy(): Promise<boolean>;
}
const healthcheckEndpoint = defineApiEndpoints<HealthcheckEndpoint>((client, apiKey) => ({
isHealthy: async () => {
try {
const res = await client.rawFetch("/health", {
method: "GET",
headers: {
// This doesn't really matter
Authorization: `Bearer ${apiKey}`,
},
});
return res.statusCode === 200;
} catch {
return false;
}
},
}));
/**
* A constant list of all endpoint groups.
* Add new endpoint groups here.
*/
export const endpointSets = [
apiKeyEndpoints,
healthcheckEndpoint,
nodeEndpoints,
policyEndpoints,
preAuthKeyEndpoints,
userEndpoints,
] as const;
/**
* All of the available API methods when interacting with Headscale's API.
* We have wrapped each operation with nice methods and parameters to make it
* easier to do integration testing by spinning up an actual Headscale instance
* and calling these methods against it.
*
* We also have the benefit of supporting multiple Headscale versions by
* passing in different internal implementations based on the OpenAPI spec.
*/
export type RuntimeApiClient = UnionToIntersection<
ExtractApiEndpoints<(typeof endpointSets)[number]>
>;
/**
* Composes all endpoint groups into a single runtime API client.
*
* @param client - The client helpers for making API requests.
* @param apiKey - The API key for authentication.
* @returns A fully composed runtime API client.
*/
export default (client: HeadscaleApiInterface["clientHelpers"], apiKey: string) =>
composeEndpoints(endpointSets, client, apiKey);
+171
View File
@@ -0,0 +1,171 @@
import type { Machine } from "~/types";
import type { HeadscaleApiInterface } from "..";
import { defineApiEndpoints } from "../factory";
interface RawMachine extends Omit<Machine, "tags"> {
tags?: string[];
forcedTags?: string[];
validTags?: string[];
invalidTags?: string[];
}
/**
* Normalizes the tags of a RawMachine based on the Headscale version.
*
* @param client The Headscale API client helper.
* @param node The RawMachine object to normalize.
* @returns A Machine object with normalized tags.
*/
function normalizeTags(client: HeadscaleApiInterface["clientHelpers"], node: RawMachine): Machine {
if (client.isAtleast("0.28.0")) {
return { ...node, tags: node.tags ?? [] } as Machine;
}
const tags = Array.from(new Set([...(node.forcedTags ?? []), ...(node.validTags ?? [])]));
return { ...node, tags } as Machine;
}
export interface NodeEndpoints {
/**
* Retrieves all nodes (machines) from the Headscale instance.
*
* @returns An array of `Machine` objects representing the nodes.
*/
getNodes(): Promise<Machine[]>;
/**
* Retrieves a specific node (machine) by its ID.
*
* @param id The ID of the node to retrieve.
* @returns A `Machine` object representing the node.
*/
getNode(id: string): Promise<Machine>;
/**
* Deletes a specific node (machine) by its ID.
*
* @param id The ID of the node to delete.
*/
deleteNode(id: string): Promise<void>;
/**
* Registers a new node (machine) with the given user and key.
*
* @param user The user to associate with the node.
* @param key The registration key for the node.
* @returns A `Machine` object representing the newly registered node.
*/
registerNode(user: string, key: string): Promise<Machine>;
/**
* Approves routes for a specific node (machine) by its ID.
*
* @param id The ID of the node.
* @param routes An array of routes to approve for the node.
*/
approveNodeRoutes(id: string, routes: string[]): Promise<void>;
/**
* Expires a specific node (machine) by its ID.
*
* @param id The ID of the node to expire.
*/
expireNode(id: string): Promise<void>;
/**
* Renames a specific node (machine) by its ID.
*
* @param id The ID of the node to rename.
* @param newName The new name for the node.
*/
renameNode(id: string, newName: string): Promise<void>;
/**
* Sets tags for a specific node (machine) by its ID.
*
* @param id The ID of the node.
* @param tags An array of tags to set for the node.
*/
setNodeTags(id: string, tags: string[]): Promise<void>;
/**
* Sets the user for a specific node (machine) by its ID.
*
* @param id The ID of the node.
* @param user The user to set for the node.
*/
setNodeUser(id: string, user: string): Promise<void>;
}
export default defineApiEndpoints<NodeEndpoints>((client, apiKey) => ({
getNodes: async () => {
const { nodes } = await client.apiFetch<{ nodes: RawMachine[] }>("GET", "v1/node", apiKey);
return nodes.map((node) => normalizeTags(client, node));
},
getNode: async (nodeId) => {
const { node } = await client.apiFetch<{ node: RawMachine }>(
"GET",
`v1/node/${nodeId}`,
apiKey,
);
return normalizeTags(client, node);
},
deleteNode: async (nodeId) => {
await client.apiFetch<void>("DELETE", `v1/node/${nodeId}`, apiKey);
},
registerNode: async (user, key) => {
const qp = new URLSearchParams();
qp.append("user", user);
qp.append("key", key);
const { node } = await client.apiFetch<{ node: RawMachine }>(
"POST",
`v1/node/register?${qp.toString()}`,
apiKey,
{
user,
key,
},
);
return normalizeTags(client, node);
},
approveNodeRoutes: async (nodeId, routes) => {
await client.apiFetch<void>("POST", `v1/node/${nodeId}/approve_routes`, apiKey, { routes });
},
expireNode: async (nodeId) => {
await client.apiFetch<void>("POST", `v1/node/${nodeId}/expire`, apiKey);
},
renameNode: async (nodeId, newName) => {
await client.apiFetch<void>(
"POST",
`v1/node/${nodeId}/rename/${encodeURIComponent(newName)}`,
apiKey,
);
},
setNodeTags: async (nodeId, tags) => {
await client.apiFetch<void>("POST", `v1/node/${nodeId}/tags`, apiKey, {
tags,
});
},
setNodeUser: async (nodeId, user) => {
// Headscale 0.28.0 got rid of node reassignment to users
if (client.isAtleast("0.28.0")) {
return;
}
await client.apiFetch<void>("POST", `v1/node/${nodeId}/user`, apiKey, {
user,
});
},
}));
@@ -0,0 +1,41 @@
import { defineApiEndpoints } from "../factory";
export interface PolicyEndpoints {
/**
* Retrieves the current ACL policy from the Headscale instance.
*
* @returns The ACL policy as a string and the date it was last updated.
*/
getPolicy(): Promise<{ policy: string; updatedAt: Date | null }>;
/**
* Sets the ACL policy for the Headscale instance.
*
* @param policy The ACL policy as a string.
* @returns The updated ACL policy as a string and the date it was last updated.
*/
setPolicy(policy: string): Promise<{ policy: string; updatedAt: Date }>;
}
export default defineApiEndpoints<PolicyEndpoints>((client, apiKey) => ({
getPolicy: async () => {
const { policy, updatedAt } = await client.apiFetch<{
policy: string;
updatedAt: string;
}>("GET", "v1/policy", apiKey);
return {
policy,
updatedAt: updatedAt !== null ? new Date(updatedAt) : null,
};
},
setPolicy: async (policy) => {
const { policy: newPolicy, updatedAt } = await client.apiFetch<{
policy: string;
updatedAt: string;
}>("PUT", "v1/policy", apiKey, { policy });
return { policy: newPolicy, updatedAt: new Date(updatedAt) };
},
}));
@@ -0,0 +1,93 @@
import type { PreAuthKey } from "~/types";
import { defineApiEndpoints } from "../factory";
export interface PreAuthKeyEndpoints {
/**
* List all pre-auth keys. Requires Headscale 0.28+.
*/
getAllPreAuthKeys(): Promise<PreAuthKey[]>;
/**
* Retrieves all pre-authentication keys for a specific user.
*
* @param user The user to retrieve pre-authentication keys for.
* @returns An array of `PreAuthKey` objects representing the pre-authentication keys.
*/
getPreAuthKeys(user: string): Promise<PreAuthKey[]>;
/**
* Creates a new pre-authentication key.
* User can be null for tag-only keys (requires Headscale 0.28+).
*/
createPreAuthKey(
user: string | null,
ephemeral: boolean,
reusable: boolean,
expiration: Date | null,
aclTags: string[] | null,
): Promise<PreAuthKey>;
/**
* Expires a specific pre-authentication key for a user.
*
* @param user The user associated with the pre-authentication key.
* @param key The pre-authentication key to expire.
*/
expirePreAuthKey(user: string, key: PreAuthKey): Promise<void>;
}
export default defineApiEndpoints<PreAuthKeyEndpoints>((client, apiKey) => ({
getAllPreAuthKeys: async () => {
const { preAuthKeys } = await client.apiFetch<{
preAuthKeys: PreAuthKey[];
}>("GET", "v1/preauthkey", apiKey, {});
return preAuthKeys;
},
getPreAuthKeys: async (user) => {
const { preAuthKeys } = await client.apiFetch<{
preAuthKeys: PreAuthKey[];
}>("GET", "v1/preauthkey", apiKey, { user });
return preAuthKeys;
},
createPreAuthKey: async (user, ephemeral, reusable, expiration, aclTags) => {
const body: Record<string, unknown> = {
ephemeral,
reusable,
expiration: expiration ? expiration.toISOString() : null,
};
if (user) {
body.user = user;
}
if (aclTags && aclTags.length > 0) {
body.aclTags = aclTags;
}
const { preAuthKey } = await client.apiFetch<{
preAuthKey: PreAuthKey;
}>("POST", "v1/preauthkey", apiKey, body);
return preAuthKey;
},
expirePreAuthKey: async (user, key) => {
if (client.isAtleast("0.28.0")) {
await client.apiFetch<void>("POST", "v1/preauthkey/expire", apiKey, {
id: key.id,
});
return;
}
await client.apiFetch<void>("POST", "v1/preauthkey/expire", apiKey, {
user,
key: key.key,
});
},
}));
@@ -0,0 +1,87 @@
import type { User } from "~/types";
import { defineApiEndpoints } from "../factory";
export interface UserEndpoints {
/**
* Retrieves users from the Headscale instance, optionally filtering by ID, name, or email.
*
* @param id Optional ID of the user to retrieve.
* @param name Optional name of the user to retrieve.
* @param email Optional email of the user to retrieve.
* @returns An array of `User` objects representing the users.
*/
getUsers(id?: string, name?: string, email?: string): Promise<User[]>;
/**
* Creates a new user in the Headscale instance.
*
* @param username The username of the new user.
* @param email Optional email of the new user.
* @param displayName Optional display name of the new user.
* @param pictureUrl Optional picture URL of the new user.
* @returns A `User` object representing the newly created user.
*/
createUser(
username: string,
email?: string,
displayName?: string,
pictureUrl?: string,
): Promise<User>;
/**
* Deletes a specific user by its ID.
*
* @param id The ID of the user to delete.
*/
deleteUser(id: string): Promise<void>;
/**
* Renames a specific user by its ID.
*
* @param id The ID of the user to rename.
* @param newName The new name for the user.
*/
renameUser(id: string, newName: string): Promise<void>;
}
export default defineApiEndpoints<UserEndpoints>((client, apiKey) => ({
getUsers: async (id, name, email) => {
const moreThanOneFilter = [id, name, email].filter((v) => v !== undefined).length > 1;
if (moreThanOneFilter) {
throw new Error("Only one of id, name, or email filters can be provided");
}
const { users } = await client.apiFetch<{ users: User[] }>("GET", "v1/user", apiKey, {
id,
name,
email,
});
return users;
},
createUser: async (username, email, displayName, pictureUrl) => {
const { user } = await client.apiFetch<{ user: User }>("POST", "v1/user", apiKey, {
name: username,
email,
displayName,
pictureUrl,
});
return user;
},
deleteUser: async (id) => {
await client.apiFetch<void>("DELETE", `v1/user/${id}`, apiKey);
},
renameUser: async (oldId, newName) => {
await client.apiFetch<void>(
"POST",
`v1/user/${oldId}/rename/${encodeURIComponent(newName)}`,
apiKey,
);
},
}));
+38
View File
@@ -0,0 +1,38 @@
import type { HeadscaleApiInterface } from "../api";
/**
* Creates a strongly-typed group factory for a given endpoint interface.
*
* Example:
* export const apiKeyGroup = defineGroup<ApiKeyEndpoints>({...})
*/
export interface EndpointFactory<T extends object> {
__type?: T;
(client: HeadscaleApiInterface["clientHelpers"], apiKey: string): T;
}
export function defineApiEndpoints<T extends object>(
factories: (client: HeadscaleApiInterface["clientHelpers"], apiKey: string) => T,
): EndpointFactory<T> {
return factories;
}
export type ExtractApiEndpoints<F> = F extends EndpointFactory<infer T> ? T : never;
export type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
k: infer I,
) => void
? I
: never;
/**
* Compose multiple endpoint sets into a single typed runtime client
*/
export function composeEndpoints<T extends readonly EndpointFactory<any>[]>(
factories: T,
clientHelpers: any,
apiKey: string,
): UnionToIntersection<ExtractApiEndpoints<T[number]>> {
const instances = factories.map((f) => f(clientHelpers, apiKey));
return Object.assign({}, ...instances) as any;
}
+51
View File
@@ -0,0 +1,51 @@
import { createHash } from "node:crypto";
import { dereference } from "@readme/openapi-parser";
import { OpenAPIV2 } from "openapi-types";
/**
* A map of operation IDs to their hashes.
*/
export interface DocumentHash {
[operationId: string]: string;
}
/**
* Given an OpenAPI v2 document, generate a map of operations with hashes.
* This gives us deterministic identifers to determine the version of Headscale
* that is being used at runtime.
*
* @param doc The OpenAPI v2 document to hash.
* @returns A map of operation IDs to their hashes.
*/
export async function hashOpenApiDocument(doc: OpenAPIV2.Document): Promise<DocumentHash> {
const spec = await dereference(doc);
const hashes: DocumentHash = {};
const seen = new Set<string>();
for (const [path, item] of Object.entries(spec.paths)) {
for (const [method, operation] of Object.entries(item)) {
if (typeof operation !== "object") {
continue;
}
const { parameters, responses } = operation as OpenAPIV2.OperationObject;
const raw = JSON.stringify(
{
path,
method: method.toUpperCase(),
parameters,
responses,
},
Object.keys({ path, method, parameters, responses }).sort(),
);
const hash = createHash("md5").update(raw).digest("hex").slice(0, 16);
const final = seen.has(hash) ? `${hash}_${seen.size}` : hash;
seen.add(final);
hashes[`${method.toUpperCase()} ${path}`] = final;
}
}
return hashes;
}
+305 -140
View File
@@ -1,163 +1,328 @@
// MARK: Headscale API
//
// The public entry point for talking to a Headscale server. At boot
// we try `GET /version` (unauthenticated, present since Headscale
// 0.27.0 — the minimum version Headplane supports) to derive a
// typed `Capabilities` object. Boot outcomes:
//
// - success: parse the response, derive capabilities, done.
// - 404: Headscale is reachable but predates 0.27.0 and is no
// longer supported. Log an error and keep retrying so an
// upgrade is picked up without a Headplane restart.
// - any other failure (network, 5xx, parse): Headplane still
// boots with `version = unknown` (capabilities-permissive) and
// a background retry. This handles docker-compose start-order
// races without making the whole process unhappy.
//
// Capabilities are always derived from `version`; once detection
// finishes there's no further state to track.
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { dereference } from "@readme/openapi-parser";
import type { OpenAPIV2 } from "openapi-types";
import { data } from "react-router";
import { Agent, type Dispatcher, request } from "undici";
import log from "~/utils/log";
import { type Capabilities, capabilitiesFor } from "./capabilities";
import { isDataWithApiError } from "./error-client";
import { type ApiKeyApi, makeApiKeyApi } from "./resources/api-keys";
import { makeNodeApi, type NodeApi } from "./resources/nodes";
import { makePolicyApi, type PolicyApi } from "./resources/policy";
import { makePreAuthKeyApi, type PreAuthKeyApi } from "./resources/pre-auth-keys";
import { makeUserApi, type UserApi } from "./resources/users";
import { formatServerVersion, parseServerVersion, type ServerVersion } from "./server-version";
import { createTransport } from "./transport";
import endpointSets, { RuntimeApiClient } from "./endpoints";
import { undiciToFriendlyError } from "./error";
import { HeadscaleAPIError, isApiError } from "./error-client";
import { detectApiVersion, isAtLeast, type Version } from "./version";
const MIN_SUPPORTED_VERSION = "0.27.0";
export interface Headscale {
readonly version: ServerVersion;
readonly capabilities: Capabilities;
/** True if the Headscale server's `/health` endpoint returns 200. */
health(): Promise<boolean>;
/** Build an API client bound to a specific Headscale API key. */
client(apiKey: string): HeadscaleClient;
/** Stop background work and close the underlying HTTP agent. */
dispose(): Promise<void>;
}
export interface HeadscaleClient {
nodes: NodeApi;
users: UserApi;
policy: PolicyApi;
preAuthKeys: PreAuthKeyApi;
apiKeys: ApiKeyApi;
}
export interface CreateHeadscaleOptions {
url: string;
certPath?: string;
/**
* A low-level composed interface for interacting with the Headscale API.
* This interface provides direct access to the underlying Undici agent
* and methods for making API requests.
*
* It is also responsible for handling OpenAPI spec polling and hashing to
* determine the implementations of API methods when requested for use.
*/
export interface HeadscaleApiInterface {
/**
* How often to retry `/version` while Headscale is unreachable.
* Defaults to 30 seconds. Exposed for tests.
* The underlying Undici agent used for making requests.
*/
retryIntervalMs?: number;
undiciAgent: Agent;
/**
* The base URL of the Headscale API.
*/
baseUrl: string;
/**
* The OpenAPI hashes retrieved from the Headscale instance at runtime.
* This is used to determine which implementations of API methods to use.
*/
openapiHashes: Record<string, string> | null;
/**
* The detected API version of the connected Headscale instance.
*/
apiVersion: Version;
/**
* Retrieves a runtime API client for the given API key.
*
* @param apiKey The API key to use for authentication.
* @returns A `RuntimeApiClient` instance for interacting with the API.
*/
getRuntimeClient(apiKey: string): RuntimeApiClient;
/**
* A set of helper methods made available to API method implementations.
* The idea is to make interacting with the API easier by providing
* common functionality that can be reused across multiple methods.
*/
clientHelpers: {
/**
* Checks if the connected Headscale instance's API version
* is at least the specified version.
*
* @param version The version to check against.
* @returns `true` if the API version is at least the specified version, `false` otherwise.
*/
isAtleast(version: Version): boolean;
/**
* Makes a raw fetch request to the Headscale API via the Undici agent.
* This method is used internally by API method implementations
* to make requests to the Headscale API.
*
* @param path The API path to request.
* @param options Optional request options.
* @returns A promise that resolves to the response data.
*/
rawFetch(
path: string,
options?: Partial<Dispatcher.RequestOptions>,
): Promise<Dispatcher.ResponseData>;
/**
* Makes a typed API fetch request to the Headscale API.
* This method is used internally by API method implementations
* to make requests to the Headscale API and parse the response.
*
* @param method The HTTP method to use.
* @param apiPath The API path to request.
* @param apiKey The API key to use for authentication.
* @param bodyOrQuery Optional body or query parameters.
* @returns A promise that resolves to the typed response data.
*/
apiFetch<T>(
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH",
apiPath: `v1/${string}`,
apiKey: string,
bodyOrQuery?: Record<string, unknown>,
): Promise<T>;
};
}
const DEFAULT_RETRY_INTERVAL_MS = 30_000;
/**
* Creates a new Headscale API client interface.
*
* @param baseUrl The base URL of the Headscale API.
* @param certPath Optional path to a custom TLS certificate for secure connections.
* @returns A promise that resolves to a `HeadscaleApiClient` instance.
*/
export async function createHeadscaleInterface(
baseUrl: string,
certPath?: string,
): Promise<HeadscaleApiInterface> {
const undiciAgent = await createUndiciAgent(certPath);
let openapiHashes: Record<string, string> | null = null;
let apiVersion: Version;
export async function createHeadscale(opts: CreateHeadscaleOptions): Promise<Headscale> {
const transport = await createTransport({ url: opts.url, certPath: opts.certPath });
const retryIntervalMs = opts.retryIntervalMs ?? DEFAULT_RETRY_INTERVAL_MS;
const rawFetch = async (
url: string,
options?: Partial<Dispatcher.RequestOptions>,
): Promise<Dispatcher.ResponseData> => {
const method = options?.method ?? "GET";
log.debug("api", "%s %s", method, url);
let version: ServerVersion = parseServerVersion("unreachable");
let capabilities: Capabilities = capabilitiesFor(version);
let detected = false;
let retryTimer: ReturnType<typeof setTimeout> | undefined;
let disposed = false;
function settle(parsed: ServerVersion) {
version = parsed;
capabilities = capabilitiesFor(parsed);
detected = true;
if (parsed.unknown) {
log.warn(
"api",
"Could not parse Headscale version %s, assuming newest known capabilities",
parsed.raw,
);
} else {
log.info("api", "Connected to Headscale %s", formatServerVersion(parsed));
}
}
async function detectOnce(): Promise<boolean> {
try {
const { version: raw } = await transport.getPublic<{ version: string }>("/version");
settle(parseServerVersion(raw));
return true;
const res = await request(new URL(url, baseUrl), {
dispatcher: undiciAgent,
headers: {
...options?.headers,
Accept: "application/json",
"User-Agent": `Headplane/${__VERSION__}`,
},
body: options?.body,
method,
});
return res;
} catch (error) {
// 404 means Headscale is reachable but predates 0.27.0 (where
// /version was introduced). That server is below the supported
// floor, so we don't settle — leave capabilities permissive and
// keep retrying in case the operator upgrades in place.
if (isDataWithApiError(error) && error.data.statusCode === 404) {
log.error(
"api",
"Headscale /version returned 404; Headplane requires Headscale %s or newer",
MIN_SUPPORTED_VERSION,
);
return false;
const errorBody = undiciToFriendlyError(error, `${method} ${url}`);
throw data(errorBody, {
status: 502,
statusText: "Bad Gateway",
});
}
};
const apiFetch = async <T>(
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH",
apiPath: `v1/${string}`,
apiKey: string,
bodyOrQuery?: Record<string, unknown>,
): Promise<T> => {
let url = `/api/${apiPath}`;
const options: Partial<Dispatcher.RequestOptions> = {
method: method,
headers: {
Authorization: `Bearer ${apiKey}`,
},
};
if (bodyOrQuery) {
if (method === "GET" || method === "DELETE") {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(bodyOrQuery)) {
if (value !== undefined) {
params.append(key, String(value));
}
}
if ([...params.keys()].length > 0) {
url += `?${params.toString()}`;
}
} else {
options.body = JSON.stringify(bodyOrQuery);
options.headers = {
...options.headers,
"Content-Type": "application/json",
};
}
log.debug("api", "Headscale /version probe failed: %s", String(error));
return false;
}
const res = await rawFetch(url, options);
if (res.statusCode >= 400) {
log.debug("api", "%s %s failed with status %d", method, apiPath, res.statusCode);
const rawData = await res.body.text();
const jsonData = (() => {
try {
return JSON.parse(rawData) as Record<string, unknown>;
} catch {
return null;
}
})();
throw data(
{
requestUrl: `${method} ${apiPath}`,
statusCode: res.statusCode,
rawData,
data: jsonData,
} satisfies HeadscaleAPIError,
{ status: 502, statusText: "Bad Gateway" },
);
}
return res.body.json() as Promise<T>;
};
/**
* Polls the OpenAPI spec endpoint and generates operation hashes.
* This is used to determine which implementations of API methods to use.
*
* @returns A promise that resolves to the OpenAPI operation hashes.
*/
async function fetchAndHashOpenapi(): Promise<Record<string, string> | null> {
try {
const res = await rawFetch("/swagger/v1/openapiv2.json");
if (res.statusCode !== 200) {
log.error("api", "Failed to fetch OpenAPI spec: %d", res.statusCode);
return null;
}
const body = await res.body.json();
const spec = await dereference(body as OpenAPIV2.Document);
const hashes = generateSpecHashes(spec);
log.debug("api", "OpenAPI hashes updated (%d endpoints)", Object.keys(hashes).length);
return hashes;
} catch (error) {
if (isApiError(error)) {
log.debug("api", "Failed to fetch OpenAPI spec: %d", error.statusCode);
}
return null;
}
}
function scheduleRetry() {
if (disposed || detected) return;
retryTimer = setTimeout(async () => {
retryTimer = undefined;
if (disposed) return;
if (await detectOnce()) return;
scheduleRetry();
}, retryIntervalMs);
// Don't keep the event loop alive on this timer alone — Headplane
// should still shut down cleanly while we're waiting to retry.
retryTimer.unref?.();
}
const isAtleast = (version: Version): boolean => {
return isAtLeast(apiVersion, version);
};
if (!(await detectOnce())) {
log.warn(
"api",
"Headscale unreachable at boot; defaulting to newest-known capabilities and retrying every %dms",
retryIntervalMs,
);
scheduleRetry();
}
openapiHashes = await fetchAndHashOpenapi();
apiVersion = detectApiVersion(openapiHashes);
setInterval(async () => {
const hashes = await fetchAndHashOpenapi();
if (hashes) {
openapiHashes = hashes;
apiVersion = detectApiVersion(openapiHashes);
}
}, 60_000); // every 60 seconds
return {
// Getters so callers always observe the latest detected values
// without having to know about the retry loop.
get version() {
return version;
undiciAgent,
baseUrl,
openapiHashes,
apiVersion,
getRuntimeClient: (apiKey: string) => {
return endpointSets(
{
rawFetch,
apiFetch,
isAtleast,
},
apiKey,
);
},
get capabilities() {
return capabilities;
},
health: () => transport.health(),
client(apiKey) {
return {
nodes: makeNodeApi(transport, capabilities, apiKey),
users: makeUserApi(transport, capabilities, apiKey),
policy: makePolicyApi(transport, capabilities, apiKey),
preAuthKeys: makePreAuthKeyApi(transport, capabilities, apiKey),
apiKeys: makeApiKeyApi(transport, capabilities, apiKey),
};
},
async dispose() {
disposed = true;
if (retryTimer) {
clearTimeout(retryTimer);
retryTimer = undefined;
}
await transport.dispose();
clientHelpers: {
rawFetch,
apiFetch,
isAtleast,
},
};
}
/**
* Creates a new Undici agent for making HTTP requests.
*
* @param certPath Optional path to a custom TLS certificate for secure connections.
* @returns A promise that resolves to an `Agent` instance.
*/
async function createUndiciAgent(certPath?: string): Promise<Agent> {
if (!certPath) {
return new Agent();
}
try {
log.debug("config", "Loading certificate from %s", certPath);
const data = await readFile(certPath, "utf8");
log.info("config", "Using certificate from %s", certPath);
return new Agent({ connect: { ca: data.trim() } });
} catch (error) {
log.error("config", "Failed to load Headscale TLS cert: %s", error);
log.debug("config", "Error Details: %o", error);
return new Agent();
}
}
function generateSpecHashes(spec: OpenAPIV2.Document) {
const hashes: Record<string, string> = {};
const seen = new Set<string>();
for (const [path, item] of Object.entries(spec.paths)) {
for (const [method, operation] of Object.entries(item)) {
if (typeof operation !== "object") {
continue;
}
const { parameters, responses } = operation as OpenAPIV2.OperationObject;
const raw = JSON.stringify(
{
path,
method: method.toUpperCase(),
parameters,
responses,
},
Object.keys({ path, method, parameters, responses }).sort(),
);
const hash = createHash("md5").update(raw).digest("hex").slice(0, 16);
const final = seen.has(hash) ? `${hash}_${seen.size}` : hash;
seen.add(final);
hashes[`${method.toUpperCase()} ${path}`] = final;
}
}
return hashes;
}
@@ -1,25 +0,0 @@
import type { Key } from "~/types";
import type { Capabilities } from "../capabilities";
import type { Transport } from "../transport";
export interface ApiKeyApi {
list(): Promise<Key[]>;
}
export function makeApiKeyApi(
transport: Transport,
_capabilities: Capabilities,
apiKey: string,
): ApiKeyApi {
return {
list: async () => {
const { apiKeys } = await transport.request<{ apiKeys: Key[] }>({
method: "GET",
path: "v1/apikey",
apiKey,
});
return apiKeys;
},
};
}
-116
View File
@@ -1,116 +0,0 @@
import type { Machine } from "~/types";
import type { Capabilities } from "../capabilities";
import type { Transport } from "../transport";
interface RawMachine extends Omit<Machine, "tags"> {
tags?: string[];
forcedTags?: string[];
validTags?: string[];
invalidTags?: string[];
}
export interface NodeApi {
list(): Promise<Machine[]>;
get(id: string): Promise<Machine>;
delete(id: string): Promise<void>;
register(user: string, key: string): Promise<Machine>;
approveRoutes(id: string, routes: string[]): Promise<void>;
expire(id: string): Promise<void>;
rename(id: string, newName: string): Promise<void>;
setTags(id: string, tags: string[]): Promise<void>;
/**
* Reassign a node to a different user. Only present when
* `capabilities.nodeOwnerIsImmutable` is false (Headscale < 0.28).
*/
reassignUser?: (id: string, user: string) => Promise<void>;
}
export function makeNodeApi(
transport: Transport,
capabilities: Capabilities,
apiKey: string,
): NodeApi {
function normalize(raw: RawMachine): Machine {
if (capabilities.nodeTagsAreFlat) {
return { ...raw, tags: raw.tags ?? [] } as Machine;
}
const tags = Array.from(new Set([...(raw.forcedTags ?? []), ...(raw.validTags ?? [])]));
return { ...raw, tags } as Machine;
}
const api: NodeApi = {
list: async () => {
const { nodes } = await transport.request<{ nodes: RawMachine[] }>({
method: "GET",
path: "v1/node",
apiKey,
});
return nodes.map(normalize);
},
get: async (id) => {
const { node } = await transport.request<{ node: RawMachine }>({
method: "GET",
path: `v1/node/${id}`,
apiKey,
});
return normalize(node);
},
delete: async (id) => {
await transport.request({ method: "DELETE", path: `v1/node/${id}`, apiKey });
},
register: async (user, key) => {
// Headscale's node-register endpoint expects the registration
// params as both query string and body — preserved as-is.
const qp = new URLSearchParams();
qp.append("user", user);
qp.append("key", key);
const { node } = await transport.request<{ node: RawMachine }>({
method: "POST",
path: `v1/node/register?${qp.toString()}`,
apiKey,
body: { user, key },
});
return normalize(node);
},
approveRoutes: async (id, routes) => {
await transport.request({
method: "POST",
path: `v1/node/${id}/approve_routes`,
apiKey,
body: { routes },
});
},
expire: async (id) => {
await transport.request({ method: "POST", path: `v1/node/${id}/expire`, apiKey });
},
rename: async (id, newName) => {
await transport.request({
method: "POST",
path: `v1/node/${id}/rename/${encodeURIComponent(newName)}`,
apiKey,
});
},
setTags: async (id, tags) => {
await transport.request({
method: "POST",
path: `v1/node/${id}/tags`,
apiKey,
body: { tags },
});
},
};
if (!capabilities.nodeOwnerIsImmutable) {
api.reassignUser = async (id, user) => {
await transport.request({
method: "POST",
path: `v1/node/${id}/user`,
apiKey,
body: { user },
});
};
}
return api;
}
@@ -1,33 +0,0 @@
import type { Capabilities } from "../capabilities";
import type { Transport } from "../transport";
export interface PolicyApi {
get(): Promise<{ policy: string; updatedAt: Date | null }>;
set(policy: string): Promise<{ policy: string; updatedAt: Date }>;
}
export function makePolicyApi(
transport: Transport,
_capabilities: Capabilities,
apiKey: string,
): PolicyApi {
return {
get: async () => {
const { policy, updatedAt } = await transport.request<{
policy: string;
updatedAt: string;
}>({ method: "GET", path: "v1/policy", apiKey });
return {
policy,
updatedAt: updatedAt !== null ? new Date(updatedAt) : null,
};
},
set: async (policy) => {
const { policy: newPolicy, updatedAt } = await transport.request<{
policy: string;
updatedAt: string;
}>({ method: "PUT", path: "v1/policy", apiKey, body: { policy } });
return { policy: newPolicy, updatedAt: new Date(updatedAt) };
},
};
}
@@ -1,90 +0,0 @@
import type { PreAuthKey } from "~/types";
import type { Capabilities } from "../capabilities";
import type { Transport } from "../transport";
export interface CreatePreAuthKeyOptions {
/** Owning user ID, or `null` for tag-only keys (0.28+). */
user: string | null;
ephemeral: boolean;
reusable: boolean;
expiration: Date | null;
aclTags: string[] | null;
}
export interface PreAuthKeyApi {
/**
* List every pre-auth key on the server. Only present when
* `capabilities.preAuthKeysHaveStableIds` is true (Headscale 0.28+).
* Pre-0.28 callers must use {@link listForUser}.
*/
listAll?: () => Promise<PreAuthKey[]>;
listForUser(userId: string): Promise<PreAuthKey[]>;
create(opts: CreatePreAuthKeyOptions): Promise<PreAuthKey>;
expire(key: PreAuthKey): Promise<void>;
}
export function makePreAuthKeyApi(
transport: Transport,
capabilities: Capabilities,
apiKey: string,
): PreAuthKeyApi {
const api: PreAuthKeyApi = {
listForUser: async (userId) => {
const { preAuthKeys } = await transport.request<{
preAuthKeys: PreAuthKey[];
}>({ method: "GET", path: "v1/preauthkey", apiKey, query: { user: userId } });
return preAuthKeys;
},
create: async ({ user, ephemeral, reusable, expiration, aclTags }) => {
const body: Record<string, unknown> = {
ephemeral,
reusable,
expiration: expiration ? expiration.toISOString() : null,
};
if (user) body.user = user;
if (aclTags && aclTags.length > 0) body.aclTags = aclTags;
const { preAuthKey } = await transport.request<{
preAuthKey: PreAuthKey;
}>({ method: "POST", path: "v1/preauthkey", apiKey, body });
return preAuthKey;
},
expire: async (key) => {
if (capabilities.preAuthKeysHaveStableIds) {
await transport.request({
method: "POST",
path: "v1/preauthkey/expire",
apiKey,
body: { id: key.id },
});
return;
}
// Pre-0.28: expire takes the owning user's ID (a uint64 — Headscale
// rejects names with `proto: invalid value for uint64 field user`)
// plus the key string.
await transport.request({
method: "POST",
path: "v1/preauthkey/expire",
apiKey,
body: { user: key.user?.id ?? "", key: key.key },
});
},
};
if (capabilities.preAuthKeysHaveStableIds) {
api.listAll = async () => {
const { preAuthKeys } = await transport.request<{
preAuthKeys: PreAuthKey[];
}>({ method: "GET", path: "v1/preauthkey", apiKey });
return preAuthKeys;
};
}
return api;
}
@@ -1,66 +0,0 @@
import type { User } from "~/types";
import type { Capabilities } from "../capabilities";
import type { Transport } from "../transport";
export interface CreateUserOptions {
name: string;
email?: string;
displayName?: string;
pictureUrl?: string;
}
export interface ListUsersFilter {
id?: string;
name?: string;
email?: string;
}
export interface UserApi {
list(filter?: ListUsersFilter): Promise<User[]>;
create(opts: CreateUserOptions): Promise<User>;
delete(id: string): Promise<void>;
rename(id: string, newName: string): Promise<void>;
}
export function makeUserApi(
transport: Transport,
_capabilities: Capabilities,
apiKey: string,
): UserApi {
return {
list: async (filter) => {
const { id, name, email } = filter ?? {};
const moreThanOneFilter = [id, name, email].filter((v) => v !== undefined).length > 1;
if (moreThanOneFilter) {
throw new Error("Only one of id, name, or email filters can be provided");
}
const { users } = await transport.request<{ users: User[] }>({
method: "GET",
path: "v1/user",
apiKey,
query: { id, name, email },
});
return users;
},
create: async ({ name, email, displayName, pictureUrl }) => {
const { user } = await transport.request<{ user: User }>({
method: "POST",
path: "v1/user",
apiKey,
body: { name, email, displayName, pictureUrl },
});
return user;
},
delete: async (id) => {
await transport.request({ method: "DELETE", path: `v1/user/${id}`, apiKey });
},
rename: async (id, newName) => {
await transport.request({
method: "POST",
path: `v1/user/${id}/rename/${encodeURIComponent(newName)}`,
apiKey,
});
},
};
}
@@ -1,98 +0,0 @@
// MARK: ServerVersion
//
// Parses the response from Headscale's `GET /version` endpoint into a
// structured value that capability checks can reason about. The
// endpoint exists in every Headscale release we support (0.27.0+) and
// returns a plain semver-like string such as `v0.28.0`, `v0.28.0-beta.1`,
// or `dev` for untagged builds.
//
// Comparisons (`gte`) are lenient about prerelease tags by design:
// `0.28.0-beta.1` is treated as `0.28.0` for capability gating. The
// behavioural changes that capabilities gate on always land in the
// first prerelease of a minor version, so a strict semver
// interpretation would lock prerelease users out of features that
// their server actually has.
const SEMVER_RE = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$/;
export interface ServerVersion {
readonly major: number;
readonly minor: number;
readonly patch: number;
readonly prerelease: string | undefined;
readonly build: string | undefined;
/** The raw string as reported by Headscale (e.g. `v0.28.0-beta.1`, `dev`). */
readonly raw: string;
/**
* True when the server reported a version we couldn't parse —
* typically `dev` for an untagged Headscale build. Capability checks
* treat unknown versions as having every known capability so we
* exercise the modern code paths against unfamiliar servers rather
* than silently falling back to compatibility shims.
*/
readonly unknown: boolean;
}
export function parseServerVersion(raw: string): ServerVersion {
const match = SEMVER_RE.exec(raw.trim());
if (!match) {
return {
major: 0,
minor: 0,
patch: 0,
prerelease: undefined,
build: undefined,
raw,
unknown: true,
};
}
const [, maj, min, pat, pre, build] = match;
return {
major: Number(maj),
minor: Number(min),
patch: Number(pat),
prerelease: pre,
build,
raw,
unknown: false,
};
}
/** Pretty-print a parsed version for logs (no `v` prefix, includes prerelease). */
export function formatServerVersion(version: ServerVersion): string {
if (version.unknown) return version.raw;
const core = `${version.major}.${version.minor}.${version.patch}`;
return version.prerelease ? `${core}-${version.prerelease}` : core;
}
/**
* Returns true when `version` is at least `target` (ignoring prerelease
* tags — see module-level note). `target` must be a plain semver
* string like `0.28.0`. Throws on a malformed target since those come
* from static capability tables, not user input.
*/
export function gte(version: ServerVersion, target: string): boolean {
if (version.unknown) return true;
const t = parseTarget(target);
if (version.major !== t.major) return version.major > t.major;
if (version.minor !== t.minor) return version.minor > t.minor;
return version.patch >= t.patch;
}
interface TargetVersion {
major: number;
minor: number;
patch: number;
}
function parseTarget(target: string): TargetVersion {
const match = SEMVER_RE.exec(target);
if (!match) {
throw new Error(`Invalid capability target version: ${target}`);
}
return {
major: Number(match[1]),
minor: Number(match[2]),
patch: Number(match[3]),
};
}
-191
View File
@@ -1,191 +0,0 @@
// MARK: Headscale Transport
//
// Internal HTTP transport for talking to a Headscale server. Owns
// the Undici agent (and any custom CA), the base URL, error
// translation, and the distinction between authenticated `/api/v1`
// calls and unauthenticated public endpoints (`/version`, `/health`).
//
// This module is intentionally not exported from the package; all
// consumers should go through `Headscale` and `HeadscaleClient` in
// `./index.ts`, never the transport directly.
import { readFile } from "node:fs/promises";
import { data } from "react-router";
import { Agent, type Dispatcher, request } from "undici";
import log from "~/utils/log";
import { undiciToFriendlyError } from "./error";
import { type HeadscaleAPIError, isApiError } from "./error-client";
export interface TransportRequest {
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
/** API path without the `/api/` prefix (e.g. `v1/node`). */
path: `v1/${string}`;
apiKey: string;
/** JSON request body for non-GET/DELETE requests. */
body?: Record<string, unknown>;
/** Query parameters for GET/DELETE requests. */
query?: Record<string, unknown>;
}
export interface Transport {
/**
* Send an authenticated JSON request against `/api/{path}`.
* Throws a React Router `data()` 502 response on transport errors
* and a typed `HeadscaleAPIError` (wrapped in `data()`) on API
* errors with statusCode >= 400.
*/
request<T>(opts: TransportRequest): Promise<T>;
/**
* Send an unauthenticated GET against the server root
* (e.g. `/version`, `/health`). Returns parsed JSON.
*/
getPublic<T>(path: `/${string}`): Promise<T>;
/** True if `GET /health` returns 200. Never throws. */
health(): Promise<boolean>;
/** Shut down the underlying Undici agent. */
dispose(): Promise<void>;
}
export interface TransportOptions {
url: string;
certPath?: string;
}
export async function createTransport(opts: TransportOptions): Promise<Transport> {
const agent = await createUndiciAgent(opts.certPath);
const baseUrl = opts.url;
async function rawRequest(
url: string,
options: Partial<Dispatcher.RequestOptions> & { method: string },
): Promise<Dispatcher.ResponseData> {
log.debug("api", "%s %s", options.method, url);
try {
return await request(new URL(url, baseUrl), {
dispatcher: agent,
headers: {
...options.headers,
Accept: "application/json",
"User-Agent": `Headplane/${__VERSION__}`,
},
body: options.body,
method: options.method,
});
} catch (error) {
const errorBody = undiciToFriendlyError(error, `${options.method} ${url}`);
throw data(errorBody, { status: 502, statusText: "Bad Gateway" });
}
}
return {
async request<T>({ method, path, apiKey, body, query }: TransportRequest): Promise<T> {
let url = `/api/${path}`;
const options: Partial<Dispatcher.RequestOptions> & { method: string } = {
method,
headers: { Authorization: `Bearer ${apiKey}` },
};
if (query && (method === "GET" || method === "DELETE")) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value !== undefined) {
params.append(key, String(value));
}
}
if ([...params.keys()].length > 0) {
url += `?${params.toString()}`;
}
} else if (body && method !== "GET" && method !== "DELETE") {
options.body = JSON.stringify(body);
options.headers = { ...options.headers, "Content-Type": "application/json" };
}
const res = await rawRequest(url, options);
if (res.statusCode >= 400) {
log.debug("api", "%s %s failed with status %d", method, path, res.statusCode);
const rawData = await res.body.text();
const jsonData = (() => {
try {
return JSON.parse(rawData) as Record<string, unknown>;
} catch {
return null;
}
})();
throw data(
{
requestUrl: `${method} ${path}`,
statusCode: res.statusCode,
rawData,
data: jsonData,
} satisfies HeadscaleAPIError,
{ status: 502, statusText: "Bad Gateway" },
);
}
return res.body.json() as Promise<T>;
},
async getPublic<T>(path: `/${string}`): Promise<T> {
const res = await rawRequest(path, { method: "GET" });
if (res.statusCode >= 400) {
const rawData = await res.body.text();
const jsonData = (() => {
try {
return JSON.parse(rawData) as Record<string, unknown>;
} catch {
return null;
}
})();
throw data(
{
requestUrl: `GET ${path}`,
statusCode: res.statusCode,
rawData,
data: jsonData,
} satisfies HeadscaleAPIError,
{ status: 502, statusText: "Bad Gateway" },
);
}
return res.body.json() as Promise<T>;
},
async health() {
try {
const res = await rawRequest("/health", { method: "GET" });
// Drain the body so the connection can be reused.
await res.body.dump();
return res.statusCode === 200;
} catch (error) {
if (isApiError(error)) {
log.debug("api", "Health check failed: %d", error.statusCode);
}
return false;
}
},
async dispose() {
await agent.close();
},
};
}
async function createUndiciAgent(certPath?: string): Promise<Agent> {
if (!certPath) return new Agent();
try {
log.debug("config", "Loading certificate from %s", certPath);
const cert = await readFile(certPath, "utf8");
log.info("config", "Using certificate from %s", certPath);
return new Agent({ connect: { ca: cert.trim() } });
} catch (error) {
log.error("config", "Failed to load Headscale TLS cert: %s", error);
log.debug("config", "Error Details: %o", error);
return new Agent();
}
}
+86
View File
@@ -0,0 +1,86 @@
import canonicals from "~/openapi-canonical-families.json";
import hashes from "~/openapi-operation-hashes.json";
import log from "~/utils/log";
/**
* The known API versions based on operation hashes.
*/
export type Version = keyof typeof hashes;
const VERSIONS = Object.keys(hashes) as Version[];
/**
* Detects the closest matching API version using operation hashes.
* Falls back to the latest known version and emits a warning if unknown.
*
* @param observed - A mapping of operation identifiers to their hashes.
* @returns The detected API version.
*/
export function detectApiVersion(observed: Record<string, string> | null): Version {
if (!observed) {
const latest = VERSIONS.at(-1)!;
log.warn("api", "No operation hashes observed, defaulting to version %s", latest);
return latest;
}
let bestVersion: Version | null = null;
let bestScore = -1;
for (const [version, known] of Object.entries(hashes) as [Version, Record<string, string>][]) {
let score = 0;
for (const [op, hash] of Object.entries(observed)) {
if (known[op] === hash) score++;
}
if (score > bestScore) {
bestVersion = version;
bestScore = score;
}
}
if (!bestVersion || bestScore === 0) {
const latest = VERSIONS.at(-1)!;
log.warn("api", "Could not determine API version, defaulting to %s", latest);
return latest;
}
if (bestScore < Object.keys(observed).length) {
log.warn(
"api",
"Partial version match: %d/%d endpoints for version %s",
bestScore,
Object.keys(observed).length,
bestVersion,
);
}
const canonical = Object.entries(canonicals).find(([_, family]) =>
family.includes(bestVersion),
)?.[0] as Version | undefined;
if (!canonical) {
log.warn("api", "Could not canonicalize detected version %s, using as-is", bestVersion);
return bestVersion;
}
if (canonical !== bestVersion) {
log.info(
"api",
"Canonicalizing detected version %s → %s (same schema)",
bestVersion,
canonical,
);
}
return canonical;
}
/**
* Checks if the current version is at least the baseline version.
*
* @param current - The current API version.
* @param baseline - The baseline API version to compare against.
* @returns True if current is at least baseline, false otherwise.
*/
export function isAtLeast(current: Version, baseline: Version) {
return VERSIONS.indexOf(current) >= VERSIONS.indexOf(baseline);
}
+10 -10
View File
@@ -1,6 +1,6 @@
import log from "~/utils/log";
import type { HeadscaleClient } from "./api";
import type { RuntimeApiClient } from "./api/endpoints";
/**
* Defines a resource that can be fetched and polled by the live store.
@@ -19,7 +19,7 @@ export interface ResourceDefinition<T> {
/**
* A callback to fire to get the latest data for this resource
*/
readonly fetch: (client: HeadscaleClient) => Promise<T>;
readonly fetch: (client: RuntimeApiClient) => Promise<T>;
}
/**
@@ -37,12 +37,12 @@ export function defineResource<T>(
export const nodesResource = defineResource("nodes", {
pollInterval: 5_000,
fetch: (api) => api.nodes.list(),
fetch: (api) => api.getNodes(),
});
export const usersResource = defineResource("users", {
pollInterval: 15_000,
fetch: (api) => api.users.list(),
fetch: (api) => api.getUsers(),
});
interface Snapshot<T> {
@@ -54,8 +54,8 @@ interface Snapshot<T> {
type ChangeListener = (resourceKey: string, version: string) => void;
export interface LiveStore {
get<T>(resource: ResourceDefinition<T>, apiClient: HeadscaleClient): Promise<Snapshot<T>>;
refresh<T>(resource: ResourceDefinition<T>, apiClient: HeadscaleClient): Promise<void>;
get<T>(resource: ResourceDefinition<T>, apiClient: RuntimeApiClient): Promise<Snapshot<T>>;
refresh<T>(resource: ResourceDefinition<T>, apiClient: RuntimeApiClient): Promise<void>;
getVersions(): Record<string, string>;
subscribe(listener: ChangeListener): () => void;
dispose(): void;
@@ -66,7 +66,7 @@ export function createLiveStore(resources: ResourceDefinition<unknown>[]): LiveS
const serializedCache = new Map<string, string>();
const listeners = new Set<ChangeListener>();
const intervals = new Map<string, ReturnType<typeof setInterval>>();
let storedApiClient: HeadscaleClient | undefined;
let storedApiClient: RuntimeApiClient | undefined;
let versionCounter = 0;
function notifyListeners(resourceKey: string, version: string) {
@@ -77,7 +77,7 @@ export function createLiveStore(resources: ResourceDefinition<unknown>[]): LiveS
async function fetchResource(
resource: ResourceDefinition<unknown>,
apiClient: HeadscaleClient,
apiClient: RuntimeApiClient,
): Promise<void> {
const data = await resource.fetch(apiClient);
const json = JSON.stringify(data);
@@ -138,7 +138,7 @@ export function createLiveStore(resources: ResourceDefinition<unknown>[]): LiveS
return {
async get<T>(
resource: ResourceDefinition<T>,
apiClient: HeadscaleClient,
apiClient: RuntimeApiClient,
): Promise<Snapshot<T>> {
storedApiClient = apiClient;
const def = findResource(resource.key);
@@ -154,7 +154,7 @@ export function createLiveStore(resources: ResourceDefinition<unknown>[]): LiveS
return snapshots.get(resource.key) as Snapshot<T>;
},
async refresh<T>(resource: ResourceDefinition<T>, apiClient: HeadscaleClient): Promise<void> {
async refresh<T>(resource: ResourceDefinition<T>, apiClient: RuntimeApiClient): Promise<void> {
storedApiClient = apiClient;
const def = findResource(resource.key);
if (!def) {
+115
View File
@@ -0,0 +1,115 @@
import { versions } from "node:process";
import { serveStatic } from "@hono/node-server/serve-static";
import { createHonoFateHandler } from "@nkzw/fate/server";
import { Hono, type Context } from "hono";
import type { AppContext } from "./context";
import { fate, type HonoFateEnv } from "./fate";
interface HonoAppOptions {
context: AppContext;
prefix: string;
staticRoot?: string;
}
export function createHeadplaneHonoApp({ context, prefix, staticRoot }: HonoAppOptions) {
const app = new Hono<HonoFateEnv>();
const fateHandler = createHonoFateHandler(fate);
app.use("*", async (c, next) => {
c.set("appContext", context);
await next();
});
const health = async (c: Context) => {
const api = context.hsApi.getRuntimeClient("fake-api-key");
const healthy = await api.isHealthy();
return c.json({ status: healthy ? "OK" : "ERROR" }, healthy ? 200 : 500);
};
app.get("/healthz", health);
app.get(`${prefix}/healthz`, health);
app.get(`${prefix}/api/info`, async (c) => {
if (context.config.server.info_secret == null) {
return c.json({ status: "Forbidden" }, 403);
}
const bearer = c.req.header("Authorization") ?? "";
if (!bearer.startsWith("Bearer ")) {
return c.json({ status: "Unauthorized" }, 401);
}
const token = bearer.slice("Bearer ".length).trim();
if (token !== context.config.server.info_secret) {
return c.json({ status: "Forbidden" }, 403);
}
const api = context.hsApi.getRuntimeClient("fake-api-key");
const healthy = await api.isHealthy();
return c.json({
status: healthy ? "healthy" : "unhealthy",
headplane_version: __VERSION__,
headscale_canonical_version: healthy ? context.hsApi.apiVersion : "unknown",
internal_versions: {
node: versions.node,
v8: versions.v8,
uv: versions.uv,
zlib: versions.zlib,
openssl: versions.openssl,
libc: versions.libc,
},
});
});
app.get(`${prefix}/api/session`, async (c) => {
try {
const principal = await context.auth.require(c.req.raw);
if (principal.kind === "api_key") {
return c.json({
authenticated: true,
principal: {
kind: principal.kind,
sessionId: principal.sessionId,
displayName: principal.displayName,
},
});
}
return c.json({
authenticated: true,
principal: {
kind: principal.kind,
sessionId: principal.sessionId,
user: principal.user,
profile: principal.profile,
},
});
} catch {
return c.json({ authenticated: false }, 401);
}
});
app.all(`${prefix}/fate`, fateHandler);
app.all(`${prefix}/fate/*`, fateHandler);
if (staticRoot) {
const stripPrefix = (path: string) => path.slice(prefix.length) || "/";
app.get(prefix, (c) => c.redirect(`${prefix}/`));
app.use(
`${prefix}/*`,
serveStatic({
root: staticRoot,
rewriteRequestPath: stripPrefix,
}),
);
app.get(`${prefix}/*`, serveStatic({ root: staticRoot, path: "index.html" }));
}
return app;
}
+87
View File
@@ -0,0 +1,87 @@
import { createServer } from "node:http";
import { exit } from "node:process";
import { getRequestListener } from "@hono/node-server";
import { createServer as createViteServer } from "vite";
import log from "~/utils/log";
import { ConfigError } from "./config/error";
import { loadConfig } from "./config/load";
import { createAppContext } from "./context";
import { createHeadplaneHonoApp } from "./hono-app";
const PREFIX = process.env.__INTERNAL_PREFIX || "/admin";
(globalThis as Record<string, unknown>).__PREFIX__ = PREFIX;
(globalThis as Record<string, unknown>).__VERSION__ = process.env.HEADPLANE_VERSION ?? "dev";
let config;
try {
config = await loadConfig();
} catch (error) {
if (error instanceof ConfigError) {
log.error("server", "Unable to load configuration: %s", error.message);
} else {
log.error("server", "Failed to load configuration: %s", error);
}
exit(1);
}
const context = await createAppContext(config);
context.auth.start();
const app = createHeadplaneHonoApp({ context, prefix: PREFIX });
const honoListener = getRequestListener(app.fetch);
const vite = await createViteServer({
appType: "spa",
server: {
middlewareMode: true,
},
});
const server = createServer((req, res) => {
if (shouldUseHono(req.url)) {
void honoListener(req, res);
return;
}
vite.middlewares(req, res, (error?: unknown) => {
if (error) {
if (error instanceof Error) {
vite.ssrFixStacktrace(error);
}
log.error("server", "Vite middleware failed: %s", error);
res.statusCode = 500;
res.end("Internal Server Error");
return;
}
void honoListener(req, res);
});
});
server.listen(config.server.port, config.server.host, () => {
log.info("server", "Listening on http://%s:%s", config.server.host, config.server.port);
});
function shouldUseHono(rawUrl: string | undefined) {
if (!rawUrl) {
return false;
}
let pathname;
try {
pathname = new URL(rawUrl, "http://localhost").pathname;
} catch {
return false;
}
return (
pathname === "/healthz" ||
pathname === `${PREFIX}/healthz` ||
pathname === `${PREFIX}/fate` ||
pathname.startsWith(`${PREFIX}/fate/`) ||
pathname.startsWith(`${PREFIX}/api/`)
);
}
+46
View File
@@ -0,0 +1,46 @@
import { exit } from "node:process";
import { serve } from "@hono/node-server";
import log from "~/utils/log";
import { ConfigError } from "./config/error";
import { loadConfig } from "./config/load";
import { createAppContext } from "./context";
import { createHeadplaneHonoApp } from "./hono-app";
const PREFIX = process.env.__INTERNAL_PREFIX || "/admin";
(globalThis as Record<string, unknown>).__PREFIX__ = PREFIX;
(globalThis as Record<string, unknown>).__VERSION__ = process.env.HEADPLANE_VERSION ?? "dev";
let config;
try {
config = await loadConfig();
} catch (error) {
if (error instanceof ConfigError) {
log.error("server", "Unable to load configuration: %s", error.message);
} else {
log.error("server", "Failed to load configuration: %s", error);
}
exit(1);
}
const context = await createAppContext(config);
context.auth.start();
const app = createHeadplaneHonoApp({
context,
prefix: PREFIX,
staticRoot: "build/client",
});
serve(
{
fetch: app.fetch,
hostname: config.server.host,
port: config.server.port,
},
(info) => {
log.info("server", "Listening on http://%s:%s", info.address, info.port);
},
);
+8 -12
View File
@@ -11,7 +11,7 @@ import log from "~/utils/log";
import { HeadplaneConfig } from "./config/config-schema";
import { hostInfo } from "./db/schema";
import type { HeadscaleClient } from "./headscale/api";
import { RuntimeApiClient } from "./headscale/api/endpoints";
export interface AgentManager {
lookup(nodeKeys: string[]): Promise<Record<string, HostInfo>>;
@@ -46,7 +46,7 @@ async function hasExistingState(workDir: string): Promise<boolean> {
export async function createAgentManager(
agentConfig: NonNullable<NonNullable<HeadplaneConfig["integration"]>["agent"]> | undefined,
headscaleUrl: string,
apiClient: HeadscaleClient,
apiClient: RuntimeApiClient,
supportsTagOnlyKeys: boolean,
db: NodeSQLiteDatabase,
): Promise<AgentManager | undefined> {
@@ -101,13 +101,9 @@ export async function createAgentManager(
async function generateAuthKey(): Promise<string> {
const expiration = new Date(Date.now() + 5 * 60_000);
const pak = await apiClient.preAuthKeys.create({
user: null,
ephemeral: false,
reusable: false,
expiration,
aclTags: [`tag:${hostName}`],
});
const pak = await apiClient.createPreAuthKey(null, false, false, expiration, [
`tag:${hostName}`,
]);
return pak.key;
}
@@ -276,7 +272,7 @@ export async function createAgentManager(
*/
async function pruneStaleHostInfo() {
try {
const nodes = await apiClient.nodes.list();
const nodes = await apiClient.getNodes();
const activeKeys = nodes.map((n) => n.nodeKey);
if (activeKeys.length === 0) {
@@ -302,11 +298,11 @@ export async function createAgentManager(
async function pruneEphemeralNodes() {
try {
const nodes = await apiClient.nodes.list();
const nodes = await apiClient.getNodes();
const toPrune = nodes.filter((n) => n.preAuthKey?.ephemeral && !n.online);
for (const node of toPrune) {
await apiClient.nodes.delete(node.id);
await apiClient.deleteNode(node.id);
log.info("agent", "Pruned offline ephemeral node %s", node.givenName);
}
} catch (error) {
+2 -44
View File
@@ -8,65 +8,23 @@
// Vite, and the dev-only `runtime/vite-plugin.ts` dispatches requests
// straight to `./app`'s default export.
import { readFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { exit } from "node:process";
import { fileURLToPath } from "node:url";
import log from "~/utils/log";
import { type StartOptions, composeListener, startHttpServer } from "../../runtime/http";
import requestListener, { config, dispose } from "./app";
import { composeListener, startHttpServer } from "../../runtime/http";
import requestListener, { config } from "./app";
// `import.meta.url` resolves to `build/server/index.js`; the built
// client lives next to it at `build/client/`.
const clientDir = resolve(dirname(fileURLToPath(import.meta.url)), "../client");
let tls: StartOptions["tls"];
const { tls_cert_path: certPath, tls_key_path: keyPath } = config.server;
if (certPath || keyPath) {
if (!certPath || !keyPath) {
log.error(
"server",
"TLS misconfigured: both `server.tls_cert_path` and `server.tls_key_path` must be provided",
);
exit(1);
}
try {
const [cert, key] = await Promise.all([readFile(certPath), readFile(keyPath)]);
tls = { cert, key };
} catch (err) {
log.error("server", "Failed to read TLS material: %s", err);
exit(1);
}
}
// `HEADPLANE_LISTEN_FILE` is a Docker-specific contract: the
// Dockerfile sets it to `/tmp/headplane-listen` so the bundled
// `hp_healthcheck` binary can discover the URL to probe. Native
// installs don't ship a consumer, so we just skip writing anything
// if the var isn't set.
const listenFilePath = process.env.HEADPLANE_LISTEN_FILE;
const listenFile = listenFilePath
? {
path: listenFilePath,
// Full URL including `__PREFIX__` so the Go binary can GET it
// verbatim — no path joining, no basename knowledge needed.
url: `${tls ? "https" : "http"}://127.0.0.1:${config.server.port}${__PREFIX__}/healthz`,
}
: undefined;
startHttpServer({
host: config.server.host,
port: config.server.port,
tls,
listenFile,
listener: composeListener({
basename: __PREFIX__,
staticRoot: clientDir,
immutableAssets: true,
requestListener,
}),
onShutdown: dispose,
});
+41 -18
View File
@@ -77,12 +77,13 @@ export interface AuthService {
linkHeadscaleUser(userId: string, headscaleUserId: string): Promise<boolean>;
unlinkHeadscaleUser(userId: string): Promise<void>;
linkHeadscaleUserBySubject(subject: string, headscaleUserId: string): Promise<boolean>;
listUsers(): Promise<HeadplaneUser[]>;
claimedHeadscaleUserIds(): Promise<Set<string>>;
roleForSubject(subject: string): Promise<Role | undefined>;
roleForHeadscaleUser(headscaleUserId: string): Promise<Role | undefined>;
transferOwnership(currentOwnerUserId: string, newOwnerUserId: string): Promise<boolean>;
reassignUser(userId: string, role: Role): Promise<boolean>;
transferOwnership(currentOwnerSubject: string, newOwnerSubject: string): Promise<boolean>;
reassignSubject(subject: string, role: Role): Promise<boolean>;
pruneExpiredSessions(): Promise<void>;
start(): void;
stop(): void;
@@ -369,6 +370,23 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
.where(eq(users.id, userId));
}
async function linkHeadscaleUserBySubject(
subject: string,
headscaleUserId: string,
): Promise<boolean> {
const [user] = await opts.db
.select({ id: users.id })
.from(users)
.where(eq(users.sub, subject))
.limit(1);
if (!user) {
return false;
}
return linkHeadscaleUser(user.id, headscaleUserId);
}
async function listUsers(): Promise<HeadplaneUser[]> {
return opts.db.select().from(users);
}
@@ -410,17 +428,13 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
}
async function transferOwnership(
currentOwnerUserId: string,
newOwnerUserId: string,
currentOwnerSubject: string,
newOwnerSubject: string,
): Promise<boolean> {
if (currentOwnerUserId === newOwnerUserId) {
return false;
}
const [current] = await opts.db
.select()
.from(users)
.where(eq(users.id, currentOwnerUserId))
.where(eq(users.sub, currentOwnerSubject))
.limit(1);
if (!current || current.role !== "owner") {
@@ -430,10 +444,10 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
const [target] = await opts.db
.select()
.from(users)
.where(eq(users.id, newOwnerUserId))
.where(eq(users.sub, newOwnerSubject))
.limit(1);
if (!target) {
if (!target || target.id === current.id) {
return false;
}
@@ -450,16 +464,24 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
return true;
}
async function reassignUser(userId: string, role: Role): Promise<boolean> {
const [user] = await opts.db.select().from(users).where(eq(users.id, userId)).limit(1);
if (!user || user.role === "owner") {
async function reassignSubject(subject: string, role: Role): Promise<boolean> {
const currentRole = await roleForSubject(subject);
if (currentRole === "owner") {
return false;
}
await opts.db
.update(users)
.set({ role, caps: capsForRole(role), updated_at: new Date() })
.where(eq(users.id, userId));
.insert(users)
.values({
id: ulid(),
sub: subject,
role,
caps: capsForRole(role),
})
.onConflictDoUpdate({
target: users.sub,
set: { role, caps: capsForRole(role), updated_at: new Date() },
});
return true;
}
@@ -490,12 +512,13 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
findOrCreateUser,
linkHeadscaleUser,
unlinkHeadscaleUser,
linkHeadscaleUserBySubject,
listUsers,
claimedHeadscaleUserIds,
roleForSubject,
roleForHeadscaleUser,
transferOwnership,
reassignUser,
reassignSubject,
pruneExpiredSessions,
start,
stop,
+23
View File
@@ -0,0 +1,23 @@
import { RouterProvider } from "@tanstack/react-router";
import { Suspense } from "react";
import { FateClient } from "react-fate";
import { createFateClient } from "react-fate/client";
import { router } from "./router";
const fate = createFateClient({
fetch: (input, init) => fetch(input, { ...init, credentials: "include" }),
url: `${__PREFIX__}/fate`,
});
export function App() {
return (
<FateClient client={fate}>
<Suspense
fallback={<div className="min-h-screen bg-neutral-950 p-6 text-neutral-400">Loading</div>}
>
<RouterProvider router={router} />
</Suspense>
</FateClient>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "~/tailwind.css";
import { App } from "./app";
const root = document.getElementById("root");
if (!root) {
throw new Error("Unable to find root element");
}
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
);
+326
View File
@@ -0,0 +1,326 @@
import { Link, Outlet, createRootRoute, createRoute, createRouter } from "@tanstack/react-router";
import { useState, useTransition } from "react";
import {
useFateClient,
useLiveListView,
useLiveView,
useRequest,
view,
type ViewRef,
} from "react-fate";
import type { FateMachine, FateUser } from "../server/fate";
const UserView = view<FateUser>()({
displayName: true,
email: true,
id: true,
name: true,
});
const MachineView = view<FateMachine>()({
expiry: true,
givenName: true,
id: true,
ipAddresses: true,
lastSeen: true,
name: true,
online: true,
tags: true,
user: {
displayName: true,
id: true,
name: true,
},
});
const MachineConnectionView = {
args: { first: 100 },
items: {
cursor: true,
node: MachineView,
},
pagination: {
hasNext: true,
hasPrevious: true,
nextCursor: true,
previousCursor: true,
},
};
const UserConnectionView = {
args: { first: 100 },
items: {
cursor: true,
node: UserView,
},
pagination: {
hasNext: true,
hasPrevious: true,
nextCursor: true,
previousCursor: true,
},
};
const rootRoute = createRootRoute({
component: RootLayout,
});
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/",
component: HomePage,
});
const machinesRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/machines",
component: MachinesPage,
});
const usersRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/users",
component: UsersPage,
});
const routeTree = rootRoute.addChildren([indexRoute, machinesRoute, usersRoute]);
export const router = createRouter({
basepath: __PREFIX__,
routeTree,
});
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
function RootLayout() {
return (
<div className="min-h-screen bg-neutral-950 text-white">
<header className="border-b border-white/10 px-6 py-4">
<nav className="flex gap-4 text-sm">
<Link to="/">Home</Link>
<Link to="/machines">Machines</Link>
<Link to="/users">Users</Link>
</nav>
</header>
<main className="p-6">
<Outlet />
</main>
</div>
);
}
function HomePage() {
return (
<section className="space-y-2">
<h1 className="text-2xl font-semibold">Headplane SPA</h1>
<p className="text-neutral-400">
This is the one-way SPA shell. Data should move to raw Fate views and actions.
</p>
</section>
);
}
function MachinesPage() {
const { machines } = useRequest({ machines: { list: MachineConnectionView } });
const [machineItems, loadNext] = useLiveListView(MachineConnectionView, machines);
return (
<section className="space-y-4">
<h1 className="text-2xl font-semibold">Machines</h1>
{machineItems.length === 0 ? (
<p className="rounded-lg border border-white/10 bg-white/5 p-4 text-neutral-400">
No machines returned from Headscale.
</p>
) : (
<div className="overflow-hidden rounded-xl border border-white/10">
<table className="min-w-full divide-y divide-white/10 text-left text-sm">
<thead className="bg-white/5 text-xs tracking-wide text-neutral-400 uppercase">
<tr>
<th className="px-4 py-3 font-medium">Machine</th>
<th className="px-4 py-3 font-medium">User</th>
<th className="px-4 py-3 font-medium">Addresses</th>
<th className="px-4 py-3 font-medium">Status</th>
<th className="px-4 py-3 font-medium">Last seen</th>
</tr>
</thead>
<tbody className="divide-y divide-white/10">
{machineItems.map(({ node: machine }) => (
<MachineRow key={machine.id} machine={machine} />
))}
</tbody>
</table>
</div>
)}
{loadNext ? (
<button
className="rounded-lg border border-white/10 px-3 py-2 text-sm text-neutral-200 hover:bg-white/10"
onClick={() => void loadNext()}
type="button"
>
Load more machines
</button>
) : null}
</section>
);
}
function MachineRow({ machine: machineRef }: { machine: ViewRef<"Machine"> }) {
const fate = useFateClient();
const machine = useLiveView(MachineView, machineRef);
const currentName = machine.givenName || machine.name;
const [name, setName] = useState(currentName);
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const rename = () => {
const nextName = name.trim();
if (!nextName || nextName === currentName) {
return;
}
startTransition(() => {
void (async () => {
setError(null);
const result = await fate.mutations.machine.rename({
input: { id: machine.id, name: nextName },
view: MachineView,
});
if (result.error) {
setError(result.error.message);
return;
}
setName(nextName);
})();
});
};
return (
<tr className="bg-neutral-950/80">
<td className="px-4 py-3 align-top">
<div className="font-medium text-white">{currentName}</div>
<div className="text-xs text-neutral-500">{machine.id}</div>
<form
className="mt-2 flex max-w-sm gap-2"
onSubmit={(event) => {
event.preventDefault();
rename();
}}
>
<input
className="min-w-0 flex-1 rounded-md border border-white/10 bg-neutral-900 px-2 py-1 text-xs text-white outline-none focus:border-cyan-400"
disabled={isPending}
onChange={(event) => setName(event.currentTarget.value)}
value={name}
/>
<button
className="rounded-md border border-white/10 px-2 py-1 text-xs text-neutral-200 hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-50"
disabled={isPending || !name.trim() || name.trim() === currentName}
type="submit"
>
{isPending ? "Saving" : "Rename"}
</button>
</form>
{error ? <div className="mt-1 text-xs text-red-300">{error}</div> : null}
{machine.tags.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1">
{machine.tags.map((tag) => (
<span
className="rounded-full bg-cyan-400/10 px-2 py-0.5 text-xs text-cyan-200"
key={tag}
>
{tag}
</span>
))}
</div>
) : null}
</td>
<td className="px-4 py-3 align-top text-neutral-300">
{machine.user ? machine.user.displayName || machine.user.name : "Tag-owned"}
</td>
<td className="px-4 py-3 align-top text-neutral-300">
<div className="flex flex-col gap-1 font-mono text-xs">
{machine.ipAddresses.map((ip) => (
<span key={ip}>{ip}</span>
))}
</div>
</td>
<td className="px-4 py-3 align-top">
<span
className={
machine.online
? "rounded-full bg-green-400/10 px-2 py-1 text-xs text-green-200"
: "rounded-full bg-neutral-700 px-2 py-1 text-xs text-neutral-300"
}
>
{machine.online ? "Online" : "Offline"}
</span>
</td>
<td className="px-4 py-3 align-top text-neutral-300">{formatDate(machine.lastSeen)}</td>
</tr>
);
}
function UsersPage() {
const { users } = useRequest({ users: { list: UserConnectionView } });
const [userItems, loadNext] = useLiveListView(UserConnectionView, users);
return (
<section className="space-y-4">
<h1 className="text-2xl font-semibold">Users</h1>
{userItems.length === 0 ? (
<p className="rounded-lg border border-white/10 bg-white/5 p-4 text-neutral-400">
No users returned from Headscale.
</p>
) : (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{userItems.map(({ node: user }) => (
<UserCard key={user.id} user={user} />
))}
</div>
)}
{loadNext ? (
<button
className="rounded-lg border border-white/10 px-3 py-2 text-sm text-neutral-200 hover:bg-white/10"
onClick={() => void loadNext()}
type="button"
>
Load more users
</button>
) : null}
</section>
);
}
function UserCard({ user: userRef }: { user: ViewRef<"User"> }) {
const user = useLiveView(UserView, userRef);
return (
<article className="rounded-xl border border-white/10 bg-white/5 p-4">
<div className="font-medium text-white">{user.displayName || user.name}</div>
<div className="text-sm text-neutral-400">{user.name}</div>
{user.email ? <div className="mt-2 text-sm text-neutral-300">{user.email}</div> : null}
<div className="mt-3 font-mono text-xs text-neutral-500">{user.id}</div>
</article>
);
}
function formatDate(value: string | null | undefined) {
if (!value) return "Never";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString();
}
+2 -41
View File
@@ -1,46 +1,18 @@
package main
import (
"crypto/tls"
"fmt"
"net/http"
"os"
"strings"
"time"
)
// hp_healthcheck pings the Headplane healthz endpoint. It's invoked
// from the Docker HEALTHCHECK directive inside the same container
// as Headplane itself.
//
// To stay fully zero-config, Headplane writes the exact URL the
// healthcheck should hit — scheme, port, and basename included — to
// listenFile when it starts accepting connections (see
// runtime/http.ts and app/server/main.ts). This binary just reads
// that file and GETs the URL verbatim. No env vars, no YAML
// parsing, no path-joining, no compile-time knowledge of the
// basename.
//
// If the file is missing (e.g. the server hasn't finished booting
// on the very first probe, or this is an old image being run with
// a new healthcheck) we fall back to the historical default.
const (
listenFile = "/tmp/headplane-listen"
defaultURL = "http://localhost:3000/admin/healthz"
)
func main() {
url := readListenFile()
client := http.Client{
Timeout: 5 * time.Second,
Transport: &http.Transport{
// Self-signed certs are normal for in-process TLS termination
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
resp, err := client.Get(url)
resp, err := client.Get("http://localhost:3000/admin/healthz")
if err != nil {
fmt.Fprintf(os.Stderr, "Health check failed: %v\n", err)
os.Exit(1)
@@ -53,16 +25,5 @@ func main() {
}
fmt.Println("Health check passed.")
}
func readListenFile() string {
data, err := os.ReadFile(listenFile)
if err != nil {
return defaultURL
}
url := strings.TrimSpace(string(data))
if url == "" {
return defaultURL
}
return url
os.Exit(0)
}
-16
View File
@@ -17,19 +17,8 @@ server:
# Whether cookies should be marked as Secure
# * Should be false if running without HTTPs
# * Should be true if running behind a reverse proxy with HTTPs
# When `tls_cert_path`/`tls_key_path` are set below this is forced
# to true automatically.
cookie_secure: true
# Optional TLS termination. When both `tls_cert_path` and
# `tls_key_path` are set, Headplane serves HTTPS on `server.port`.
# Both files must be PEM encoded.
#
# If you are terminating TLS at a reverse proxy (recommended for most
# deployments) leave these unset.
# tls_cert_path: "/var/lib/headplane/tls/fullchain.pem"
# tls_key_path: "/var/lib/headplane/tls/privkey.pem"
# The maximum age of the session cookie in seconds
cookie_max_age: 86400 # 1 day in seconds
@@ -69,11 +58,6 @@ headscale:
# If you use the TLS configuration in Headscale, and you are not using
# Let's Encrypt for your certificate, pass in the path to the certificate.
# (This has no effect if `url` does not start with `https://`)
#
# Note: this pins Headscale's connection to exactly this one cert. If you
# have a private CA you want trusted everywhere (Headscale, OIDC, Docker,
# etc.), set `NODE_EXTRA_CA_CERTS=/path/to/ca.pem` on the Headplane
# process instead — see https://headplane.net/configuration/tls#custom-certificate-authorities
# tls_cert_path: "/var/lib/headplane/tls.crt"
# Optional, public URL if its different from the `headscale.url`
-1
View File
@@ -38,7 +38,6 @@ export default defineConfig({
link: "/configuration",
items: [
{ text: "Common Issues", link: "/configuration/common-issues" },
{ text: "TLS & Certificates", link: "/configuration/tls" },
{
text: "Sensitive Values",
link: "/configuration#sensitive-values",
-7
View File
@@ -86,13 +86,6 @@ To enable debug logging, set the **`HEADPLANE_DEBUG_LOG=true`** environment vari
This will enable all debug logs for Headplane, which could fill up log space very quickly.
This is not recommended in production environments.
## TLS & Certificates
Anything to do with TLS — terminating HTTPS in-process, trusting a private
CA for outbound connections to Headscale or your OIDC provider, the
interaction with `cookie_secure` and the Docker healthcheck — lives on its
own page: [TLS & Certificates](./tls).
## Reverse Proxying
Reverse proxying is very common when deploying web applications. Headscale and
-80
View File
@@ -1,80 +0,0 @@
# TLS & Certificates
Everything Headplane does with TLS lives on this page: terminating HTTPS
in-process, trusting private certificate authorities for outbound
connections, and the interactions with cookies and the bundled Docker
healthcheck.
## Custom Certificate Authorities
If you front any of the services Headplane talks to — your Headscale server,
your OIDC provider, an HTTPS Docker daemon, etc. — with a private or
self-signed certificate authority, Headplane needs to trust that CA. The
cleanest way to do that is the standard Node.js `NODE_EXTRA_CA_CERTS`
environment variable, which is honoured for **every** outbound TLS
connection in the process (OIDC discovery, Headscale API, Docker, anything
else that uses `fetch` or `https`).
Provide it as a path to a PEM-encoded bundle of one or more CA certificates:
```bash
NODE_EXTRA_CA_CERTS=/etc/headplane/extra-cas.pem
```
In Docker, bind-mount the bundle and pass the variable through:
```yaml
services:
headplane:
image: ghcr.io/tale/headplane:latest
environment:
NODE_EXTRA_CA_CERTS: /etc/headplane/extra-cas.pem
volumes:
- "./internal-ca.pem:/etc/headplane/extra-cas.pem:ro"
- "./config.yaml:/etc/headplane/config.yaml"
- "./headplane-data:/var/lib/headplane"
```
The bundle is added **on top of** the system trust store, so public
certificates (Let's Encrypt, ZeroSSL, etc.) keep working.
> `headscale.tls_cert_path` is a narrower knob that pins Headscale's API
> connection to exactly one certificate, bypassing the rest of the trust
> store. It still has its place if you want Headplane to refuse anything
> other than that specific cert for Headscale, but `NODE_EXTRA_CA_CERTS` is
> the right tool whenever you simply want to add a CA to the trusted set.
## TLS Termination
Headplane can terminate TLS itself when both `server.tls_cert_path` and
`server.tls_key_path` are set in the configuration file. Both must point to
PEM-encoded files that Headplane can read.
```yaml
server:
port: 443
tls_cert_path: "/var/lib/headplane/tls/fullchain.pem"
tls_key_path: "/var/lib/headplane/tls/privkey.pem"
```
When TLS is configured Headplane serves HTTPS/1.1 on `server.port`. HTTP/2
and HTTP/3 are intentionally not supported in-process — terminate those at a
reverse proxy (e.g. Caddy or Traefik) and forward to Headplane over HTTP/1.1
if you need them today.
`server.cookie_secure` is forced to `true` whenever TLS is enabled (browsers
refuse `Secure`-less cookies over HTTPS); a warning is logged if your config
had it set to `false`.
For most deployments we still recommend terminating TLS at a reverse proxy
(see [Reverse Proxying](./#reverse-proxying)) so you can share certificates
with Headscale and other services. Built-in TLS is meant for the simpler
"Headplane on a single box" scenarios.
## Healthcheck
The bundled Docker healthcheck picks up the right scheme and port
automatically — Headplane writes its loopback URL to `/tmp/headplane-listen`
when it starts, and the healthcheck reads it from there. So flipping TLS on
or off requires no healthcheck-specific configuration; everything just
works.
+2 -2
View File
@@ -171,7 +171,7 @@ export interface AppRuntime {
db: DbClient;
auth: AuthService;
oidc?: OidcService;
headscale: Headscale;
hsApi: HeadscaleInterface;
agents?: AgentManager;
stop(): Promise<void>;
}
@@ -300,7 +300,7 @@ function createTestRuntime(overrides: Partial<AppRuntime> = {}): AppRuntime {
config: testConfig,
db: createTestDb(),
auth: createTestAuth(),
headscale: createTestHeadscale(),
hsApi: createTestHsApi(),
stop: async () => {},
...overrides,
};
+4 -4
View File
@@ -18,7 +18,7 @@ with Docker.
## Prerequisites
- Docker and Docker Compose
- Headscale version 0.27.0 or later installed and running
- Headscale version 0.26.0 or later installed and running
- A [completed configuration file](./index.md#configuration) for Headplane.
## Installation
@@ -57,7 +57,7 @@ services:
headplane:
image: ghcr.io/tale/headplane:latest
healthcheck:
test: ["CMD", "/bin/hp_healthcheck"]
test: ["/bin/hp_healthcheck"]
interval: 30s
timeout: 5s
start_period: 5s
@@ -132,7 +132,7 @@ services:
# Read-only access to the Docker socket (or a proxy)
- "/var/run/docker.sock:/var/run/docker.sock:ro"
headscale:
image: headscale/headscale:0.27.1
image: headscale/headscale:0.26.0
container_name: headscale
restart: unless-stopped
command: serve
@@ -239,7 +239,7 @@ services:
- "traefik.http.routers.headplane.entrypoints=websecure"
- "traefik.http.routers.headplane.tls=true"
headscale:
image: headscale/headscale:0.27.1
image: headscale/headscale:0.26.0
container_name: headscale
restart: unless-stopped
command: serve
+1 -1
View File
@@ -19,7 +19,7 @@ advanced features, making it suitable for local testing and development.
## Prerequisites
- Docker (and optionally Docker Compose)
- Headscale version 0.27.0 or later installed and running
- Headscale version 0.26.0 or later installed and running
- A [completed configuration file](/index.md#configuration) for Headplane.
## Installation
+1 -1
View File
@@ -20,7 +20,7 @@ or prefer to avoid containers.
- A Linux-based operating system (e.g, Ubuntu, Debian, CentOS, Fedora)
- Go version 1.25.1 installed (only needed to build Headplane)
- Node.js version 22.16.x and [pnpm](https://pnpm.io/) version 10.4.x installed
- Headscale version 0.27.0 or later installed and running
- Headscale version 0.26.0 or later installed and running
- A [completed configuration file](./index.md#configuration) for Headplane.
Before building and running Headplane, ensure that the directory defined in
Generated
+3 -3
View File
@@ -40,11 +40,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1779536132,
"narHash": "sha256-q+fF42iv/geEbHfgSzy3tS0FF/EyD6XTZ98E6yxiBO8=",
"lastModified": 1776949667,
"narHash": "sha256-GMSVw35Q+294GlrTUKlx087E31z7KurReQ1YHSKp5iw=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "3d8f0f3f72a6cd4d93d0ad13203f2ea1cb7e1456",
"rev": "01fbdeef22b76df85ea168fbfe1bfd9e63681b30",
"type": "github"
},
"original": {
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Headplane</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/app/spa/main.tsx"></script>
</body>
</html>
+1 -1
View File
@@ -33,7 +33,7 @@ in
inherit (finalAttrs) pname version src;
fetcherVersion = 3;
pnpm = pnpm_10;
hash = "sha256-msrc7poYMCAdhny0qyOUYo1Qt6FKIobtxLJ479E5O0g=";
hash = "sha256-NGIeboj/2kXuWsmTVl1fv4LgU1VYRdO+qSnNLVuneC8=";
};
buildPhase = ''
+15 -5
View File
@@ -1,20 +1,21 @@
{
"name": "headplane",
"version": "0.7.0-beta.4",
"version": "0.7.0-beta.3",
"private": true,
"type": "module",
"sideEffects": false,
"scripts": {
"preinstall": "npx only-allow pnpm",
"build": "react-router build",
"dev": "HEADPLANE_CONFIG_PATH=./config.example.yaml react-router dev",
"start": "node build/server/index.js",
"typecheck": "react-router typegen && tsgo",
"build": "vite build",
"dev": "HEADPLANE_CONFIG_PATH=./config.example.yaml HEADPLANE_SERVER__DATA_PATH=./.data tsx app/server/hono-dev.ts",
"start": "tsx app/server/hono-main.ts",
"typecheck": "fate generate && react-router typegen && tsgo",
"test:unit": "vitest run --project unit",
"test:integration": "vitest run --project integration:*",
"docs:dev": "vitepress dev docs",
"docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs",
"gen:hashes": "tsx tests/generate-openapi-hashes.ts",
"lint": "oxlint",
"format": "oxfmt"
},
@@ -27,23 +28,30 @@
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/inter": "^5.2.8",
"@hono/node-server": "^2.0.2",
"@iconify/react": "^6.0.2",
"@kubernetes/client-node": "^1.4.0",
"@lezer/highlight": "^1.2.3",
"@nkzw/fate": "^1.0.2",
"@react-router/node": "^7.14.0",
"@readme/openapi-parser": "^6.0.1",
"@tanstack/react-router": "^1.170.3",
"@uiw/react-codemirror": "4.25.9",
"arktype": "^2.2.0",
"clsx": "^2.1.1",
"drizzle-orm": "1.0.0-beta.21",
"hono": "^4.12.19",
"isbot": "5.1.37",
"jose": "6.2.2",
"js-yaml": "^4.1.1",
"lucide-react": "^1.8.0",
"mime": "^4.1.0",
"openapi-types": "^12.1.3",
"react": "19.2.5",
"react-codemirror-merge": "4.25.9",
"react-dom": "19.2.5",
"react-error-boundary": "^6.1.1",
"react-fate": "^1.0.2",
"react-router": "^7.14.0",
"react-router-dom": "^7.14.0",
"restty": "^0.1.35",
@@ -56,11 +64,13 @@
"@react-router/dev": "^7.14.0",
"@shopify/lang-jsonc": "^1.0.1",
"@tailwindcss/vite": "^4.2.2",
"@tanstack/router-plugin": "^1.168.4",
"@types/js-yaml": "^4.0.9",
"@types/node": "^25.6.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@typescript/native-preview": "7.0.0-dev.20260410.1",
"@vitejs/plugin-react": "^6.0.2",
"drizzle-kit": "1.0.0-beta.21",
"lefthook": "^2.1.5",
"oxfmt": "^0.44.0",
+601 -28
View File
File diff suppressed because it is too large Load Diff
+5 -37
View File
@@ -4,7 +4,7 @@
// React Router request listener from `@react-router/node`) and serves
// static assets out of a directory.
import { createReadStream } from "node:fs";
import { stat, writeFile } from "node:fs/promises";
import { stat } from "node:fs/promises";
import {
type IncomingMessage,
type RequestListener,
@@ -172,21 +172,6 @@ export interface StartOptions {
listener: RequestListener;
tls?: HttpsServerOptions;
logger?: Logger;
/**
* If set, writes `url` to `path` once the server is accepting
* connections. Used by the bundled Docker healthcheck binary to
* discover the right scheme, port, and basename without any
* duplicate configuration. The caller assembles the URL so that
* the runtime stays generic.
*/
listenFile?: { path: string; url: string };
/**
* Optional async hook invoked on SIGINT/SIGTERM after the HTTP
* server stops accepting new connections but before the process
* exits. Use this to dispose long-lived resources (timers,
* subprocesses, DB handles).
*/
onShutdown?: () => Promise<void> | void;
}
/**
@@ -202,34 +187,17 @@ export function startHttpServer(opts: StartOptions): Server {
server.listen(opts.port, opts.host, () => {
const proto = opts.tls ? "https" : "http";
log.info("Listening on %s://%s:%s", proto, opts.host, opts.port);
if (opts.listenFile) {
const { path, url } = opts.listenFile;
writeFile(path, `${url}\n`, "utf8").catch((err) => {
log.error("Failed to write listen file %s: %s", path, err);
});
}
});
const shutdown = async (signal: string) => {
const shutdown = (signal: string) => {
log.info("Received %s, shutting down...", signal);
server.close(async () => {
if (opts.onShutdown) {
try {
await opts.onShutdown();
} catch (err) {
log.error("Error during shutdown hook: %o", err);
}
}
process.exit(0);
});
server.close(() => process.exit(0));
// Force exit if connections don't drain in time.
setTimeout(() => process.exit(0), 5_000).unref();
};
process.once("SIGINT", () => void shutdown("SIGINT"));
process.once("SIGTERM", () => void shutdown("SIGTERM"));
process.once("SIGINT", () => shutdown("SIGINT"));
process.once("SIGTERM", () => shutdown("SIGTERM"));
return server;
}
+3 -20
View File
@@ -18,11 +18,6 @@ export interface DevServerOptions {
publicDir: string;
}
interface AppModule {
default: RequestListener;
dispose?: () => Promise<void> | void;
}
export function headplaneDevServer(options: DevServerOptions): Plugin {
return {
name: "headplane:dev-server",
@@ -35,12 +30,6 @@ export function headplaneDevServer(options: DevServerOptions): Plugin {
res.end("Server entry not loaded yet");
};
// Track the last-loaded module identity so we can call its
// `dispose` when Vite swaps in a new one on HMR. Without this
// the LiveStore and other long-lived services leak intervals
// and subprocesses on every reload.
let currentModule: AppModule | null = null;
const composed = composeListener({
basename: options.basename,
staticRoot: options.publicDir,
@@ -54,15 +43,9 @@ export function headplaneDevServer(options: DevServerOptions): Plugin {
return () => {
server.middlewares.use(async (req, res, next) => {
try {
const mod = (await server.ssrLoadModule(options.entry)) as AppModule;
if (currentModule && currentModule !== mod) {
try {
await currentModule.dispose?.();
} catch (err) {
server.config.logger.warn(`[headplane:dev-server] dispose failed: ${String(err)}`);
}
}
currentModule = mod;
const mod = (await server.ssrLoadModule(options.entry)) as {
default: RequestListener;
};
appListener = mod.default;
composed(req, res);
} catch (err) {
+101
View File
@@ -0,0 +1,101 @@
import { writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { cwd } from "node:process";
import type { OpenAPIV2 } from "openapi-types";
import { request } from "undici";
import { hashOpenApiDocument } from "~/server/headscale/api/hasher";
const HASH_FILE_LOCATION = "app/openapi-operation-hashes.json";
const CANONICAL_LOCATION = "app/openapi-canonical-families.json";
const SPEC_MAP = {
// '0.25.0': '/v0.25.0/gen/openapiv2/headscale/v1/headscale.swagger.json',
// '0.25.1': '/v0.25.1/gen/openapiv2/headscale/v1/headscale.swagger.json',
"0.26.0": "/v0.26.0/gen/openapiv2/headscale/v1/headscale.swagger.json",
"0.26.1": "/v0.26.1/gen/openapiv2/headscale/v1/headscale.swagger.json",
"0.27.0": "/v0.27.0/gen/openapiv2/headscale/v1/headscale.swagger.json",
"0.27.1": "/v0.27.1/gen/openapiv2/headscale/v1/headscale.swagger.json",
"0.28.0": "/v0.28.0/gen/openapiv2/headscale/v1/headscale.swagger.json",
} as const;
async function hashOpenApiOperations(specUrl: string) {
const url = `https://raw.githubusercontent.com/juanfont/headscale${specUrl}`;
const res = await request(url);
if (res.statusCode !== 200) {
console.error("Failed to fetch OpenAPI spec:", res.statusCode);
process.exit(1);
}
const body = (await res.body.json()) as OpenAPIV2.Document;
return hashOpenApiDocument(body);
}
async function collectCanonicalizedFamilies(
newHashes: readonly (readonly [string, Record<string, string>])[],
) {
const canonicalizedFamilies: Record<string, string[]> = {};
for (const [version, hashes] of newHashes) {
const signature = JSON.stringify(hashes);
let canonical: string | null = null;
for (const existingCanonical of Object.keys(canonicalizedFamilies)) {
const existingSignature = JSON.stringify(
newHashes.find(([v]) => v === existingCanonical)![1],
);
if (existingSignature === signature) {
canonical = existingCanonical;
break;
}
}
if (!canonical) {
canonicalizedFamilies[version] = [version];
continue;
}
canonicalizedFamilies[canonical].push(version);
if (
version.localeCompare(canonical, undefined, {
numeric: true,
sensitivity: "base",
}) > 0
) {
canonicalizedFamilies[version] = canonicalizedFamilies[canonical];
delete canonicalizedFamilies[canonical];
}
}
for (const [canonical, family] of Object.entries(canonicalizedFamilies)) {
canonicalizedFamilies[canonical] = family.sort((a, b) =>
a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }),
);
}
return canonicalizedFamilies;
}
async function writeHashes(hashes: Record<string, Record<string, string>>) {
const path = resolve(cwd(), HASH_FILE_LOCATION);
await writeFile(path, `${JSON.stringify(hashes, null, 2)}\n`, "utf-8");
}
async function writeCanonicalizedFamilies(families: Record<string, string[]>) {
const path = resolve(cwd(), CANONICAL_LOCATION);
await writeFile(path, `${JSON.stringify(families, null, 2)}\n`, "utf-8");
}
const newHashes = await Promise.all(
Object.entries(SPEC_MAP).map(async ([version, specUrl]) => {
const hashes = await hashOpenApiOperations(specUrl);
return [version, hashes] as const;
}),
);
const canonicalizedFamilies = await collectCanonicalizedFamilies(newHashes);
console.log("Writing new OpenAPI operation hashes to file");
await writeHashes(Object.fromEntries(newHashes));
await writeCanonicalizedFamilies(canonicalizedFamilies);
+1 -1
View File
@@ -5,7 +5,7 @@ import { getRuntimeClient, HS_VERSIONS } from "../setup/env";
describe.for(HS_VERSIONS)("Headscale %s: API Keys", (version) => {
test("api keys can be fetched", async () => {
const client = await getRuntimeClient(version);
const apiKeys = await client.apiKeys.list();
const apiKeys = await client.getApiKeys();
expect(Array.isArray(apiKeys)).toBe(true);
expect(apiKeys.length).toBe(1);
});
+15 -17
View File
@@ -9,8 +9,8 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Users", (version) => {
const client = await getRuntimeClient(version);
const tailnetNode = await getNode(version);
const user = await client.users.create({ name: "node-reg@" });
const node = await client.nodes.register(user.name, tailnetNode.authCode);
const user = await client.createUser("node-reg@");
const node = await client.registerNode(user.name, tailnetNode.authCode);
expect(node).toBeDefined();
expect(node.registerMethod).toBe("REGISTER_METHOD_CLI");
expect(node.name).toBe(tailnetNode.nodeName);
@@ -19,12 +19,12 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Users", (version) => {
test("nodes can be retrieved", async () => {
const client = await getRuntimeClient(version);
const { nodeName } = await getNode(version);
const nodes = await client.nodes.list();
const nodes = await client.getNodes();
const node = nodes.find((n) => n.name === nodeName);
expect(node).toBeDefined();
expect(node?.name).toBe(nodeName);
const fetchedNode = await client.nodes.get(node!.id);
const fetchedNode = await client.getNode(node!.id);
expect(fetchedNode).toBeDefined();
expect(fetchedNode.id).toBe(node!.id);
workingNodeId = node!.id;
@@ -35,43 +35,41 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Users", (version) => {
const { nodeName } = await getNode(version);
const newName = `${nodeName}-renamed`;
await client.nodes.rename(workingNodeId, newName);
const renamedNode = await client.nodes.get(workingNodeId);
await client.renameNode(workingNodeId, newName);
const renamedNode = await client.getNode(workingNodeId);
expect(renamedNode).toBeDefined();
expect(renamedNode.givenName).toBe(newName);
});
test("nodes can be reassigned to another user", async (context) => {
const bootstrap = await getBootstrapClient(version);
// Reassigning a node owner was removed in 0.28.
if (bootstrap.capabilities.nodeOwnerIsImmutable) {
if (bootstrap.clientHelpers.isAtleast("0.28.0")) {
context.skip();
}
const client = await getRuntimeClient(version);
const user = await client.users.create({ name: "node-reassign@" });
const user = await client.createUser("node-reassign@");
// reassignUser is only defined on pre-0.28 clients, hence the guard above.
await client.nodes.reassignUser!(workingNodeId, user.id);
const reassignedNode = await client.nodes.get(workingNodeId);
await client.setNodeUser(workingNodeId, user.id);
const reassignedNode = await client.getNode(workingNodeId);
expect(reassignedNode).toBeDefined();
expect(reassignedNode.user?.name).toBe(user.name);
expect(reassignedNode.user.name).toBe(user.name);
});
test("nodes can be expired", async () => {
const client = await getRuntimeClient(version);
await client.nodes.expire(workingNodeId);
await client.expireNode(workingNodeId);
const expiredNode = await client.nodes.get(workingNodeId);
const expiredNode = await client.getNode(workingNodeId);
expect(expiredNode).toBeDefined();
expect(expiredNode.expiry).toBeDefined();
});
test("nodes can be deleted", async () => {
const client = await getRuntimeClient(version);
await client.nodes.delete(workingNodeId);
await client.deleteNode(workingNodeId);
const nodes = await client.nodes.list();
const nodes = await client.getNodes();
const node = nodes.find((n) => n.id === workingNodeId);
expect(node).toBeUndefined();
});
+22 -40
View File
@@ -1,22 +1,16 @@
import { describe, expect, test } from "vitest";
import { getBootstrapClient, getRuntimeClient, HS_VERSIONS } from "../setup/env";
import { getBootstrapClient, getIsAtLeast, getRuntimeClient, HS_VERSIONS } from "../setup/env";
describe.sequential.for(HS_VERSIONS)("Headscale %s: Pre-auth Keys", (version) => {
test("pre-auth keys can be created", async () => {
const client = await getRuntimeClient(version);
const preAuthKeyUser = await client.users.create({ name: "preauthkeyuser@" });
const preAuthKeyUser = await client.createUser("preauthkeyuser@");
expect(preAuthKeyUser).toBeDefined();
expect(preAuthKeyUser.name).toBe("preauthkeyuser@");
const expiry = new Date(Date.now() + 3600 * 1000);
const preAuthKey = await client.preAuthKeys.create({
user: preAuthKeyUser.id,
ephemeral: false,
reusable: false,
expiration: expiry,
aclTags: null,
});
const preAuthKey = await client.createPreAuthKey(preAuthKeyUser.id, false, false, expiry, null);
expect(preAuthKey).toBeDefined();
expect(preAuthKey.user?.id).toBe(preAuthKeyUser.id);
@@ -28,18 +22,12 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Pre-auth Keys", (version) =>
test("pre-auth keys can be created with ACL tags", async () => {
const client = await getRuntimeClient(version);
const [preAuthKeyUser] = await client.users.list({ name: "preauthkeyuser@" });
const [preAuthKeyUser] = await client.getUsers(undefined, "preauthkeyuser@");
expect(preAuthKeyUser).toBeDefined();
expect(preAuthKeyUser.name).toBe("preauthkeyuser@");
const aclTags = ["tag:test1", "tag:test2"];
const preAuthKey = await client.preAuthKeys.create({
user: preAuthKeyUser.id,
ephemeral: true,
reusable: true,
expiration: null,
aclTags,
});
const preAuthKey = await client.createPreAuthKey(preAuthKeyUser.id, true, true, null, aclTags);
expect(preAuthKey).toBeDefined();
expect(preAuthKey.user?.id).toBe(preAuthKeyUser.id);
@@ -50,19 +38,13 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Pre-auth Keys", (version) =>
test("tag-only pre-auth keys (0.28+)", async (context) => {
const bootstrap = await getBootstrapClient(version);
if (!bootstrap.capabilities.preAuthKeysHaveStableIds) {
if (!bootstrap.clientHelpers.isAtleast("0.28.0")) {
context.skip();
}
const client = await getRuntimeClient(version);
const aclTags = ["tag:server", "tag:prod"];
const preAuthKey = await client.preAuthKeys.create({
user: null,
ephemeral: false,
reusable: true,
expiration: null,
aclTags,
});
const preAuthKey = await client.createPreAuthKey(null, false, true, null, aclTags);
expect(preAuthKey).toBeDefined();
expect(preAuthKey.user).toBeNull();
@@ -73,44 +55,44 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Pre-auth Keys", (version) =>
test("pre-auth keys can be listed", async () => {
const client = await getRuntimeClient(version);
const [preAuthKeyUser] = await client.users.list({ name: "preauthkeyuser@" });
const [preAuthKeyUser] = await client.getUsers(undefined, "preauthkeyuser@");
expect(preAuthKeyUser).toBeDefined();
expect(preAuthKeyUser.name).toBe("preauthkeyuser@");
const preAuthKeys = await client.preAuthKeys.listForUser(preAuthKeyUser.id);
const preAuthKeys = await client.getPreAuthKeys(preAuthKeyUser.id);
expect(Array.isArray(preAuthKeys)).toBe(true);
expect(preAuthKeys.length).toBeGreaterThanOrEqual(2);
});
test("all pre-auth keys can be listed without user filter (0.28+)", async (context) => {
const bootstrap = await getBootstrapClient(version);
if (!bootstrap.capabilities.preAuthKeysHaveStableIds) {
const isAtLeast = await getIsAtLeast(version);
if (!isAtLeast("0.28.0")) {
context.skip();
}
const client = await getRuntimeClient(version);
const [preAuthKeyUser] = await client.users.list({ name: "preauthkeyuser@" });
const [preAuthKeyUser] = await client.getUsers(undefined, "preauthkeyuser@");
expect(preAuthKeyUser).toBeDefined();
const allKeys = await client.preAuthKeys.listAll!();
const allKeys = await client.getAllPreAuthKeys();
expect(Array.isArray(allKeys)).toBe(true);
expect(allKeys.length).toBeGreaterThanOrEqual(2);
const userSpecificKeys = await client.preAuthKeys.listForUser(preAuthKeyUser.id);
const userSpecificKeys = await client.getPreAuthKeys(preAuthKeyUser.id);
for (const userKey of userSpecificKeys) {
const found = allKeys.find((k) => k.key === userKey.key);
expect(found).toBeDefined();
}
});
test("listAll returns keys with correct structure (0.28+)", async (context) => {
const bootstrap = await getBootstrapClient(version);
if (!bootstrap.capabilities.preAuthKeysHaveStableIds) {
test("getAllPreAuthKeys returns keys with correct structure (0.28+)", async (context) => {
const isAtLeast = await getIsAtLeast(version);
if (!isAtLeast("0.28.0")) {
context.skip();
}
const client = await getRuntimeClient(version);
const allKeys = await client.preAuthKeys.listAll!();
const allKeys = await client.getAllPreAuthKeys();
for (const key of allKeys) {
expect(key.id).toBeDefined();
@@ -125,16 +107,16 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Pre-auth Keys", (version) =>
test("pre-auth keys can be expired", async () => {
const client = await getRuntimeClient(version);
const [preAuthKeyUser] = await client.users.list({ name: "preauthkeyuser@" });
const [preAuthKeyUser] = await client.getUsers(undefined, "preauthkeyuser@");
expect(preAuthKeyUser).toBeDefined();
expect(preAuthKeyUser.name).toBe("preauthkeyuser@");
const preAuthKeys = await client.preAuthKeys.listForUser(preAuthKeyUser.id);
const preAuthKeys = await client.getPreAuthKeys(preAuthKeyUser.id);
expect(preAuthKeys.length).toBeGreaterThanOrEqual(2);
const preAuthKeyToExpire = preAuthKeys[0];
await client.preAuthKeys.expire(preAuthKeyToExpire);
await client.expirePreAuthKey(preAuthKeyUser.id, preAuthKeyToExpire);
const preAuthKeysAfterExpire = await client.preAuthKeys.listForUser(preAuthKeyUser.id);
const preAuthKeysAfterExpire = await client.getPreAuthKeys(preAuthKeyUser.id);
const expiredKey = preAuthKeysAfterExpire.find((key) => key.key === preAuthKeyToExpire.key);
expect(expiredKey).toBeDefined();
});

Some files were not shown because too many files have changed in this diff Show More