mirror of
https://github.com/tale/headplane.git
synced 2026-07-27 08:08:56 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87a9219fd3 | |||
| ad7e58570f | |||
| 04ff2138d2 |
@@ -1,57 +0,0 @@
|
||||
# PULLFROG ACTION — DO NOT EDIT EXCEPT WHERE INDICATED
|
||||
name: Pullfrog
|
||||
run-name: ${{ inputs.name || github.workflow }}
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
prompt:
|
||||
type: string
|
||||
description: Agent prompt
|
||||
name:
|
||||
type: string
|
||||
description: Run name
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pullfrog:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- name: Run agent
|
||||
uses: pullfrog/pullfrog@v0
|
||||
with:
|
||||
prompt: ${{ inputs.prompt }}
|
||||
env:
|
||||
# add at least one provider API key
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
GOOGLE_GENERATIVE_AI_API_KEY:
|
||||
${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }}
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
|
||||
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
|
||||
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||
|
||||
# for Amazon Bedrock (https://docs.pullfrog.com/bedrock)
|
||||
# AWS_BEARER_TOKEN_BEDROCK: ${{ secrets.AWS_BEARER_TOKEN_BEDROCK }}
|
||||
# AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
# AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
# AWS_REGION: us-east-1
|
||||
# BEDROCK_MODEL_ID: <bedrock-model-id>
|
||||
|
||||
# for Google Vertex AI (https://docs.pullfrog.com/vertex)
|
||||
# VERTEX_SERVICE_ACCOUNT_JSON: ${{ secrets.VERTEX_SERVICE_ACCOUNT_JSON }}
|
||||
# GOOGLE_CLOUD_PROJECT: my-project
|
||||
# VERTEX_LOCATION: global
|
||||
# VERTEX_MODEL_ID: <vertex-model-id>
|
||||
@@ -1,6 +1,8 @@
|
||||
node_modules
|
||||
/.fate
|
||||
/.react-router
|
||||
/.cache
|
||||
/.data
|
||||
/build
|
||||
/test
|
||||
.env
|
||||
|
||||
@@ -1,47 +1,3 @@
|
||||
# Next
|
||||
|
||||
- Fixed the Headplane agent falling back to an interactive Tailscale login. The agent now starts with a pre-auth-key, preserves its existing state across restarts, and auto-approves itself when Headscale requires manual approval (closes [#582](https://github.com/tale/headplane/issues/582)).
|
||||
|
||||
# 0.7.0
|
||||
|
||||
- Switched to structured JSON logging (closes [#279](https://github.com/tale/headplane/issues/279)).
|
||||
- Added suggestions to pick existing tags to the machine tag dialog (closes [#560](https://github.com/tale/headplane/issues/560)).
|
||||
- Headplane correctly handles `dns.extra_records_path` from the Headscale configuration (closes [#543](https://github.com/tale/headplane/issues/543)).
|
||||
- Fixed Headscale PostgreSQL config validation so `pass` is not required when `password_file` is supplied (closes [#528](https://github.com/tale/headplane/issues/528)).
|
||||
- Fixed Browser SSH's WASM DERP probe to account for custom DERP ports (closes [#552](https://github.com/tale/headplane/issues/552)).
|
||||
- Fixed Browser SSH pre-auth key handling by increasing the temporary key expiry window and showing key creation errors in the UI (closes [#565](https://github.com/tale/headplane/issues/565)).
|
||||
- Fixed machine rename submission by validating names before sending the rename request (closes [#564](https://github.com/tale/headplane/issues/564)).
|
||||
- Fixed OIDC token exchange fallback when retrying with `client_secret_basic` (closes [#493](https://github.com/tale/headplane/issues/493)).
|
||||
- Added support for proxy authentication via `server.proxy_auth` (closes [#353](https://github.com/tale/headplane/issues/353)).
|
||||
- Added automatic role assignment for new OIDC users via `oidc.default_role` and IdP-provided role claims via `oidc.role_claim` (closes [#352](https://github.com/tale/headplane/issues/352)).
|
||||
- Fixed the DNS page crashing when Headscale has no Split DNS nameservers configured (closes [#570](https://github.com/tale/headplane/issues/570)).
|
||||
- User lists now show Headscale display names while preserving usernames as secondary text (closes [#571](https://github.com/tale/headplane/issues/571)).
|
||||
- Fixed the Register Machine Key dialog so it accepts registration URLs and full `hskey-authreq-...` registration keys (closes [#579](https://github.com/tale/headplane/issues/579)).
|
||||
- Fixed assigning ACL tags to tag-only (no-user) nodes from the UI. The "Add" and "Remove" tag buttons in the tag dialog lacked `type="button"`, so clicking them submitted the form before the local state update was applied and Headscale received the unchanged tag list. Tag modifications now reach Headscale as intended (closes [#574](https://github.com/tale/headplane/issues/574)).
|
||||
|
||||
---
|
||||
|
||||
# 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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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" ]
|
||||
|
||||
@@ -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>} />
|
||||
) : (
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -4,18 +4,16 @@ import { createReadableStreamFromReadable } from "@react-router/node";
|
||||
import { isbot } from "isbot";
|
||||
import type { RenderToPipeableStreamOptions } from "react-dom/server";
|
||||
import { renderToPipeableStream } from "react-dom/server";
|
||||
import type { EntryContext, RouterContextProvider } from "react-router";
|
||||
import type { AppLoadContext, EntryContext } from "react-router";
|
||||
import { ServerRouter } from "react-router";
|
||||
|
||||
import log from "~/utils/log";
|
||||
|
||||
export const streamTimeout = 5_000;
|
||||
export default function handleRequest(
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
routerContext: EntryContext,
|
||||
_loadContext: RouterContextProvider,
|
||||
_loadContext: AppLoadContext,
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let shellRendered = false;
|
||||
@@ -54,7 +52,7 @@ export default function handleRequest(
|
||||
// errors encountered during initial shell rendering since they'll
|
||||
// reject and get logged in handleDocumentRequest.
|
||||
if (shellRendered) {
|
||||
log.error("server", "Streaming render error: %o", error);
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
+32
-45
@@ -2,17 +2,8 @@ import { Outlet, redirect, type ShouldRevalidateFunction } from "react-router";
|
||||
|
||||
import { ErrorBanner } from "~/components/error-banner";
|
||||
import StatusBanner from "~/components/status-banner";
|
||||
import {
|
||||
appConfigContext,
|
||||
authContext,
|
||||
headscaleConfigContext,
|
||||
headscaleContext,
|
||||
headscaleLiveStoreContext,
|
||||
requestApiContext,
|
||||
} from "~/server/context";
|
||||
import { isDataUnauthorizedError } from "~/server/headscale/api/error-client";
|
||||
import { usersResource } from "~/server/headscale/live-store";
|
||||
import { isUserPrincipal } from "~/server/web/auth";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
import log from "~/utils/log";
|
||||
|
||||
@@ -39,40 +30,36 @@ export const shouldRevalidate: ShouldRevalidateFunction = ({
|
||||
};
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const auth = context.get(authContext);
|
||||
const config = context.get(appConfigContext);
|
||||
const getRequestApi = context.get(requestApiContext);
|
||||
const headscale = context.get(headscaleContext);
|
||||
const headscaleConfig = context.get(headscaleConfigContext);
|
||||
const headscaleLiveStore = context.get(headscaleLiveStoreContext);
|
||||
|
||||
try {
|
||||
const { principal, api } = await getRequestApi(request);
|
||||
const principal = await context.auth.require(request);
|
||||
|
||||
const user = isUserPrincipal(principal)
|
||||
? {
|
||||
email: principal.profile.email,
|
||||
name: principal.profile.name,
|
||||
picture: principal.profile.picture,
|
||||
subject: principal.user.subject,
|
||||
username: principal.profile.username,
|
||||
}
|
||||
: { name: principal.displayName, subject: "api_key" };
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
|
||||
const user =
|
||||
principal.kind === "oidc"
|
||||
? {
|
||||
email: principal.profile.email,
|
||||
name: principal.profile.name,
|
||||
picture: principal.profile.picture,
|
||||
subject: principal.user.subject,
|
||||
username: principal.profile.username,
|
||||
}
|
||||
: { name: principal.displayName, subject: "api_key" };
|
||||
|
||||
// MARK: The session should stay valid if Headscale isn't healthy
|
||||
const isHealthy = await headscale.health();
|
||||
const isHealthy = await api.isHealthy();
|
||||
if (isHealthy) {
|
||||
try {
|
||||
await api.apiKeys.list();
|
||||
await api.getApiKeys();
|
||||
} catch (error) {
|
||||
if (isDataUnauthorizedError(error)) {
|
||||
const displayName = isUserPrincipal(principal)
|
||||
? principal.profile.name
|
||||
: principal.displayName;
|
||||
const displayName =
|
||||
principal.kind === "oidc" ? principal.profile.name : principal.displayName;
|
||||
log.warn("auth", "Logging out %s due to expired API key", displayName);
|
||||
return redirect("/login", {
|
||||
headers: {
|
||||
"Set-Cookie": await auth.destroySession(request),
|
||||
"Set-Cookie": await context.auth.destroySession(request),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -80,11 +67,11 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
|
||||
// Self-heal: if the linked Headscale user was deleted, clear the
|
||||
// stale link so the user gets prompted to re-link.
|
||||
if (isUserPrincipal(principal) && principal.user.headscaleUserId) {
|
||||
if (principal.kind === "oidc" && principal.user.headscaleUserId) {
|
||||
try {
|
||||
const usersSnap = await headscaleLiveStore.get(usersResource, api);
|
||||
const usersSnap = await context.hsLive.get(usersResource, api);
|
||||
if (!usersSnap.data.some((u) => u.id === principal.user.headscaleUserId)) {
|
||||
await auth.unlinkHeadscaleUser(principal.user.id);
|
||||
await context.auth.unlinkHeadscaleUser(principal.user.id);
|
||||
}
|
||||
} catch {
|
||||
// API call failed, skip validation
|
||||
@@ -94,23 +81,23 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
|
||||
return {
|
||||
access: {
|
||||
dns: auth.can(principal, Capabilities.read_network),
|
||||
machines: auth.can(principal, Capabilities.read_machines),
|
||||
policy: auth.can(principal, Capabilities.read_policy),
|
||||
settings: auth.can(principal, Capabilities.read_feature),
|
||||
ui: auth.can(principal, Capabilities.ui_access),
|
||||
users: auth.can(principal, Capabilities.read_users),
|
||||
dns: context.auth.can(principal, Capabilities.read_network),
|
||||
machines: context.auth.can(principal, Capabilities.read_machines),
|
||||
policy: context.auth.can(principal, Capabilities.read_policy),
|
||||
settings: context.auth.can(principal, Capabilities.read_feature),
|
||||
ui: context.auth.can(principal, Capabilities.ui_access),
|
||||
users: context.auth.can(principal, Capabilities.read_users),
|
||||
},
|
||||
baseUrl: config.headscale.public_url ?? config.headscale.url,
|
||||
configAvailable: headscaleConfig.readable(),
|
||||
isDebug: config.debug,
|
||||
baseUrl: context.config.headscale.public_url ?? context.config.headscale.url,
|
||||
configAvailable: context.hs.readable(),
|
||||
isDebug: context.config.debug,
|
||||
isHealthy,
|
||||
user,
|
||||
};
|
||||
} catch {
|
||||
return redirect("/login", {
|
||||
headers: {
|
||||
"Set-Cookie": await auth.destroySession(request),
|
||||
"Set-Cookie": await context.auth.destroySession(request),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { data } from "react-router";
|
||||
|
||||
import { authContext, requestApiContext } from "~/server/context";
|
||||
import { isDataWithApiError } from "~/server/headscale/api/error-client";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
|
||||
@@ -10,11 +9,8 @@ import type { Route } from "./+types/overview";
|
||||
// If it isn't, it'll gracefully error anyways, since this means some
|
||||
// fishy client manipulation is happening.
|
||||
export async function aclAction({ request, context }: Route.ActionArgs) {
|
||||
const auth = context.get(authContext);
|
||||
const getRequestApi = context.get(requestApiContext);
|
||||
|
||||
const principal = await auth.require(request);
|
||||
const check = auth.can(principal, Capabilities.write_policy);
|
||||
const principal = await context.auth.require(request);
|
||||
const check = context.auth.can(principal, Capabilities.write_policy);
|
||||
if (!check) {
|
||||
throw data("You do not have permission to write to the ACL policy", {
|
||||
status: 403,
|
||||
@@ -30,9 +26,10 @@ export async function aclAction({ request, context }: Route.ActionArgs) {
|
||||
});
|
||||
}
|
||||
|
||||
const { api } = await getRequestApi(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,
|
||||
@@ -58,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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { data } from "react-router";
|
||||
|
||||
import { authContext, requestApiContext } from "~/server/context";
|
||||
import { isDataWithApiError } from "~/server/headscale/api/error-client";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
|
||||
@@ -12,13 +11,10 @@ import type { Route } from "./+types/overview";
|
||||
// 2. Does the user have permission to write to the policy?
|
||||
// 3. Is the Headscale policy in file or database mode?
|
||||
// If database, we can read/write easily via the API.
|
||||
// If in file mode, we can only write if the Headscale config is available.
|
||||
// If in file mode, we can only write if context.config is available.
|
||||
export async function aclLoader({ request, context }: Route.LoaderArgs) {
|
||||
const auth = context.get(authContext);
|
||||
const getRequestApi = context.get(requestApiContext);
|
||||
|
||||
const principal = await auth.require(request);
|
||||
const check = auth.can(principal, Capabilities.read_policy);
|
||||
const principal = await context.auth.require(request);
|
||||
const check = context.auth.can(principal, Capabilities.read_policy);
|
||||
if (!check) {
|
||||
throw data("You do not have permission to read the ACL policy.", {
|
||||
status: 403,
|
||||
@@ -27,15 +23,16 @@ export async function aclLoader({ request, context }: Route.LoaderArgs) {
|
||||
|
||||
const flags = {
|
||||
// Can the user write to the ACL policy
|
||||
access: auth.can(principal, Capabilities.write_policy),
|
||||
access: context.auth.can(principal, Capabilities.write_policy),
|
||||
writable: false,
|
||||
policy: "",
|
||||
};
|
||||
|
||||
// Try to load the ACL policy from the API.
|
||||
const { api } = await getRequestApi(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;
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import { redirect } from "react-router";
|
||||
|
||||
import { authContext, headscaleContext } from "~/server/context";
|
||||
import { isDataWithApiError } from "~/server/headscale/api/error-client";
|
||||
import log from "~/utils/log";
|
||||
|
||||
import type { Route } from "./+types/page";
|
||||
|
||||
export async function loginAction({ request, context }: Route.LoaderArgs) {
|
||||
const auth = context.get(authContext);
|
||||
const headscale = context.get(headscaleContext);
|
||||
|
||||
const formData = await request.formData();
|
||||
const apiKey = formData.has("api_key") ? String(formData.get("api_key")) : undefined;
|
||||
|
||||
@@ -37,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 = 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
|
||||
@@ -74,7 +68,7 @@ export async function loginAction({ request, context }: Route.LoaderArgs) {
|
||||
|
||||
return redirect("/machines", {
|
||||
headers: {
|
||||
"Set-Cookie": await auth.createApiKeySession(
|
||||
"Set-Cookie": await context.auth.createApiKeySession(
|
||||
apiKey,
|
||||
`${lookup.prefix}...`,
|
||||
expiry.getTime() - Date.now(),
|
||||
|
||||
@@ -7,10 +7,7 @@ import Card from "~/components/card";
|
||||
import Code from "~/components/code";
|
||||
import Input from "~/components/input";
|
||||
import Link from "~/components/link";
|
||||
import { appConfigContext, authContext, oidcContext } from "~/server/context";
|
||||
import type { OidcError, OidcService } from "~/server/oidc/provider";
|
||||
import { useLiveData } from "~/utils/live-data";
|
||||
import log from "~/utils/log";
|
||||
|
||||
import type { Route } from "./+types/page";
|
||||
import { loginAction } from "./action";
|
||||
@@ -18,41 +15,24 @@ import { OidcConfigErrorNotice, OidcDiscoveryFailedNotice } from "./config-error
|
||||
import Logout from "./logout";
|
||||
import { OidcErrorNotice } from "./oidc-error";
|
||||
|
||||
export async function loader({ request, context, url }: Route.LoaderArgs) {
|
||||
const auth = context.get(authContext);
|
||||
const config = context.get(appConfigContext);
|
||||
const oidc = context.get(oidcContext);
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
try {
|
||||
await auth.require(request);
|
||||
await context.auth.require(request);
|
||||
return redirect("/machines");
|
||||
} catch {}
|
||||
|
||||
const qp = url.searchParams;
|
||||
const qp = new URL(request.url).searchParams;
|
||||
const urlState = qp.get("s") ?? undefined;
|
||||
|
||||
const oidcService = oidc.state === "enabled" ? oidc.value : undefined;
|
||||
let oidcStatus: ReturnType<OidcService["status"]> | undefined;
|
||||
if (oidcService) {
|
||||
try {
|
||||
const result = await oidcService.discover();
|
||||
if (!result.ok) {
|
||||
logLoginOidcError("OIDC discovery failed", result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("auth", "OIDC discovery failed unexpectedly: %s", String(error));
|
||||
log.debug("auth", "OIDC discovery error details: %o", error);
|
||||
}
|
||||
const oidcService = context.oidc?.service;
|
||||
const oidcStatus = oidcService
|
||||
? await oidcService.discover().then(
|
||||
(r) => (r.ok ? oidcService.status() : oidcService.status()),
|
||||
() => oidcService.status(),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
oidcStatus = oidcService.status();
|
||||
}
|
||||
|
||||
if (
|
||||
oidcService &&
|
||||
config.oidc?.disable_api_key_login &&
|
||||
oidcStatus?.state === "ready" &&
|
||||
urlState !== "logout"
|
||||
) {
|
||||
if (context.oidc?.disableApiKeyLogin && oidcStatus?.state === "ready" && urlState !== "logout") {
|
||||
return redirect("/oidc/start");
|
||||
}
|
||||
|
||||
@@ -60,7 +40,7 @@ export async function loader({ request, context, url }: Route.LoaderArgs) {
|
||||
const oidcErrorCodes = oidcStatus?.state === "error" ? [oidcStatus.error.code] : [];
|
||||
|
||||
return {
|
||||
isCookieSecureEnabled: config.server.cookie_secure,
|
||||
isCookieSecureEnabled: context.config.server.cookie_secure,
|
||||
isOidcConnectorEnabled,
|
||||
oidcErrorCodes,
|
||||
urlState,
|
||||
@@ -69,13 +49,6 @@ export async function loader({ request, context, url }: Route.LoaderArgs) {
|
||||
|
||||
export const action = loginAction;
|
||||
|
||||
function logLoginOidcError(context: string, error: OidcError): void {
|
||||
log.error("auth", "%s [%s]: %s", context, error.code, error.message);
|
||||
if (error.hint) {
|
||||
log.error("auth", "Hint: %s", error.hint);
|
||||
}
|
||||
}
|
||||
|
||||
export default function Page({ loaderData, actionData }: Route.ComponentProps) {
|
||||
const { isCookieSecureEnabled, isOidcConnectorEnabled, oidcErrorCodes, urlState } = loaderData;
|
||||
|
||||
|
||||
+10
-15
@@ -1,42 +1,37 @@
|
||||
import { type ActionFunctionArgs, redirect } from "react-router";
|
||||
|
||||
import { appConfigContext, authContext, oidcContext } from "~/server/context";
|
||||
import type { AppContext } from "~/server/context";
|
||||
|
||||
export async function loader() {
|
||||
return redirect("/machines");
|
||||
}
|
||||
|
||||
export async function action({ request, context }: ActionFunctionArgs) {
|
||||
const auth = context.get(authContext);
|
||||
const config = context.get(appConfigContext);
|
||||
const oidc = context.get(oidcContext);
|
||||
|
||||
let principal: Awaited<ReturnType<typeof auth.require>> | undefined;
|
||||
export async function action({ request, context }: ActionFunctionArgs<AppContext>) {
|
||||
let principal: Awaited<ReturnType<typeof context.auth.require>> | undefined;
|
||||
try {
|
||||
principal = await auth.require(request);
|
||||
principal = await context.auth.require(request);
|
||||
} catch {
|
||||
return redirect("/login");
|
||||
}
|
||||
|
||||
// When API key is disabled, we need to explicitly redirect
|
||||
// with a logout state to prevent auto login again.
|
||||
let url = config.oidc?.disable_api_key_login ? "/login?s=logout" : "/login";
|
||||
let url = context.config.oidc?.disable_api_key_login ? "/login?s=logout" : "/login";
|
||||
|
||||
// For OIDC sessions, redirect to the provider's RP-initiated logout
|
||||
// endpoint when explicitly enabled, so the upstream IdP session is also
|
||||
// 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" && oidc.state === "enabled" && config.oidc?.use_end_session) {
|
||||
const service = 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;
|
||||
}
|
||||
@@ -44,7 +39,7 @@ export async function action({ request, context }: ActionFunctionArgs) {
|
||||
|
||||
return redirect(url, {
|
||||
headers: {
|
||||
"Set-Cookie": await auth.destroySession(request),
|
||||
"Set-Cookie": await context.auth.destroySession(request),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,38 +1,23 @@
|
||||
import { data, redirect } from "react-router";
|
||||
|
||||
import {
|
||||
appConfigContext,
|
||||
authContext,
|
||||
headscaleApiKeyContext,
|
||||
headscaleContext,
|
||||
oidcContext,
|
||||
} from "~/server/context";
|
||||
import { logOidcError } from "~/server/oidc/provider";
|
||||
import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity";
|
||||
import { Roles } from "~/server/web/roles";
|
||||
import log from "~/utils/log";
|
||||
import { createOidcStateCookie } from "~/utils/oidc-state";
|
||||
|
||||
import type { Route } from "./+types/oidc-callback";
|
||||
|
||||
export async function loader({ request, context, url }: Route.LoaderArgs) {
|
||||
const auth = context.get(authContext);
|
||||
const config = context.get(appConfigContext);
|
||||
const headscale = context.get(headscaleContext);
|
||||
const headscaleApiKey = context.get(headscaleApiKeyContext);
|
||||
const oidc = context.get(oidcContext);
|
||||
|
||||
if (oidc.state !== "enabled") {
|
||||
throw data(`OIDC is unavailable: ${oidc.reason}`, { status: 501 });
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const service = context.oidc?.service;
|
||||
if (!service) {
|
||||
throw data("OIDC is not enabled or misconfigured", { status: 501 });
|
||||
}
|
||||
const service = oidc.value;
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (url.searchParams.toString().length === 0) {
|
||||
log.warn("auth", "Called OIDC callback without query parameters");
|
||||
return redirect("/login?s=error_no_query");
|
||||
}
|
||||
|
||||
const cookie = createOidcStateCookie(config);
|
||||
const cookie = createOidcStateCookie(context.config);
|
||||
const oidcCookieState = await cookie.parse(request.headers.get("Cookie"));
|
||||
|
||||
if (oidcCookieState == null) {
|
||||
@@ -55,38 +40,27 @@ export async function loader({ request, context, url }: Route.LoaderArgs) {
|
||||
|
||||
const result = await service.handleCallback(url.searchParams, flowState);
|
||||
if (!result.ok) {
|
||||
logOidcError("OIDC callback failed", result.error);
|
||||
log.error("auth", "OIDC callback failed [%s]: %s", result.error.code, result.error.message);
|
||||
if (result.error.hint) {
|
||||
log.error("auth", "Hint: %s", result.error.hint);
|
||||
}
|
||||
return redirect("/login?s=error_auth_failed");
|
||||
}
|
||||
|
||||
const identity = result.value;
|
||||
const claimedRole =
|
||||
identity.role && identity.role !== "owner" && identity.role in Roles
|
||||
? identity.role
|
||||
: undefined;
|
||||
|
||||
const userId = await auth.findOrCreateUser(
|
||||
identity.subject,
|
||||
{
|
||||
name: identity.name,
|
||||
email: identity.email,
|
||||
picture: identity.picture,
|
||||
},
|
||||
{
|
||||
initialRole: claimedRole ?? config.oidc?.default_role,
|
||||
syncRole: claimedRole,
|
||||
},
|
||||
);
|
||||
const userId = await context.auth.findOrCreateUser(identity.subject, {
|
||||
name: identity.name,
|
||||
email: identity.email,
|
||||
picture: identity.picture,
|
||||
});
|
||||
|
||||
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 = headscale.client(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 auth.linkHeadscaleUser(userId, hsUser.id);
|
||||
await context.auth.linkHeadscaleUser(userId, hsUser.id);
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn("auth", "Failed to link Headscale user: %s", String(error));
|
||||
@@ -94,11 +68,11 @@ export async function loader({ request, context, url }: 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 = config.oidc?.use_end_session ? identity.idToken : undefined;
|
||||
const idToken = context.oidc?.useEndSession ? identity.idToken : undefined;
|
||||
|
||||
return redirect("/", {
|
||||
headers: {
|
||||
"Set-Cookie": await auth.createOidcSession(
|
||||
"Set-Cookie": await context.auth.createOidcSession(
|
||||
userId,
|
||||
{
|
||||
name: identity.name,
|
||||
|
||||
@@ -1,34 +1,27 @@
|
||||
import { data, redirect } from "react-router";
|
||||
|
||||
import { appConfigContext, authContext, oidcContext } from "~/server/context";
|
||||
import { logOidcError } from "~/server/oidc/provider";
|
||||
import { createOidcStateCookie } from "~/utils/oidc-state";
|
||||
|
||||
import type { Route } from "./+types/oidc-start";
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const auth = context.get(authContext);
|
||||
const config = context.get(appConfigContext);
|
||||
const oidc = context.get(oidcContext);
|
||||
|
||||
try {
|
||||
await auth.require(request);
|
||||
await context.auth.require(request);
|
||||
return redirect("/");
|
||||
} catch {}
|
||||
|
||||
if (oidc.state !== "enabled") {
|
||||
throw data(`OIDC is unavailable: ${oidc.reason}`, { status: 501 });
|
||||
const service = context.oidc?.service;
|
||||
if (!service) {
|
||||
throw data("OIDC is not enabled or misconfigured", { status: 501 });
|
||||
}
|
||||
const service = oidc.value;
|
||||
|
||||
const result = await service.startFlow();
|
||||
if (!result.ok) {
|
||||
logOidcError("OIDC start failed", result.error);
|
||||
return redirect(`/login?s=${result.error.code}`);
|
||||
}
|
||||
|
||||
const { url, flowState } = result.value;
|
||||
const cookie = createOidcStateCookie(config);
|
||||
const cookie = createOidcStateCookie(context.config);
|
||||
|
||||
return redirect(url, {
|
||||
status: 302,
|
||||
|
||||
@@ -1,32 +1,24 @@
|
||||
import { data } from "react-router";
|
||||
|
||||
import {
|
||||
authContext,
|
||||
headscaleConfigContext,
|
||||
headscaleContext,
|
||||
integrationContext,
|
||||
} from "~/server/context";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
|
||||
import type { Route } from "./+types/overview";
|
||||
|
||||
export async function dnsAction({ request, context }: Route.ActionArgs) {
|
||||
const auth = context.get(authContext);
|
||||
const headscale = context.get(headscaleContext);
|
||||
const headscaleConfig = context.get(headscaleConfigContext);
|
||||
const integration = context.get(integrationContext);
|
||||
|
||||
const principal = await auth.require(request);
|
||||
const check = auth.can(principal, Capabilities.write_network);
|
||||
const principal = await context.auth.require(request);
|
||||
const check = context.auth.can(principal, Capabilities.write_network);
|
||||
|
||||
if (!check) {
|
||||
return data({ success: false }, 403);
|
||||
}
|
||||
|
||||
if (!headscaleConfig.writable()) {
|
||||
if (!context.hs.writable()) {
|
||||
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) {
|
||||
@@ -40,14 +32,14 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
await headscaleConfig.patch([
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: "dns.base_domain",
|
||||
value: newName,
|
||||
},
|
||||
]);
|
||||
|
||||
await integration?.onConfigChange(headscale);
|
||||
await context.integration?.onConfigChange(api);
|
||||
return { message: "Tailnet renamed successfully" };
|
||||
}
|
||||
case "toggle_magic": {
|
||||
@@ -56,18 +48,18 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
await headscaleConfig.patch([
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: "dns.magic_dns",
|
||||
value: newState === "enabled",
|
||||
},
|
||||
]);
|
||||
|
||||
await integration?.onConfigChange(headscale);
|
||||
await context.integration?.onConfigChange(api);
|
||||
return { message: "Magic DNS state updated successfully" };
|
||||
}
|
||||
case "remove_ns": {
|
||||
const config = headscaleConfig.getDNSConfig();
|
||||
const config = context.hs.c!;
|
||||
const ns = formData.get("ns")?.toString();
|
||||
const splitName = formData.get("split_name")?.toString();
|
||||
|
||||
@@ -76,19 +68,19 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
|
||||
}
|
||||
|
||||
if (splitName === "global") {
|
||||
const servers = config.nameservers.filter((i) => i !== ns);
|
||||
const servers = config.dns.nameservers.global.filter((i) => i !== ns);
|
||||
|
||||
await headscaleConfig.patch([
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: "dns.nameservers.global",
|
||||
value: servers,
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
const splits = config.splitDns;
|
||||
const splits = config.dns.nameservers.split;
|
||||
const servers = splits[splitName].filter((i) => i !== ns);
|
||||
|
||||
await headscaleConfig.patch([
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: `dns.nameservers.split."${splitName}"`,
|
||||
value: servers.length > 0 ? servers : null,
|
||||
@@ -96,11 +88,11 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
|
||||
]);
|
||||
}
|
||||
|
||||
await integration?.onConfigChange(headscale);
|
||||
await context.integration?.onConfigChange(api);
|
||||
return { message: "Nameserver removed successfully" };
|
||||
}
|
||||
case "add_ns": {
|
||||
const config = headscaleConfig.getDNSConfig();
|
||||
const config = context.hs.c!;
|
||||
const ns = formData.get("ns")?.toString();
|
||||
const splitName = formData.get("split_name")?.toString();
|
||||
|
||||
@@ -109,19 +101,21 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
|
||||
}
|
||||
|
||||
if (splitName === "global") {
|
||||
const servers = [...config.nameservers, ns];
|
||||
const servers = config.dns.nameservers.global;
|
||||
servers.push(ns);
|
||||
|
||||
await headscaleConfig.patch([
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: "dns.nameservers.global",
|
||||
value: servers,
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
const splits = config.splitDns;
|
||||
const servers = [...(splits[splitName] ?? []), ns];
|
||||
const splits = config.dns.nameservers.split;
|
||||
const servers = splits[splitName] ?? [];
|
||||
servers.push(ns);
|
||||
|
||||
await headscaleConfig.patch([
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: `dns.nameservers.split."${splitName}"`,
|
||||
value: servers,
|
||||
@@ -129,44 +123,45 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
|
||||
]);
|
||||
}
|
||||
|
||||
await integration?.onConfigChange(headscale);
|
||||
await context.integration?.onConfigChange(api);
|
||||
return { message: "Nameserver added successfully" };
|
||||
}
|
||||
case "remove_domain": {
|
||||
const config = headscaleConfig.getDNSConfig();
|
||||
const config = context.hs.c!;
|
||||
const domain = formData.get("domain")?.toString();
|
||||
if (!domain) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
const domains = config.searchDomains.filter((i) => i !== domain);
|
||||
await headscaleConfig.patch([
|
||||
const domains = config.dns.search_domains.filter((i) => i !== domain);
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: "dns.search_domains",
|
||||
value: domains,
|
||||
},
|
||||
]);
|
||||
|
||||
await integration?.onConfigChange(headscale);
|
||||
await context.integration?.onConfigChange(api);
|
||||
return { message: "Domain removed successfully" };
|
||||
}
|
||||
case "add_domain": {
|
||||
const config = headscaleConfig.getDNSConfig();
|
||||
const config = context.hs.c!;
|
||||
const domain = formData.get("domain")?.toString();
|
||||
if (!domain) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
const domains = [...config.searchDomains, domain];
|
||||
const domains = config.dns.search_domains;
|
||||
domains.push(domain);
|
||||
|
||||
await headscaleConfig.patch([
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: "dns.search_domains",
|
||||
value: domains,
|
||||
},
|
||||
]);
|
||||
|
||||
await integration?.onConfigChange(headscale);
|
||||
await context.integration?.onConfigChange(api);
|
||||
return { message: "Domain added successfully" };
|
||||
}
|
||||
case "remove_record": {
|
||||
@@ -178,7 +173,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
|
||||
}
|
||||
|
||||
// Value is not needed for removal
|
||||
const restart = await headscaleConfig.removeDNS({
|
||||
const restart = await context.hs.removeDNS({
|
||||
name: recordName,
|
||||
type: recordType,
|
||||
value: "",
|
||||
@@ -188,7 +183,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
|
||||
return;
|
||||
}
|
||||
|
||||
await integration?.onConfigChange(headscale);
|
||||
await context.integration?.onConfigChange(api);
|
||||
return { message: "DNS record removed successfully" };
|
||||
}
|
||||
case "add_record": {
|
||||
@@ -200,7 +195,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
const restart = await headscaleConfig.addDNS({
|
||||
const restart = await context.hs.addDNS({
|
||||
name: recordName,
|
||||
type: recordType,
|
||||
value: recordValue,
|
||||
@@ -210,7 +205,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
|
||||
return;
|
||||
}
|
||||
|
||||
await integration?.onConfigChange(headscale);
|
||||
await context.integration?.onConfigChange(api);
|
||||
return { message: "DNS record added successfully" };
|
||||
}
|
||||
case "override_dns": {
|
||||
@@ -220,14 +215,14 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
|
||||
}
|
||||
|
||||
const overrideValue = override === "true";
|
||||
await headscaleConfig.patch([
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: "dns.override_local_dns",
|
||||
value: overrideValue,
|
||||
},
|
||||
]);
|
||||
|
||||
await integration?.onConfigChange(headscale);
|
||||
await context.integration?.onConfigChange(api);
|
||||
return { message: "DNS override updated successfully" };
|
||||
}
|
||||
default:
|
||||
|
||||
+19
-13
@@ -1,13 +1,12 @@
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router";
|
||||
import { useLoaderData } from "react-router";
|
||||
|
||||
import Code from "~/components/code";
|
||||
import Notice from "~/components/notice";
|
||||
import PageError from "~/components/page-error";
|
||||
import { authContext, headscaleConfigContext } from "~/server/context";
|
||||
import type { AppContext } from "~/server/context";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
|
||||
import type { Route } from "./+types/overview";
|
||||
import ManageDomains from "./components/manage-domains";
|
||||
import ManageNS from "./components/manage-ns";
|
||||
import ManageRecords from "./components/manage-records";
|
||||
@@ -16,16 +15,13 @@ import ToggleMagic from "./components/toggle-magic";
|
||||
import { dnsAction } from "./dns-actions";
|
||||
|
||||
// We do not want to expose every config value
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const auth = context.get(authContext);
|
||||
const headscaleConfig = context.get(headscaleConfigContext);
|
||||
|
||||
if (!headscaleConfig.readable()) {
|
||||
export async function loader({ request, context }: LoaderFunctionArgs<AppContext>) {
|
||||
if (!context.hs.readable()) {
|
||||
throw new Error("No configuration is available");
|
||||
}
|
||||
|
||||
const principal = await auth.require(request);
|
||||
const check = auth.can(principal, Capabilities.read_network);
|
||||
const principal = await context.auth.require(request);
|
||||
const check = context.auth.can(principal, Capabilities.read_network);
|
||||
if (!check) {
|
||||
// Not authorized to view this page
|
||||
throw new Error(
|
||||
@@ -33,14 +29,24 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
);
|
||||
}
|
||||
|
||||
const writablePermission = auth.can(principal, Capabilities.write_network);
|
||||
const writablePermission = context.auth.can(principal, Capabilities.write_network);
|
||||
|
||||
const dns = headscaleConfig.getDNSConfig();
|
||||
const config = context.hs.c!;
|
||||
const dns = {
|
||||
prefixes: config.prefixes,
|
||||
magicDns: config.dns.magic_dns,
|
||||
baseDomain: config.dns.base_domain,
|
||||
nameservers: config.dns.nameservers.global,
|
||||
splitDns: config.dns.nameservers.split,
|
||||
searchDomains: config.dns.search_domains,
|
||||
overrideDns: config.dns.override_local_dns,
|
||||
extraRecords: context.hs.d,
|
||||
};
|
||||
|
||||
return {
|
||||
...dns,
|
||||
access: writablePermission,
|
||||
writable: headscaleConfig.writable(),
|
||||
writable: context.hs.writable(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+21
-29
@@ -10,14 +10,7 @@ import Card from "~/components/card";
|
||||
import CodeBlock from "~/components/code-block";
|
||||
import Link from "~/components/link";
|
||||
import LinkAccount from "~/layout/link-account";
|
||||
import {
|
||||
authContext,
|
||||
headscaleConfigContext,
|
||||
headscaleLiveStoreContext,
|
||||
requestApiContext,
|
||||
} from "~/server/context";
|
||||
import { usersResource } from "~/server/headscale/live-store";
|
||||
import { isUserPrincipal } from "~/server/web/auth";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
import cn from "~/utils/cn";
|
||||
import { getUserDisplayName } from "~/utils/user";
|
||||
@@ -25,24 +18,24 @@ import { getUserDisplayName } from "~/utils/user";
|
||||
import type { Route } from "./+types/home";
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const auth = context.get(authContext);
|
||||
const getRequestApi = context.get(requestApiContext);
|
||||
const headscaleConfig = context.get(headscaleConfigContext);
|
||||
const headscaleLiveStore = context.get(headscaleLiveStoreContext);
|
||||
const principal = await context.auth.require(request);
|
||||
|
||||
const principal = await auth.require(request);
|
||||
|
||||
// If the signed-in Headplane user has no linked Headscale user,
|
||||
// check for unclaimed users they can pick from before anything else.
|
||||
// If the OIDC user has no linked Headscale user, check for
|
||||
// Unclaimed users they can pick from before anything else.
|
||||
let unlinked = false;
|
||||
if (isUserPrincipal(principal) && !principal.user.headscaleUserId) {
|
||||
const { api } = await getRequestApi(request);
|
||||
if (
|
||||
typeof context.oidc === "object" &&
|
||||
principal.kind === "oidc" &&
|
||||
!principal.user.headscaleUserId
|
||||
) {
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
|
||||
let headscaleUsers: { id: string; name: string }[] = [];
|
||||
try {
|
||||
const [usersSnap, claimed] = await Promise.all([
|
||||
headscaleLiveStore.get(usersResource, api),
|
||||
auth.claimedHeadscaleUserIds(),
|
||||
context.hsLive.get(usersResource, api),
|
||||
context.auth.claimedHeadscaleUserIds(),
|
||||
]);
|
||||
|
||||
const apiUsers = usersSnap.data;
|
||||
@@ -61,22 +54,23 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
// Only warn if Headscale isn't using OIDC — if it is, the user
|
||||
// Just needs to connect a device and Headscale will auto-create
|
||||
// Their account, at which point auto-link will pick it up.
|
||||
if (!headscaleConfig.hasOIDCConfig()) {
|
||||
if (!context.hs.c?.oidc) {
|
||||
unlinked = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (auth.can(principal, Capabilities.ui_access)) {
|
||||
if (context.auth.can(principal, Capabilities.ui_access)) {
|
||||
return redirect("/machines");
|
||||
}
|
||||
|
||||
// No UI access — show the download/connect page
|
||||
const { api } = await getRequestApi(request);
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
|
||||
let linkedUserName: string | undefined;
|
||||
if (isUserPrincipal(principal) && principal.user.headscaleUserId) {
|
||||
if (principal.kind === "oidc" && principal.user.headscaleUserId) {
|
||||
try {
|
||||
const usersSnap = await headscaleLiveStore.get(usersResource, api);
|
||||
const usersSnap = await context.hsLive.get(usersResource, api);
|
||||
const hsUser = usersSnap.data.find((u) => u.id === principal.user.headscaleUserId);
|
||||
linkedUserName = hsUser?.name;
|
||||
} catch {
|
||||
@@ -88,10 +82,8 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
}
|
||||
|
||||
export async function action({ request, context }: Route.ActionArgs) {
|
||||
const auth = context.get(authContext);
|
||||
|
||||
const principal = await auth.require(request);
|
||||
if (!isUserPrincipal(principal)) {
|
||||
const principal = await context.auth.require(request);
|
||||
if (principal.kind !== "oidc") {
|
||||
return redirect("/");
|
||||
}
|
||||
|
||||
@@ -99,7 +91,7 @@ export async function action({ request, context }: Route.ActionArgs) {
|
||||
const headscaleUserId = formData.get("headscale_user_id")?.toString();
|
||||
|
||||
if (headscaleUserId) {
|
||||
await auth.linkHeadscaleUser(principal.user.id, headscaleUserId);
|
||||
await context.auth.linkHeadscaleUser(principal.user.id, headscaleUserId);
|
||||
}
|
||||
|
||||
return redirect("/");
|
||||
|
||||
@@ -12,11 +12,10 @@ import Text from "~/components/text";
|
||||
import Title from "~/components/title";
|
||||
import { useForm } from "~/hooks/use-form";
|
||||
import type { User } from "~/types";
|
||||
import { normalizeRegistrationKey } from "~/utils/register-key";
|
||||
import { getUserDisplayName } from "~/utils/user";
|
||||
|
||||
const registerSchema = type({
|
||||
register_key: "string > 0",
|
||||
register_key: "string == 24",
|
||||
user: "string > 0",
|
||||
});
|
||||
|
||||
@@ -29,16 +28,7 @@ export interface NewMachineProps {
|
||||
|
||||
export default function NewMachine(data: NewMachineProps) {
|
||||
const [pushDialog, setPushDialog] = useState(false);
|
||||
const form = useForm({
|
||||
schema: registerSchema,
|
||||
validate: (values) =>
|
||||
normalizeRegistrationKey(String(values.register_key ?? ""))
|
||||
? undefined
|
||||
: {
|
||||
register_key:
|
||||
"Paste the registration URL or full hskey-authreq-... key from tailscale up.",
|
||||
},
|
||||
});
|
||||
const form = useForm({ schema: registerSchema });
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
@@ -53,8 +43,7 @@ export default function NewMachine(data: NewMachineProps) {
|
||||
{...form.field("register_key")}
|
||||
required
|
||||
label="Machine Key"
|
||||
placeholder="hskey-authreq-XXXXXXXXXXXXXXXXXXXXXXXX"
|
||||
description="Paste the registration URL or full key shown by tailscale up."
|
||||
placeholder="AbCd..."
|
||||
/>
|
||||
<Select
|
||||
required
|
||||
@@ -63,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),
|
||||
}))}
|
||||
/>
|
||||
|
||||
@@ -12,17 +12,6 @@ const renameSchema = type({
|
||||
name: "string > 0",
|
||||
});
|
||||
|
||||
const dnsLabelPattern = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||
|
||||
function validateMachineName(values: Record<string, unknown>) {
|
||||
const name = String(values.name ?? "").toLowerCase();
|
||||
if (!dnsLabelPattern.test(name)) {
|
||||
return {
|
||||
name: "Use a valid DNS label: lowercase letters, numbers, and hyphens only. It must start and end with a letter or number.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface RenameProps {
|
||||
machine: Machine;
|
||||
isOpen: boolean;
|
||||
@@ -34,13 +23,12 @@ export default function Rename({ machine, magic, isOpen, setIsOpen }: RenameProp
|
||||
const form = useForm({
|
||||
schema: renameSchema,
|
||||
defaultValues: { name: machine.givenName },
|
||||
validate: validateMachineName,
|
||||
});
|
||||
const name = form.values.name as string;
|
||||
|
||||
return (
|
||||
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogPanel isDisabled={!form.canSubmit}>
|
||||
<DialogPanel>
|
||||
<Title>Edit machine name for {machine.givenName}</Title>
|
||||
<Text className="mb-6">
|
||||
This name is shown in the admin panel, in Tailscale clients, and used when generating
|
||||
|
||||
@@ -24,10 +24,6 @@ export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsP
|
||||
const submittingRef = useRef(false);
|
||||
const [tags, setTags] = useState([...machine.tags]);
|
||||
const [tag, setTag] = useState("tag:");
|
||||
const tagOptions = useMemo(
|
||||
() => (existingTags ?? []).filter((existingTag) => !tags.includes(existingTag)),
|
||||
[existingTags, tags],
|
||||
);
|
||||
const tagIsInvalid = useMemo(
|
||||
() => tag.length === 0 || !tag.startsWith("tag:") || tags.includes(tag),
|
||||
[tag, tags],
|
||||
@@ -102,7 +98,6 @@ export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsP
|
||||
onClick={() => {
|
||||
setTags(tags.filter((tag) => tag !== item));
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<X className="p-1" />
|
||||
</Button>
|
||||
@@ -129,26 +124,10 @@ export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsP
|
||||
setTags([...tags, tag]);
|
||||
setTag("tag:");
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Plus className="p-1" size={30} />
|
||||
</Button>
|
||||
</div>
|
||||
{tagOptions.length > 0 ? (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{tagOptions.map((option) => (
|
||||
<Button
|
||||
className="px-2 py-1 font-mono text-xs"
|
||||
key={option}
|
||||
onClick={() => setTags([...tags, option])}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{option}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<p className="mt-2 text-sm opacity-50">
|
||||
Not seeing the tags you expect? Tags need to be defined in your access control policy
|
||||
before they can be assigned to machines.
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
import { data, redirect } from "react-router";
|
||||
|
||||
import { authContext, headscaleLiveStoreContext, requestApiContext } from "~/server/context";
|
||||
import { isDataWithApiError } from "~/server/headscale/api/error-client";
|
||||
import { nodesResource } from "~/server/headscale/live-store";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
import { normalizeRegistrationKey } from "~/utils/register-key";
|
||||
|
||||
import type { Route } from "./+types/machine";
|
||||
|
||||
export async function machineAction({ request, context }: Route.ActionArgs) {
|
||||
const auth = context.get(authContext);
|
||||
const getRequestApi = context.get(requestApiContext);
|
||||
const headscaleLiveStore = context.get(headscaleLiveStoreContext);
|
||||
|
||||
const { principal, api } = await getRequestApi(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) {
|
||||
@@ -26,22 +21,15 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
|
||||
|
||||
// Fast track register since it doesn't require an existing machine
|
||||
if (action === "register") {
|
||||
if (!auth.can(principal, Capabilities.write_machines)) {
|
||||
if (!context.auth.can(principal, Capabilities.write_machines)) {
|
||||
throw data("You do not have permission to manage machines", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
const registrationKeyInput = formData.get("register_key")?.toString();
|
||||
if (!registrationKeyInput) {
|
||||
throw data("Missing `register_key` in the form data.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const registrationKey = normalizeRegistrationKey(registrationKeyInput);
|
||||
const registrationKey = formData.get("register_key")?.toString();
|
||||
if (!registrationKey) {
|
||||
throw data("Invalid `register_key` in the form data.", {
|
||||
throw data("Missing `register_key` in the form data.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
@@ -53,8 +41,8 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
|
||||
});
|
||||
}
|
||||
|
||||
const node = await api.nodes.register(user, registrationKey);
|
||||
await headscaleLiveStore.refresh(nodesResource, api);
|
||||
const node = await api.registerNode(user, registrationKey);
|
||||
await context.hsLive.refresh(nodesResource, api);
|
||||
return redirect(`/machines/${node.id}`);
|
||||
}
|
||||
|
||||
@@ -66,14 +54,14 @@ 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,
|
||||
});
|
||||
}
|
||||
|
||||
if (!auth.canManageNode(principal, node)) {
|
||||
if (!context.auth.canManageNode(principal, node)) {
|
||||
throw data("You do not have permission to act on this machine", {
|
||||
status: 403,
|
||||
});
|
||||
@@ -89,27 +77,20 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
|
||||
}
|
||||
|
||||
const name = String(formData.get("name"));
|
||||
if (!/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(name.toLowerCase())) {
|
||||
throw data(
|
||||
"Machine names must be valid DNS labels: lowercase letters, numbers, and hyphens only, and must start and end with a letter or number.",
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
await api.nodes.rename(nodeId, name);
|
||||
await headscaleLiveStore.refresh(nodesResource, api);
|
||||
await api.renameNode(nodeId, name);
|
||||
await context.hsLive.refresh(nodesResource, api);
|
||||
return { message: "Machine renamed" };
|
||||
}
|
||||
|
||||
case "delete": {
|
||||
await api.nodes.delete(nodeId);
|
||||
await headscaleLiveStore.refresh(nodesResource, api);
|
||||
await api.deleteNode(nodeId);
|
||||
await context.hsLive.refresh(nodesResource, api);
|
||||
return redirect("/machines");
|
||||
}
|
||||
|
||||
case "expire": {
|
||||
await api.nodes.expire(nodeId);
|
||||
await headscaleLiveStore.refresh(nodesResource, api);
|
||||
await api.expireNode(nodeId);
|
||||
await context.hsLive.refresh(nodesResource, api);
|
||||
return { message: "Machine expired" };
|
||||
}
|
||||
|
||||
@@ -122,12 +103,12 @@ 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 !== ""),
|
||||
);
|
||||
|
||||
await headscaleLiveStore.refresh(nodesResource, api);
|
||||
await context.hsLive.refresh(nodesResource, api);
|
||||
return { success: true as const, message: "Tags updated" };
|
||||
} catch (error) {
|
||||
if (isDataWithApiError(error) && error.data.statusCode === 400) {
|
||||
@@ -135,7 +116,6 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
|
||||
{
|
||||
success: false as const,
|
||||
error:
|
||||
extractApiErrorMessage(error.data) ??
|
||||
"One or more tags are not defined in your ACL policy. Please add them to your policy before assigning them to a machine.",
|
||||
},
|
||||
{ status: 400 },
|
||||
@@ -192,8 +172,8 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
|
||||
}
|
||||
}
|
||||
|
||||
await api.nodes.approveRoutes(nodeId, newApproved);
|
||||
await headscaleLiveStore.refresh(nodesResource, api);
|
||||
await api.approveNodeRoutes(nodeId, newApproved);
|
||||
await context.hsLive.refresh(nodesResource, api);
|
||||
return { message: "Routes updated" };
|
||||
}
|
||||
|
||||
@@ -205,13 +185,8 @@ 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 headscaleLiveStore.refresh(nodesResource, api);
|
||||
await api.setNodeUser(nodeId, user);
|
||||
await context.hsLive.refresh(nodesResource, api);
|
||||
return { message: "Machine reassigned" };
|
||||
}
|
||||
|
||||
@@ -221,14 +196,3 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function extractApiErrorMessage(error: { data?: unknown; rawData: string }) {
|
||||
if (error.data != null && typeof error.data === "object" && "message" in error.data) {
|
||||
const message = (error.data as { message?: unknown }).message;
|
||||
if (typeof message === "string" && message.length > 0) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
return error.rawData.length > 0 ? error.rawData : undefined;
|
||||
}
|
||||
|
||||
@@ -9,17 +9,10 @@ import Chip from "~/components/chip";
|
||||
import Link from "~/components/link";
|
||||
import StatusCircle from "~/components/status-circle";
|
||||
import Tooltip from "~/components/tooltip";
|
||||
import {
|
||||
agentsContext,
|
||||
headscaleConfigContext,
|
||||
headscaleContext,
|
||||
headscaleLiveStoreContext,
|
||||
requestApiContext,
|
||||
} from "~/server/context";
|
||||
import { nodesResource, usersResource } from "~/server/headscale/live-store";
|
||||
import cn from "~/utils/cn";
|
||||
import { getOSInfo, getTSVersion } from "~/utils/host-info";
|
||||
import { isNoExpiry, mapNodes, sortAssignableTags } from "~/utils/node-info";
|
||||
import { isNoExpiry, mapNodes, sortNodeTags } from "~/utils/node-info";
|
||||
import { getUserDisplayName } from "~/utils/user";
|
||||
|
||||
import type { Route } from "./+types/machine";
|
||||
@@ -29,12 +22,7 @@ import Routes from "./dialogs/routes";
|
||||
import { machineAction } from "./machine-actions";
|
||||
|
||||
export async function loader({ request, params, context }: Route.LoaderArgs) {
|
||||
const agentsFeature = context.get(agentsContext);
|
||||
const getRequestApi = context.get(requestApiContext);
|
||||
const headscale = context.get(headscaleContext);
|
||||
const headscaleConfig = context.get(headscaleConfigContext);
|
||||
const headscaleLiveStore = context.get(headscaleLiveStoreContext);
|
||||
|
||||
const principal = await context.auth.require(request);
|
||||
if (!params.id) {
|
||||
throw new Error("No machine ID provided");
|
||||
}
|
||||
@@ -43,12 +31,17 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
|
||||
throw data(null, { status: 204 });
|
||||
}
|
||||
|
||||
const magic = headscaleConfig.getMagicDNSBaseDomain();
|
||||
let magic: string | undefined;
|
||||
if (context.hs.readable()) {
|
||||
if (context.hs.c?.dns.magic_dns) {
|
||||
magic = context.hs.c.dns.base_domain;
|
||||
}
|
||||
}
|
||||
|
||||
const { api } = await getRequestApi(request);
|
||||
const api = context.hsApi.getRuntimeClient(context.auth.getHeadscaleApiKey(principal));
|
||||
const [nodesSnap, usersSnap] = await Promise.all([
|
||||
headscaleLiveStore.get(nodesResource, api),
|
||||
headscaleLiveStore.get(usersResource, api),
|
||||
context.hsLive.get(nodesResource, api),
|
||||
context.hsLive.get(usersResource, api),
|
||||
]);
|
||||
const nodes = nodesSnap.data;
|
||||
const users = usersSnap.data;
|
||||
@@ -57,30 +50,24 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
|
||||
throw data(null, { status: 404 });
|
||||
}
|
||||
|
||||
const agents = agentsFeature.state === "enabled" ? agentsFeature.value : undefined;
|
||||
const [lookup, policyResult] = await Promise.allSettled([
|
||||
agents?.lookup([node.nodeKey]),
|
||||
api.policy.get(),
|
||||
]);
|
||||
const stats = lookup.status === "fulfilled" ? lookup.value : undefined;
|
||||
const [enhancedNode] = mapNodes([node], stats);
|
||||
const lookup = await context.agents?.lookup([node.nodeKey]);
|
||||
const [enhancedNode] = mapNodes([node], lookup);
|
||||
const tags = [...node.tags].toSorted();
|
||||
const supportsNodeOwnerChange = !headscale.capabilities.nodeOwnerIsImmutable;
|
||||
const agentSync = agents?.lastSync();
|
||||
const policy = policyResult.status === "fulfilled" ? policyResult.value.policy : undefined;
|
||||
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: sortAssignableTags(nodes, policy),
|
||||
existingTags: sortNodeTags(nodes),
|
||||
magic,
|
||||
node: enhancedNode,
|
||||
stats: stats?.[enhancedNode.nodeKey],
|
||||
stats: lookup?.[enhancedNode.nodeKey],
|
||||
supportsNodeOwnerChange: supportsNodeOwnerChange,
|
||||
tags,
|
||||
users,
|
||||
|
||||
@@ -7,20 +7,10 @@ import Input from "~/components/input";
|
||||
import Link from "~/components/link";
|
||||
import PageError from "~/components/page-error";
|
||||
import Tooltip from "~/components/tooltip";
|
||||
import {
|
||||
agentsContext,
|
||||
appConfigContext,
|
||||
authContext,
|
||||
headscaleConfigContext,
|
||||
headscaleContext,
|
||||
headscaleLiveStoreContext,
|
||||
requestApiContext,
|
||||
} from "~/server/context";
|
||||
import { nodesResource, usersResource } from "~/server/headscale/live-store";
|
||||
import { isUserPrincipal } from "~/server/web/auth";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
import cn from "~/utils/cn";
|
||||
import { mapNodes, sortAssignableTags, type PopulatedNode } from "~/utils/node-info";
|
||||
import { mapNodes, sortNodeTags, type PopulatedNode } from "~/utils/node-info";
|
||||
|
||||
import type { Route } from "./+types/overview";
|
||||
import { MachineFilters } from "./components/machine-filters";
|
||||
@@ -30,61 +20,51 @@ import { useMachineFilterParams } from "./hooks/use-machine-filter-params";
|
||||
import { machineAction } from "./machine-actions";
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const agentsFeature = context.get(agentsContext);
|
||||
const auth = context.get(authContext);
|
||||
const config = context.get(appConfigContext);
|
||||
const getRequestApi = context.get(requestApiContext);
|
||||
const headscale = context.get(headscaleContext);
|
||||
const headscaleConfig = context.get(headscaleConfigContext);
|
||||
const headscaleLiveStore = context.get(headscaleLiveStoreContext);
|
||||
const principal = await context.auth.require(request);
|
||||
|
||||
const principal = await auth.require(request);
|
||||
|
||||
if (!auth.can(principal, Capabilities.read_machines)) {
|
||||
if (!context.auth.can(principal, Capabilities.read_machines)) {
|
||||
throw new Error(
|
||||
"You do not have permission to view this page. Please contact your administrator.",
|
||||
);
|
||||
}
|
||||
|
||||
const writablePermission = auth.can(principal, Capabilities.write_machines);
|
||||
const writablePermission = context.auth.can(principal, Capabilities.write_machines);
|
||||
|
||||
const { api } = await getRequestApi(request);
|
||||
const api = context.hsApi.getRuntimeClient(context.auth.getHeadscaleApiKey(principal));
|
||||
const [nodesSnap, usersSnap] = await Promise.all([
|
||||
headscaleLiveStore.get(nodesResource, api),
|
||||
headscaleLiveStore.get(usersResource, api),
|
||||
context.hsLive.get(nodesResource, api),
|
||||
context.hsLive.get(usersResource, api),
|
||||
]);
|
||||
const nodes = nodesSnap.data;
|
||||
const users = usersSnap.data;
|
||||
|
||||
const magic = headscaleConfig.getMagicDNSBaseDomain();
|
||||
let magic: string | undefined;
|
||||
if (context.hs.readable()) {
|
||||
if (context.hs.c?.dns.magic_dns) {
|
||||
magic = context.hs.c.dns.base_domain;
|
||||
}
|
||||
}
|
||||
|
||||
const agents = agentsFeature.state === "enabled" ? agentsFeature.value : undefined;
|
||||
const [statsResult, policyResult] = await Promise.allSettled([
|
||||
agents?.lookup(nodes.map((node) => node.nodeKey)),
|
||||
api.policy.get(),
|
||||
]);
|
||||
const stats = statsResult.status === "fulfilled" ? statsResult.value : undefined;
|
||||
const policy = policyResult.status === "fulfilled" ? policyResult.value.policy : undefined;
|
||||
const stats = await context.agents?.lookup(nodes.map((node) => node.nodeKey));
|
||||
const populatedNodes = mapNodes(nodes, stats);
|
||||
const supportsNodeOwnerChange = !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: isUserPrincipal(principal) ? principal.user.headscaleUserId : undefined,
|
||||
existingTags: sortAssignableTags(nodes, policy),
|
||||
headscaleUserId: principal.kind === "oidc" ? principal.user.headscaleUserId : undefined,
|
||||
magic,
|
||||
nodes,
|
||||
populatedNodes,
|
||||
preAuth: auth.can(principal, Capabilities.generate_authkeys),
|
||||
publicServer: config.headscale.public_url,
|
||||
server: config.headscale.url,
|
||||
preAuth: context.auth.can(principal, Capabilities.generate_authkeys),
|
||||
publicServer: context.config.headscale.public_url,
|
||||
server: context.config.headscale.url,
|
||||
supportsNodeOwnerChange: supportsNodeOwnerChange,
|
||||
users,
|
||||
writable: writablePermission,
|
||||
@@ -442,7 +422,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
|
||||
) : (
|
||||
filteredAndSortedNodes.map((node) => (
|
||||
<MachineRow
|
||||
existingTags={loaderData.existingTags}
|
||||
existingTags={sortNodeTags(loaderData.nodes)}
|
||||
isAgent={
|
||||
loaderData.agent !== undefined
|
||||
? node.nodeKey === loaderData.agent.nodeKey
|
||||
|
||||
@@ -6,48 +6,36 @@ import Notice from "~/components/notice";
|
||||
import StatusCircle from "~/components/status-circle";
|
||||
import Text from "~/components/text";
|
||||
import Title from "~/components/title";
|
||||
import { agentsContext, authContext } from "~/server/context";
|
||||
import { formatTimeDelta } from "~/utils/time";
|
||||
|
||||
import type { Route } from "./+types/agent";
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const agents = context.get(agentsContext);
|
||||
const auth = context.get(authContext);
|
||||
const principal = await context.auth.require(request);
|
||||
|
||||
await auth.require(request);
|
||||
|
||||
if (agents.state !== "enabled") {
|
||||
return { enabled: false as const, reason: agents.reason };
|
||||
if (!context.agents) {
|
||||
return { enabled: false as const };
|
||||
}
|
||||
|
||||
const sync = agents.value.lastSync();
|
||||
const sync = context.agents.lastSync();
|
||||
return {
|
||||
enabled: true as const,
|
||||
syncedAt: sync.syncedAt?.toISOString() ?? null,
|
||||
nodeCount: sync.nodeCount,
|
||||
error: sync.error,
|
||||
authUrl: sync.authUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export async function action({ request, context }: Route.ActionArgs) {
|
||||
const agents = context.get(agentsContext);
|
||||
const auth = context.get(authContext);
|
||||
await context.auth.require(request);
|
||||
|
||||
await auth.require(request);
|
||||
|
||||
if (agents.state !== "enabled") {
|
||||
return { success: false, error: agents.reason };
|
||||
if (!context.agents) {
|
||||
return { success: false, error: "Agent is not enabled" };
|
||||
}
|
||||
|
||||
await agents.value.triggerSync();
|
||||
const sync = agents.value.lastSync();
|
||||
return {
|
||||
success: !sync.error,
|
||||
error: sync.error,
|
||||
authUrl: sync.authUrl,
|
||||
};
|
||||
await context.agents.triggerSync();
|
||||
const sync = context.agents.lastSync();
|
||||
return { success: !sync.error, error: sync.error };
|
||||
}
|
||||
|
||||
export default function Page({ loaderData }: Route.ComponentProps) {
|
||||
@@ -59,8 +47,8 @@ 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{" "}
|
||||
<Link external styled to="https://headplane.net/features/agent">
|
||||
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>
|
||||
</Notice>
|
||||
@@ -68,7 +56,6 @@ export default function Page({ loaderData }: Route.ComponentProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const isPending = !loaderData.syncedAt && loaderData.authUrl;
|
||||
const hasError = Boolean(loaderData.error);
|
||||
|
||||
return (
|
||||
@@ -82,10 +69,8 @@ export default function Page({ loaderData }: Route.ComponentProps) {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusCircle isOnline={!hasError && !isPending} className="h-5 w-5" />
|
||||
<span className="text-lg font-medium">
|
||||
{hasError ? "Error" : isPending ? "Waiting for approval" : "Healthy"}
|
||||
</span>
|
||||
<StatusCircle isOnline={!hasError} className="h-5 w-5" />
|
||||
<span className="text-lg font-medium">{hasError ? "Error" : "Healthy"}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -103,17 +88,6 @@ export default function Page({ loaderData }: Route.ComponentProps) {
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{isPending ? (
|
||||
<Notice variant="warning" title="Agent Needs Approval">
|
||||
The agent is waiting for its Tailnet registration to be approved. Headplane will attempt
|
||||
to auto-approve it, but if that fails, you can complete approval by visiting{" "}
|
||||
<Link external styled to={loaderData.authUrl!}>
|
||||
this link
|
||||
</Link>
|
||||
.
|
||||
</Notice>
|
||||
) : undefined}
|
||||
|
||||
{loaderData.error ? (
|
||||
<Notice variant="error" title="Sync Error">
|
||||
{loaderData.error}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { data } from "react-router";
|
||||
|
||||
import { authContext, requestApiContext } from "~/server/context";
|
||||
import { isUserPrincipal } from "~/server/web/auth";
|
||||
import { getOidcSubject } from "~/server/web/headscale-identity";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
import type { PreAuthKey } from "~/types";
|
||||
@@ -9,13 +7,12 @@ import type { PreAuthKey } from "~/types";
|
||||
import type { Route } from "./+types/overview";
|
||||
|
||||
export async function authKeysAction({ request, context }: Route.ActionArgs) {
|
||||
const auth = context.get(authContext);
|
||||
const getRequestApi = context.get(requestApiContext);
|
||||
const principal = await context.auth.require(request);
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
|
||||
const { principal, api } = await getRequestApi(request);
|
||||
|
||||
const canGenerateAny = auth.can(principal, Capabilities.generate_authkeys);
|
||||
const canGenerateOwn = auth.can(principal, Capabilities.generate_own_authkeys);
|
||||
const canGenerateAny = context.auth.can(principal, Capabilities.generate_authkeys);
|
||||
const canGenerateOwn = context.auth.can(principal, Capabilities.generate_own_authkeys);
|
||||
|
||||
if (!canGenerateAny && !canGenerateOwn) {
|
||||
throw data("You do not have permission to manage pre-auth keys", {
|
||||
@@ -25,15 +22,12 @@ 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 });
|
||||
}
|
||||
const targetSubject = getOidcSubject(targetUser);
|
||||
const ownsTarget =
|
||||
isUserPrincipal(principal) &&
|
||||
(principal.user.headscaleUserId === userId || targetSubject === principal.user.subject);
|
||||
if (!ownsTarget) {
|
||||
if (principal.kind !== "oidc" || targetSubject !== principal.user.subject) {
|
||||
throw data("You do not have permission to manage this user's pre-auth keys", {
|
||||
status: 403,
|
||||
});
|
||||
@@ -92,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 });
|
||||
}
|
||||
@@ -120,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");
|
||||
}
|
||||
|
||||
|
||||
@@ -18,22 +18,10 @@ interface AddAuthKeyProps {
|
||||
users: User[];
|
||||
url: string;
|
||||
selfServiceOnly: boolean;
|
||||
currentHeadscaleUserId?: string;
|
||||
currentSubject?: string;
|
||||
}
|
||||
|
||||
function findCurrentUser(
|
||||
users: User[],
|
||||
headscaleUserId: string | undefined,
|
||||
subject: string | undefined,
|
||||
): User | undefined {
|
||||
if (headscaleUserId) {
|
||||
const linked = users.find((u) => u.id === headscaleUserId);
|
||||
if (linked) {
|
||||
return linked;
|
||||
}
|
||||
}
|
||||
|
||||
function findCurrentUser(users: User[], subject: string | undefined): User | undefined {
|
||||
if (!subject) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -50,7 +38,6 @@ export default function AddAuthKey({
|
||||
users,
|
||||
url,
|
||||
selfServiceOnly,
|
||||
currentHeadscaleUserId,
|
||||
currentSubject,
|
||||
}: AddAuthKeyProps) {
|
||||
const fetcher = useFetcher();
|
||||
@@ -59,9 +46,7 @@ export default function AddAuthKey({
|
||||
const [reusable, setReusable] = useState(false);
|
||||
const [ephemeral, setEphemeral] = useState(false);
|
||||
const [tagOnly, setTagOnly] = useState(false);
|
||||
const currentUser = selfServiceOnly
|
||||
? findCurrentUser(users, currentHeadscaleUserId, currentSubject)
|
||||
: null;
|
||||
const currentUser = selfServiceOnly ? findCurrentUser(users, currentSubject) : null;
|
||||
const availableUsers = selfServiceOnly && currentUser ? [currentUser] : users;
|
||||
const [userId, setUserId] = useState<string | null>(availableUsers[0]?.id);
|
||||
const [tags, setTags] = useState("");
|
||||
|
||||
@@ -6,14 +6,7 @@ import Link from "~/components/link";
|
||||
import Notice from "~/components/notice";
|
||||
import Select from "~/components/select";
|
||||
import TableList from "~/components/table-list";
|
||||
import {
|
||||
appConfigContext,
|
||||
authContext,
|
||||
headscaleLiveStoreContext,
|
||||
requestApiContext,
|
||||
} from "~/server/context";
|
||||
import { usersResource } from "~/server/headscale/live-store";
|
||||
import { isUserPrincipal } from "~/server/web/auth";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
import type { PreAuthKey } from "~/types";
|
||||
import type { User } from "~/types/User";
|
||||
@@ -26,14 +19,11 @@ import AuthKeyRow from "./auth-key-row";
|
||||
import AddAuthKey from "./dialogs/add-auth-key";
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const auth = context.get(authContext);
|
||||
const config = context.get(appConfigContext);
|
||||
const getRequestApi = context.get(requestApiContext);
|
||||
const headscaleLiveStore = context.get(headscaleLiveStoreContext);
|
||||
const principal = await context.auth.require(request);
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
|
||||
const { principal, api } = await getRequestApi(request);
|
||||
|
||||
const usersSnap = await headscaleLiveStore.get(usersResource, api);
|
||||
const usersSnap = await context.hsLive.get(usersResource, api);
|
||||
const users = usersSnap.data;
|
||||
|
||||
let keys: { user: User | null; preAuthKeys: PreAuthKey[] }[];
|
||||
@@ -41,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) {
|
||||
@@ -79,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);
|
||||
@@ -97,17 +85,16 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
.map(({ user, error }) => ({ error, user }));
|
||||
}
|
||||
|
||||
const canGenerateAny = auth.can(principal, Capabilities.generate_authkeys);
|
||||
const canGenerateOwn = auth.can(principal, Capabilities.generate_own_authkeys);
|
||||
const canGenerateAny = context.auth.can(principal, Capabilities.generate_authkeys);
|
||||
const canGenerateOwn = context.auth.can(principal, Capabilities.generate_own_authkeys);
|
||||
|
||||
return {
|
||||
access: canGenerateAny || canGenerateOwn,
|
||||
currentHeadscaleUserId: isUserPrincipal(principal) ? principal.user.headscaleUserId : undefined,
|
||||
currentSubject: isUserPrincipal(principal) ? principal.user.subject : undefined,
|
||||
currentSubject: principal.kind === "oidc" ? principal.user.subject : undefined,
|
||||
keys,
|
||||
missing,
|
||||
selfServiceOnly: !canGenerateAny && canGenerateOwn,
|
||||
url: config.headscale.public_url ?? config.headscale.url,
|
||||
url: context.config.headscale.public_url ?? context.config.headscale.url,
|
||||
users,
|
||||
};
|
||||
}
|
||||
@@ -116,16 +103,7 @@ export const action = authKeysAction;
|
||||
|
||||
type Status = "all" | "active" | "expired" | "reusable" | "ephemeral";
|
||||
export default function Page({
|
||||
loaderData: {
|
||||
keys,
|
||||
missing,
|
||||
users,
|
||||
url,
|
||||
access,
|
||||
selfServiceOnly,
|
||||
currentHeadscaleUserId,
|
||||
currentSubject,
|
||||
},
|
||||
loaderData: { keys, missing, users, url, access, selfServiceOnly, currentSubject },
|
||||
}: Route.ComponentProps) {
|
||||
const [selectedUser, setSelectedUser] = useState("__headplane_all");
|
||||
const [status, setStatus] = useState<Status>("active");
|
||||
@@ -221,7 +199,6 @@ export default function Page({
|
||||
</Link>
|
||||
</p>
|
||||
<AddAuthKey
|
||||
currentHeadscaleUserId={currentHeadscaleUserId}
|
||||
currentSubject={currentSubject}
|
||||
selfServiceOnly={selfServiceOnly}
|
||||
url={url}
|
||||
|
||||
@@ -2,17 +2,13 @@ import { ArrowRight } from "lucide-react";
|
||||
|
||||
import Link from "~/components/link";
|
||||
import PageError from "~/components/page-error";
|
||||
import { headscaleConfigContext, oidcContext } from "~/server/context";
|
||||
|
||||
import type { Route } from "./+types/overview";
|
||||
|
||||
export async function loader({ context }: Route.LoaderArgs) {
|
||||
const headscaleConfig = context.get(headscaleConfigContext);
|
||||
const oidc = context.get(oidcContext);
|
||||
|
||||
return {
|
||||
config: headscaleConfig.writable(),
|
||||
isOidcEnabled: oidc.state === "enabled" && oidc.value.status().state === "ready",
|
||||
config: context.hs.writable(),
|
||||
isOidcEnabled: context.oidc?.service.status().state === "ready",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +1,12 @@
|
||||
import { data } from "react-router";
|
||||
|
||||
import {
|
||||
authContext,
|
||||
headscaleConfigContext,
|
||||
headscaleContext,
|
||||
integrationContext,
|
||||
} from "~/server/context";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
|
||||
import type { Route } from "./+types/overview";
|
||||
|
||||
export async function restrictionAction({ request, context }: Route.ActionArgs) {
|
||||
const auth = context.get(authContext);
|
||||
const headscale = context.get(headscaleContext);
|
||||
const headscaleConfig = context.get(headscaleConfigContext);
|
||||
const integration = context.get(integrationContext);
|
||||
|
||||
const principal = await auth.require(request);
|
||||
const check = auth.can(principal, Capabilities.configure_iam);
|
||||
const principal = await context.auth.require(request);
|
||||
const check = context.auth.can(principal, Capabilities.configure_iam);
|
||||
|
||||
if (!check) {
|
||||
throw data("You do not have permission to modify IAM settings.", {
|
||||
@@ -25,7 +14,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
|
||||
});
|
||||
}
|
||||
|
||||
if (!headscaleConfig.writable()) {
|
||||
if (!context.hs.writable()) {
|
||||
throw data("The Headscale configuration file is not editable.", {
|
||||
status: 403,
|
||||
});
|
||||
@@ -39,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();
|
||||
@@ -48,18 +39,16 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
|
||||
});
|
||||
}
|
||||
|
||||
const domains = [
|
||||
...new Set([...(headscaleConfig.getOIDCConfig()?.allowedDomains ?? []), domain]),
|
||||
];
|
||||
const domains = [...new Set([...(context.hs.c?.oidc?.allowed_domains ?? []), domain])];
|
||||
|
||||
await headscaleConfig.patch([
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: "oidc.allowed_domains",
|
||||
value: domains,
|
||||
},
|
||||
]);
|
||||
|
||||
integration?.onConfigChange(headscale);
|
||||
context.integration?.onConfigChange(api);
|
||||
return data("Domain added successfully.");
|
||||
}
|
||||
|
||||
@@ -71,7 +60,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
|
||||
});
|
||||
}
|
||||
|
||||
const storedDomains = headscaleConfig.getOIDCConfig()?.allowedDomains ?? [];
|
||||
const storedDomains = context.hs.c?.oidc?.allowed_domains ?? [];
|
||||
if (!storedDomains.includes(domain)) {
|
||||
// Domain not found in the list
|
||||
throw data(`Domain "${domain}" not found in allowed domains.`, {
|
||||
@@ -81,13 +70,13 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
|
||||
|
||||
// Filter out the domain to remove it from the list
|
||||
const domains = storedDomains.filter((d: string) => d !== domain);
|
||||
await headscaleConfig.patch([
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: "oidc.allowed_domains",
|
||||
value: domains,
|
||||
},
|
||||
]);
|
||||
integration?.onConfigChange(headscale);
|
||||
context.integration?.onConfigChange(api);
|
||||
return data("Domain removed successfully.");
|
||||
}
|
||||
|
||||
@@ -99,18 +88,16 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
|
||||
});
|
||||
}
|
||||
|
||||
const groups = [
|
||||
...new Set([...(headscaleConfig.getOIDCConfig()?.allowedGroups ?? []), group]),
|
||||
];
|
||||
const groups = [...new Set([...(context.hs.c?.oidc?.allowed_groups ?? []), group])];
|
||||
|
||||
await headscaleConfig.patch([
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: "oidc.allowed_groups",
|
||||
value: groups,
|
||||
},
|
||||
]);
|
||||
|
||||
integration?.onConfigChange(headscale);
|
||||
context.integration?.onConfigChange(api);
|
||||
return data("Group added successfully.");
|
||||
}
|
||||
|
||||
@@ -122,7 +109,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
|
||||
});
|
||||
}
|
||||
|
||||
const storedGroups = headscaleConfig.getOIDCConfig()?.allowedGroups ?? [];
|
||||
const storedGroups = context.hs.c?.oidc?.allowed_groups ?? [];
|
||||
if (!storedGroups.includes(group)) {
|
||||
// Group not found in the list
|
||||
throw data(`Group "${group}" not found in allowed groups.`, {
|
||||
@@ -132,14 +119,14 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
|
||||
|
||||
// Filter out the group to remove it from the list
|
||||
const groups = storedGroups.filter((d: string) => d !== group);
|
||||
await headscaleConfig.patch([
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: "oidc.allowed_groups",
|
||||
value: groups,
|
||||
},
|
||||
]);
|
||||
|
||||
integration?.onConfigChange(headscale);
|
||||
context.integration?.onConfigChange(api);
|
||||
return data("Group removed successfully.");
|
||||
}
|
||||
|
||||
@@ -151,16 +138,16 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
|
||||
});
|
||||
}
|
||||
|
||||
const users = [...new Set([...(headscaleConfig.getOIDCConfig()?.allowedUsers ?? []), user])];
|
||||
const users = [...new Set([...(context.hs.c?.oidc?.allowed_users ?? []), user])];
|
||||
|
||||
await headscaleConfig.patch([
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: "oidc.allowed_users",
|
||||
value: users,
|
||||
},
|
||||
]);
|
||||
|
||||
integration?.onConfigChange(headscale);
|
||||
context.integration?.onConfigChange(api);
|
||||
return data("User added successfully.");
|
||||
}
|
||||
|
||||
@@ -172,7 +159,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
|
||||
});
|
||||
}
|
||||
|
||||
const storedUsers = headscaleConfig.getOIDCConfig()?.allowedUsers ?? [];
|
||||
const storedUsers = context.hs.c?.oidc?.allowed_users ?? [];
|
||||
if (!storedUsers.includes(user)) {
|
||||
// User not found in the list
|
||||
throw data(`User "${user}" not found in allowed users.`, {
|
||||
@@ -182,14 +169,14 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
|
||||
|
||||
// Filter out the user to remove it from the list
|
||||
const users = storedUsers.filter((d: string) => d !== user);
|
||||
await headscaleConfig.patch([
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: "oidc.allowed_users",
|
||||
value: users,
|
||||
},
|
||||
]);
|
||||
|
||||
integration?.onConfigChange(headscale);
|
||||
context.integration?.onConfigChange(api);
|
||||
return data("User removed successfully.");
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { data } from "react-router";
|
||||
|
||||
import Link from "~/components/link";
|
||||
import Notice from "~/components/notice";
|
||||
import { authContext, headscaleConfigContext } from "~/server/context";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
|
||||
import type { Route } from "./+types/overview";
|
||||
@@ -13,32 +12,28 @@ import AddUser from "./dialogs/add-user";
|
||||
import RestrictionTable from "./table";
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const auth = context.get(authContext);
|
||||
const headscaleConfig = context.get(headscaleConfigContext);
|
||||
|
||||
const principal = await auth.require(request);
|
||||
const check = auth.can(principal, Capabilities.read_users);
|
||||
const principal = await context.auth.require(request);
|
||||
const check = context.auth.can(principal, Capabilities.read_users);
|
||||
if (!check) {
|
||||
throw data("You do not have permission to view IAM settings.", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
const oidc = headscaleConfig.getOIDCConfig();
|
||||
if (!oidc) {
|
||||
if (!context.hs.c?.oidc) {
|
||||
throw data("OIDC is not configured on this Headscale instance.", {
|
||||
status: 501,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
access: auth.can(principal, Capabilities.configure_iam),
|
||||
access: context.auth.can(principal, Capabilities.configure_iam),
|
||||
settings: {
|
||||
domains: [...new Set(oidc.allowedDomains)],
|
||||
groups: [...new Set(oidc.allowedGroups)],
|
||||
users: [...new Set(oidc.allowedUsers)],
|
||||
domains: [...new Set(context.hs.c.oidc.allowed_domains)],
|
||||
groups: [...new Set(context.hs.c.oidc.allowed_groups)],
|
||||
users: [...new Set(context.hs.c.oidc.allowed_users)],
|
||||
},
|
||||
writable: headscaleConfig.writable(),
|
||||
writable: context.hs.writable(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+40
-113
@@ -5,13 +5,6 @@ import { data, isRouteErrorResponse, type ShouldRevalidateFunction } from "react
|
||||
import Button from "~/components/button";
|
||||
import Card from "~/components/card";
|
||||
import Code from "~/components/code";
|
||||
import StatusBanner from "~/components/status-banner";
|
||||
import {
|
||||
agentsContext,
|
||||
appConfigContext,
|
||||
headscaleContext,
|
||||
requestApiContext,
|
||||
} from "~/server/context";
|
||||
import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity";
|
||||
|
||||
import type { Route } from "./+types/page";
|
||||
@@ -23,20 +16,13 @@ import { loadHeadplaneWASM } from "./wasm.client";
|
||||
|
||||
const WASM_MODULE_URL = `${__PREFIX__}/hp_ssh.wasm`;
|
||||
const WASM_HELPER_URL = `${__PREFIX__}/wasm_exec.js`;
|
||||
const SSH_PREAUTH_KEY_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
export const shouldRevalidate: ShouldRevalidateFunction = () => {
|
||||
return false;
|
||||
};
|
||||
|
||||
export async function loader({ request, params, context, url }: Route.LoaderArgs) {
|
||||
const agents = context.get(agentsContext);
|
||||
const config = context.get(appConfigContext);
|
||||
const headscale = context.get(headscaleContext);
|
||||
const getRequestApi = context.get(requestApiContext);
|
||||
const compatibilityWarning = getBrowserSSHCompatibilityWarning(headscale.version);
|
||||
|
||||
const origin = url.origin;
|
||||
export async function loader({ request, params, context }: Route.LoaderArgs) {
|
||||
const origin = new URL(request.url).origin;
|
||||
const assets = [WASM_HELPER_URL, WASM_MODULE_URL];
|
||||
const missing: string[] = [];
|
||||
|
||||
@@ -51,57 +37,52 @@ export async function loader({ request, params, context, url }: Route.LoaderArgs
|
||||
throw data(sshErrors.wasm_missing, 405);
|
||||
}
|
||||
|
||||
if (agents.state !== "enabled") {
|
||||
if (context.agents == null) {
|
||||
throw data(sshErrors.agent_required, 400);
|
||||
}
|
||||
|
||||
const { principal, api } = await getRequestApi(request);
|
||||
const principal = await context.auth.require(request);
|
||||
if (principal.kind === "api_key") {
|
||||
throw data(sshErrors.oidc_required, 403);
|
||||
}
|
||||
|
||||
const hostname = params.id;
|
||||
const username = url.searchParams.get("user") || undefined;
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
|
||||
const nodes = await api.nodes.list();
|
||||
const hostname = params.id;
|
||||
const username = new URL(request.url).searchParams.get("user") || undefined;
|
||||
|
||||
const nodes = await api.getNodes();
|
||||
const node = nodes.find((n) => n.givenName === hostname);
|
||||
if (!node) {
|
||||
throw data(sshErrors.node_not_found(hostname), 404);
|
||||
}
|
||||
|
||||
if (!node.online) {
|
||||
return { hostname, username, offline: true, node: undefined, compatibilityWarning };
|
||||
return { hostname, username, offline: true, node: undefined };
|
||||
}
|
||||
|
||||
if (!username) {
|
||||
return {
|
||||
hostname,
|
||||
username: undefined,
|
||||
offline: false,
|
||||
node: undefined,
|
||||
compatibilityWarning,
|
||||
};
|
||||
return { hostname, username: undefined, offline: false, node: undefined };
|
||||
}
|
||||
|
||||
// The user must exist within Headscale to generate a pre-auth key
|
||||
const users = await api.users.list();
|
||||
const hsUser = principal.user.headscaleUserId
|
||||
? users.find((u) => u.id === principal.user.headscaleUserId)
|
||||
: findHeadscaleUserBySubject(users, principal.user.subject, principal.profile.email);
|
||||
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() + SSH_PREAUTH_KEY_TTL_MS),
|
||||
aclTags: null,
|
||||
});
|
||||
const preAuthKey = await api.createPreAuthKey(
|
||||
hsUser.id,
|
||||
true,
|
||||
false,
|
||||
new Date(Date.now() + 60 * 1000), // 1 minute expiry
|
||||
null,
|
||||
);
|
||||
|
||||
const controlURL = config.headscale.public_url ?? config.headscale.url;
|
||||
const controlURL = context.config.headscale.public_url ?? context.config.headscale.url;
|
||||
return {
|
||||
hostname,
|
||||
username,
|
||||
@@ -112,24 +93,9 @@ export async function loader({ request, params, context, url }: Route.LoaderArgs
|
||||
preAuthKey: preAuthKey.key,
|
||||
ephemeralHostname: generateHostname(username),
|
||||
},
|
||||
compatibilityWarning,
|
||||
};
|
||||
}
|
||||
|
||||
function getBrowserSSHCompatibilityWarning(version: {
|
||||
unknown: boolean;
|
||||
major: number;
|
||||
minor: number;
|
||||
patch: number;
|
||||
raw: string;
|
||||
}) {
|
||||
if (version.unknown) return null;
|
||||
if (version.major === 0 && version.minor === 29 && version.patch < 2) {
|
||||
return { version: version.raw };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function generateHostname(username: string) {
|
||||
const hex = crypto.randomUUID().replace(/-/g, "").slice(0, 8);
|
||||
return `ssh-${hex}-${username}`;
|
||||
@@ -146,66 +112,32 @@ export const links: Route.LinksFunction = () => [
|
||||
];
|
||||
|
||||
export default function Page({ loaderData }: Route.ComponentProps) {
|
||||
const { hostname, username, offline, node, compatibilityWarning } = loaderData;
|
||||
const { hostname, username, offline, node } = loaderData;
|
||||
|
||||
if (offline) {
|
||||
return (
|
||||
<>
|
||||
<BrowserSSHCompatibilityBanner warning={compatibilityWarning} />
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-black">
|
||||
<Card className="w-screen" variant="flat">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Card.Title>Node Offline</Card.Title>
|
||||
<WifiOff className="mb-2 h-6 w-6 text-red-500" />
|
||||
</div>
|
||||
<Card.Text>
|
||||
<Code>{hostname}</Code> is not currently connected to the Tailnet.
|
||||
</Card.Text>
|
||||
<Button className="mt-8 w-full" onClick={() => window.location.reload()}>
|
||||
Retry Connection
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-black">
|
||||
<Card className="w-screen" variant="flat">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Card.Title>Node Offline</Card.Title>
|
||||
<WifiOff className="mb-2 h-6 w-6 text-red-500" />
|
||||
</div>
|
||||
<Card.Text>
|
||||
<Code>{hostname}</Code> is not currently connected to the Tailnet.
|
||||
</Card.Text>
|
||||
<Button className="mt-8 w-full" onClick={() => window.location.reload()}>
|
||||
Retry Connection
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!username || !node) {
|
||||
return (
|
||||
<>
|
||||
<BrowserSSHCompatibilityBanner warning={compatibilityWarning} />
|
||||
<UserPrompt hostname={hostname} />
|
||||
</>
|
||||
);
|
||||
return <UserPrompt hostname={hostname} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<BrowserSSHCompatibilityBanner warning={compatibilityWarning} />
|
||||
<SSHConsole hostname={hostname} username={username} node={node} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BrowserSSHCompatibilityBanner({
|
||||
warning,
|
||||
}: {
|
||||
warning: { version: string } | null | undefined;
|
||||
}) {
|
||||
if (!warning) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-x-4 top-4 z-[60] mx-auto max-w-2xl">
|
||||
<StatusBanner
|
||||
variant="warning"
|
||||
title={`Browser SSH is broken on Headscale ${warning.version}`}
|
||||
>
|
||||
Headscale 0.29 beta releases through 0.29.1 reject Tailscale's browser/WASM{" "}
|
||||
<Code>/ts2021</Code> WebSocket request with <Code>405 Method Not Allowed</Code>. Upgrade
|
||||
Headscale to 0.29.2 or newer, or use Headscale 0.28.x.
|
||||
</StatusBanner>
|
||||
</div>
|
||||
);
|
||||
return <SSHConsole hostname={hostname} username={username} node={node} />;
|
||||
}
|
||||
|
||||
function SSHConsole({
|
||||
@@ -244,12 +176,7 @@ function SSHConsole({
|
||||
setSsh(instance);
|
||||
}
|
||||
},
|
||||
onError: (msg) => {
|
||||
console.error("[ssh] IPN error:", msg);
|
||||
if (!cancelled) {
|
||||
setStatus(`Failed to join Tailnet: ${msg}`);
|
||||
}
|
||||
},
|
||||
onError: (msg) => console.error("[ssh] IPN error:", msg),
|
||||
});
|
||||
|
||||
console.log("[ssh] IPN instance created", instance);
|
||||
|
||||
@@ -27,11 +27,6 @@ export default function HeadplaneUserRow({
|
||||
);
|
||||
|
||||
const displayName = user.linkedHeadscaleUser?.displayName || user.name || user.email || user.sub;
|
||||
const displayUsername =
|
||||
user.linkedHeadscaleUser?.displayName &&
|
||||
user.linkedHeadscaleUser.displayName !== user.linkedHeadscaleUser.name
|
||||
? user.linkedHeadscaleUser.name
|
||||
: undefined;
|
||||
const displayEmail = user.linkedHeadscaleUser?.email ?? user.email;
|
||||
|
||||
return (
|
||||
@@ -45,7 +40,6 @@ export default function HeadplaneUserRow({
|
||||
)}
|
||||
<div className="ml-4">
|
||||
<p className="leading-snug font-semibold">{displayName}</p>
|
||||
{displayUsername && <p className="text-sm opacity-50">{displayUsername}</p>}
|
||||
{displayEmail && <p className="text-sm opacity-50">{displayEmail}</p>}
|
||||
{!user.headscaleUserId && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">Not linked</p>
|
||||
|
||||
@@ -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,35 +4,33 @@ 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()),
|
||||
0,
|
||||
);
|
||||
const displayName = user.displayName || user.name;
|
||||
const displayUsername =
|
||||
user.displayName && user.displayName !== user.name ? user.name : undefined;
|
||||
|
||||
return (
|
||||
<tr className="group hover:bg-mist-50 dark:hover:bg-mist-950" key={user.id}>
|
||||
<td className="py-2 pl-0.5">
|
||||
<div className="flex items-center">
|
||||
{user.profilePicUrl ? (
|
||||
<img alt={displayName} className="h-10 w-10 rounded-full" src={user.profilePicUrl} />
|
||||
<img
|
||||
alt={user.name || user.displayName}
|
||||
className="h-10 w-10 rounded-full"
|
||||
src={user.profilePicUrl}
|
||||
/>
|
||||
) : (
|
||||
<CircleUser className="h-10 w-10" />
|
||||
)}
|
||||
<div className="ml-4">
|
||||
<p className="leading-snug font-semibold">{displayName}</p>
|
||||
{displayUsername && <p className="text-sm opacity-50">{displayUsername}</p>}
|
||||
<p className="leading-snug font-semibold">{user.name || user.displayName}</p>
|
||||
{user.email && <p className="text-sm opacity-50">{user.email}</p>}
|
||||
</div>
|
||||
</div>
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import PageError from "~/components/page-error";
|
||||
import {
|
||||
appConfigContext,
|
||||
authContext,
|
||||
headscaleConfigContext,
|
||||
headscaleLiveStoreContext,
|
||||
requestApiContext,
|
||||
} from "~/server/context";
|
||||
import { nodesResource, usersResource } from "~/server/headscale/live-store";
|
||||
import { isUserPrincipal } from "~/server/web/auth";
|
||||
import { Capabilities, Roles } from "~/server/web/roles";
|
||||
import type { Role } from "~/server/web/roles";
|
||||
import type { Machine, User } from "~/types";
|
||||
@@ -43,24 +35,18 @@ export interface UnlinkedHeadscaleUser extends User {
|
||||
}
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const auth = context.get(authContext);
|
||||
const config = context.get(appConfigContext);
|
||||
const getRequestApi = context.get(requestApiContext);
|
||||
const headscaleConfig = context.get(headscaleConfigContext);
|
||||
const headscaleLiveStore = context.get(headscaleLiveStoreContext);
|
||||
|
||||
const principal = await auth.require(request);
|
||||
const check = await auth.can(principal, Capabilities.read_users);
|
||||
const principal = await context.auth.require(request);
|
||||
const check = await context.auth.can(principal, Capabilities.read_users);
|
||||
if (!check) {
|
||||
throw new Error(
|
||||
"You do not have permission to view this page. Please contact your administrator.",
|
||||
);
|
||||
}
|
||||
|
||||
const writablePermission = await auth.can(principal, Capabilities.write_users);
|
||||
const writablePermission = await context.auth.can(principal, Capabilities.write_users);
|
||||
|
||||
// Primary data: Headplane users from the database (always available)
|
||||
const hpUsers = await auth.listUsers();
|
||||
const hpUsers = await context.auth.listUsers();
|
||||
|
||||
// Secondary data: Headscale API (may fail)
|
||||
let apiUsers: User[] = [];
|
||||
@@ -68,10 +54,11 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
let apiError: string | undefined;
|
||||
|
||||
try {
|
||||
const { api } = await getRequestApi(request);
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
const [nodesSnap, usersSnap] = await Promise.all([
|
||||
headscaleLiveStore.get(nodesResource, api),
|
||||
headscaleLiveStore.get(usersResource, api),
|
||||
context.hsLive.get(nodesResource, api),
|
||||
context.hsLive.get(usersResource, api),
|
||||
]);
|
||||
nodes = nodesSnap.data;
|
||||
apiUsers = usersSnap.data;
|
||||
@@ -81,7 +68,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
"Could not connect to the Headscale API. Headscale user data and machine information are unavailable.";
|
||||
}
|
||||
|
||||
const useGravatar = config.oidc?.profile_picture_source === "gravatar";
|
||||
const useGravatar = context.config.oidc?.profile_picture_source === "gravatar";
|
||||
|
||||
function resolveProfilePic(email?: string, profilePicUrl?: string): string | undefined {
|
||||
if (!useGravatar) return profilePicUrl;
|
||||
@@ -138,15 +125,20 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
claimed: claimedIds.has(u.id),
|
||||
}));
|
||||
|
||||
const magic = headscaleConfig.getMagicDNSBaseDomain();
|
||||
let magic: string | undefined;
|
||||
if (context.hs.readable()) {
|
||||
if (context.hs.c?.dns.magic_dns) {
|
||||
magic = context.hs.c.dns.base_domain;
|
||||
}
|
||||
}
|
||||
|
||||
const isOwner = isUserPrincipal(principal) && principal.user.role === "owner";
|
||||
const isOwner = principal.kind === "oidc" && principal.user.role === "owner";
|
||||
|
||||
return {
|
||||
writable: writablePermission,
|
||||
currentUserId: isUserPrincipal(principal) ? principal.user.id : undefined,
|
||||
currentUserId: principal.kind === "oidc" ? principal.user.id : undefined,
|
||||
isOwner,
|
||||
oidc: config.oidc ? { issuer: config.oidc.issuer } : undefined,
|
||||
oidc: context.config.oidc ? { issuer: context.config.oidc.issuer } : undefined,
|
||||
magic,
|
||||
apiError,
|
||||
headplaneUsers,
|
||||
@@ -243,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>
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
import { data } from "react-router";
|
||||
|
||||
import { authContext, headscaleLiveStoreContext, requestApiContext } from "~/server/context";
|
||||
import { usersResource } from "~/server/headscale/live-store";
|
||||
import { isUserPrincipal } from "~/server/web/auth";
|
||||
import { getOidcSubject } from "~/server/web/headscale-identity";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
import type { Role } from "~/server/web/roles";
|
||||
|
||||
import type { Route } from "./+types/overview";
|
||||
|
||||
export async function userAction({ request, context }: Route.ActionArgs) {
|
||||
const auth = context.get(authContext);
|
||||
const getRequestApi = context.get(requestApiContext);
|
||||
const headscaleLiveStore = context.get(headscaleLiveStoreContext);
|
||||
|
||||
const principal = await auth.require(request);
|
||||
const check = await auth.can(principal, Capabilities.write_users);
|
||||
const principal = await context.auth.require(request);
|
||||
const check = await context.auth.can(principal, Capabilities.write_users);
|
||||
if (!check) {
|
||||
throw data("You do not have permission to update users", {
|
||||
status: 403,
|
||||
@@ -29,7 +24,8 @@ export async function userAction({ request, context }: Route.ActionArgs) {
|
||||
});
|
||||
}
|
||||
|
||||
const { api } = await getRequestApi(request);
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
switch (action) {
|
||||
case "create_user": {
|
||||
const name = formData.get("username")?.toString();
|
||||
@@ -42,33 +38,33 @@ export async function userAction({ request, context }: Route.ActionArgs) {
|
||||
});
|
||||
}
|
||||
|
||||
await api.users.create({ name, email, displayName });
|
||||
await headscaleLiveStore.refresh(usersResource, api);
|
||||
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 headscaleLiveStore.refresh(usersResource, api);
|
||||
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") {
|
||||
@@ -78,20 +74,34 @@ export async function userAction({ request, context }: Route.ActionArgs) {
|
||||
});
|
||||
}
|
||||
|
||||
await api.users.rename(headscaleUserId, newName);
|
||||
await headscaleLiveStore.refresh(usersResource, api);
|
||||
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 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 });
|
||||
}
|
||||
@@ -99,16 +109,27 @@ export async function userAction({ request, context }: Route.ActionArgs) {
|
||||
return { message: "User reassigned successfully" };
|
||||
}
|
||||
case "transfer_ownership": {
|
||||
if (!isUserPrincipal(principal) || principal.user.role !== "owner") {
|
||||
if (principal.kind !== "oidc" || principal.user.role !== "owner") {
|
||||
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 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 });
|
||||
}
|
||||
@@ -116,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 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 });
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { headscaleContext } from "~/server/context";
|
||||
|
||||
import type { Route } from "./+types/healthz";
|
||||
|
||||
export async function loader({ context }: Route.LoaderArgs) {
|
||||
const headscale = context.get(headscaleContext);
|
||||
|
||||
const healthy = await 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,
|
||||
|
||||
@@ -2,15 +2,10 @@ import { versions } from "node:process";
|
||||
|
||||
import { data } from "react-router";
|
||||
|
||||
import { appConfigContext, headscaleContext } from "~/server/context";
|
||||
|
||||
import type { Route } from "./+types/info";
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const config = context.get(appConfigContext);
|
||||
const headscale = context.get(headscaleContext);
|
||||
|
||||
if (config.server.info_secret == null) {
|
||||
if (context.config.server.info_secret == null) {
|
||||
throw data(
|
||||
{
|
||||
status: "Forbidden",
|
||||
@@ -30,7 +25,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
}
|
||||
|
||||
const token = bearer.slice("Bearer ".length).trim();
|
||||
if (token !== config.server.info_secret) {
|
||||
if (token !== context.config.server.info_secret) {
|
||||
throw data(
|
||||
{
|
||||
status: "Forbidden",
|
||||
@@ -39,12 +34,14 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
);
|
||||
}
|
||||
|
||||
const healthy = await 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 ? headscale.version.raw : "unknown",
|
||||
headscale_canonical_version: healthy ? context.hsApi.apiVersion : "unknown",
|
||||
internal_versions: {
|
||||
node: versions.node,
|
||||
v8: versions.v8,
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
import { headscaleLiveStoreContext, requestApiContext } from "~/server/context";
|
||||
import { nodesResource, usersResource } from "~/server/headscale/live-store";
|
||||
import log from "~/utils/log";
|
||||
|
||||
import type { Route } from "./+types/live";
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const getRequestApi = context.get(requestApiContext);
|
||||
const headscaleLiveStore = context.get(headscaleLiveStoreContext);
|
||||
|
||||
const { api } = await getRequestApi(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([
|
||||
headscaleLiveStore.get(nodesResource, api),
|
||||
headscaleLiveStore.get(usersResource, api),
|
||||
context.hsLive.get(nodesResource, api),
|
||||
context.hsLive.get(usersResource, api),
|
||||
]);
|
||||
|
||||
const stream = new ReadableStream({
|
||||
@@ -29,11 +27,11 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
}
|
||||
};
|
||||
|
||||
const versions = headscaleLiveStore.getVersions();
|
||||
const versions = context.hsLive.getVersions();
|
||||
log.debug("sse", "Client connected, sending hello with versions: %o", versions);
|
||||
send("hello", versions);
|
||||
|
||||
const unsubscribe = headscaleLiveStore.subscribe((resource, version) => {
|
||||
const unsubscribe = context.hsLive.subscribe((resource, version) => {
|
||||
log.debug("sse", "Sending change event: %s v%s", resource, version);
|
||||
send("changed", { resource, version });
|
||||
});
|
||||
|
||||
+14
-17
@@ -9,7 +9,7 @@ runs only on the Node process — never in the browser.
|
||||
app/server/
|
||||
├── app.ts ← The Headplane application (load context, RR listener)
|
||||
├── main.ts ← Production bootstrap (binds an http(s) server)
|
||||
├── context.ts ← createAppContext() — assembles the RouterContextProvider data
|
||||
├── context.ts ← createAppContext() — assembles the AppLoadContext
|
||||
├── result.ts ← Result<T, E> helper used across the server modules
|
||||
│
|
||||
├── config/ ← YAML config loading, schema, env-overrides, integrations
|
||||
@@ -26,10 +26,9 @@ There are two SSR entries; both are picked up by Vite via `vite.config.ts`.
|
||||
|
||||
### `app.ts` — the application module
|
||||
|
||||
Loads config → builds the application context (via [`context.ts`](./context.ts))
|
||||
→ seeds React Router's `RouterContextProvider` with the named service contexts
|
||||
→ exports the React Router `RequestListener` as `default`, plus the resolved
|
||||
`config` as a named export.
|
||||
Loads config → builds the `AppLoadContext` (via [`context.ts`](./context.ts))
|
||||
→ exports the React Router `RequestListener` as `default`, plus the
|
||||
resolved `config` as a named export.
|
||||
|
||||
This module has no opinions about how the server is hosted. It does not
|
||||
listen on a socket, doesn't compose static-asset serving, and doesn't
|
||||
@@ -64,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`)
|
||||
@@ -72,19 +71,17 @@ process:
|
||||
- the (best-effort) parsed Headscale config (`hs`)
|
||||
- the integration adapter (`integration`)
|
||||
|
||||
The returned object owns process-lifetime services, but route handlers consume
|
||||
those services through named React Router contexts such as `authContext`,
|
||||
`headscaleContext`, and `headscaleConfigContext`:
|
||||
The returned object is the `AppLoadContext` exposed to every React
|
||||
Router loader/action. The module also `declare module "react-router" { interface AppLoadContext extends AppContext {} }`
|
||||
so route handlers get full type inference on `context`.
|
||||
|
||||
When a route needs a service, import the matching context from
|
||||
`~/server/context`:
|
||||
When a route needs the type, import it from `~/server/context`:
|
||||
|
||||
```ts
|
||||
import { authContext } from "~/server/context";
|
||||
import type { AppContext } from "~/server/context";
|
||||
|
||||
export async function loader({ context, request }: Route.LoaderArgs) {
|
||||
const auth = context.get(authContext);
|
||||
const principal = await auth.require(request);
|
||||
export async function loader({ context }: LoaderFunctionArgs<AppContext>) {
|
||||
// …
|
||||
}
|
||||
```
|
||||
|
||||
@@ -108,7 +105,7 @@ file is loaded, not by runtime conditionals.
|
||||
that names a coherent concern, e.g. `metrics/`, `ratelimit/`).
|
||||
2. If it owns process-lifetime state (a connection pool, a service
|
||||
client, …), construct it in [`context.ts`](./context.ts) and add it
|
||||
to the returned object. Expose it through a named React Router context
|
||||
and seed that context in [`app.ts`](./app.ts)'s `getLoadContext`.
|
||||
to the returned object — this gives every route automatic access via
|
||||
`context.<name>`.
|
||||
3. If it's purely a helper (pure functions, type definitions), import
|
||||
it directly from the module that needs it.
|
||||
|
||||
+3
-56
@@ -12,7 +12,6 @@
|
||||
import { exit, versions } from "node:process";
|
||||
|
||||
import { createRequestListener } from "@react-router/node";
|
||||
import { RouterContextProvider } from "react-router";
|
||||
import * as build from "virtual:react-router/server-build";
|
||||
|
||||
import log from "~/utils/log";
|
||||
@@ -20,20 +19,7 @@ import log from "~/utils/log";
|
||||
import type { HeadplaneConfig } from "./config/config-schema";
|
||||
import { ConfigError } from "./config/error";
|
||||
import { loadConfig } from "./config/load";
|
||||
import {
|
||||
agentsContext,
|
||||
appConfigContext,
|
||||
authContext,
|
||||
createAppContext,
|
||||
dbContext,
|
||||
headscaleApiKeyContext,
|
||||
headscaleConfigContext,
|
||||
headscaleContext,
|
||||
headscaleLiveStoreContext,
|
||||
integrationContext,
|
||||
oidcContext,
|
||||
requestApiContext,
|
||||
} from "./context";
|
||||
import { createAppContext } from "./context";
|
||||
|
||||
log.info("server", "Running Node.js %s", versions.node);
|
||||
|
||||
@@ -49,55 +35,16 @@ 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.
|
||||
function getLoadContext(request: Request, client: ClientAddress) {
|
||||
ctx.auth.registerRequestClientAddress(request, client.address);
|
||||
|
||||
const routerContext = new RouterContextProvider();
|
||||
routerContext.set(agentsContext, ctx.agents);
|
||||
routerContext.set(appConfigContext, ctx.config);
|
||||
routerContext.set(authContext, ctx.auth);
|
||||
routerContext.set(dbContext, ctx.db);
|
||||
routerContext.set(headscaleContext, ctx.headscale);
|
||||
routerContext.set(headscaleApiKeyContext, ctx.headscaleApiKey);
|
||||
routerContext.set(headscaleConfigContext, ctx.hs);
|
||||
routerContext.set(headscaleLiveStoreContext, ctx.hsLive);
|
||||
routerContext.set(integrationContext, ctx.integration);
|
||||
routerContext.set(oidcContext, ctx.oidc);
|
||||
routerContext.set(requestApiContext, ctx.apiForRequest);
|
||||
return routerContext;
|
||||
}
|
||||
|
||||
interface ClientAddress {
|
||||
address?: string;
|
||||
}
|
||||
|
||||
export default createRequestListener({
|
||||
build,
|
||||
mode: import.meta.env.MODE,
|
||||
getLoadContext,
|
||||
getLoadContext: () => ctx,
|
||||
});
|
||||
|
||||
@@ -42,23 +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?",
|
||||
|
||||
"proxy_auth?": {
|
||||
enabled: "boolean",
|
||||
allowed_cidrs: "string[]?",
|
||||
trusted_proxy_cidrs: "string[]?",
|
||||
ip_header: "string?",
|
||||
user_header: "string?",
|
||||
email_header: "string?",
|
||||
name_header: "string?",
|
||||
picture_header: "string?",
|
||||
},
|
||||
});
|
||||
|
||||
const partialServerConfig = type({
|
||||
@@ -72,20 +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?",
|
||||
|
||||
"proxy_auth?": {
|
||||
enabled: "boolean?",
|
||||
allowed_cidrs: "string[]?",
|
||||
trusted_proxy_cidrs: "string[]?",
|
||||
ip_header: "string?",
|
||||
user_header: "string?",
|
||||
email_header: "string?",
|
||||
name_header: "string?",
|
||||
picture_header: "string?",
|
||||
},
|
||||
});
|
||||
|
||||
const headscaleConfig = type({
|
||||
@@ -114,8 +83,6 @@ const partialHeadscaleConfig = type({
|
||||
tls_cert_path: "string.lower?",
|
||||
});
|
||||
|
||||
const assignableRole = '"admin" | "network_admin" | "it_admin" | "auditor" | "viewer" | "member"';
|
||||
|
||||
const oidcConfig = type({
|
||||
enabled: "boolean = true",
|
||||
issuer: "string.url",
|
||||
@@ -149,8 +116,6 @@ const oidcConfig = type({
|
||||
disable_api_key_login: "boolean = false",
|
||||
scope: 'string = "openid email profile"',
|
||||
subject_claims: type("string[]").pipe(normalizeStringArray).optional(),
|
||||
default_role: `${assignableRole} = "member"`,
|
||||
role_claim: "string?",
|
||||
allow_weak_rsa_keys: "boolean = false",
|
||||
profile_picture_source: '"oidc" | "gravatar" = "oidc"',
|
||||
extra_params: "Record<string, string>?",
|
||||
@@ -178,8 +143,6 @@ const partialOidcConfig = type({
|
||||
disable_api_key_login: "boolean?",
|
||||
scope: "string?",
|
||||
subject_claims: type("string[]").pipe(normalizeStringArray).optional(),
|
||||
default_role: `${assignableRole}?`,
|
||||
role_claim: "string?",
|
||||
allow_weak_rsa_keys: "boolean?",
|
||||
extra_params: "Record<string, string>?",
|
||||
profile_picture_source: '"oidc" | "gravatar"?',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
+67
-153
@@ -1,66 +1,51 @@
|
||||
import { join } from "node:path";
|
||||
|
||||
import { createContext } from "react-router";
|
||||
|
||||
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>>;
|
||||
export const agentsContext = createContext<AppContext["agents"]>();
|
||||
export const appConfigContext = createContext<AppContext["config"]>();
|
||||
export const authContext = createContext<AppContext["auth"]>();
|
||||
export const dbContext = createContext<AppContext["db"]>();
|
||||
export const headscaleContext = createContext<AppContext["headscale"]>();
|
||||
export const headscaleApiKeyContext = createContext<AppContext["headscaleApiKey"]>();
|
||||
export const headscaleConfigContext = createContext<AppContext["hs"]>();
|
||||
export const headscaleLiveStoreContext = createContext<AppContext["hsLive"]>();
|
||||
export const integrationContext = createContext<AppContext["integration"]>();
|
||||
export const oidcContext = createContext<AppContext["oidc"]>();
|
||||
export const requestApiContext = createContext<AppContext["apiForRequest"]>();
|
||||
|
||||
declare module "react-router" {
|
||||
interface AppLoadContext extends AppContext {}
|
||||
}
|
||||
|
||||
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,
|
||||
headscaleApiKey,
|
||||
proxyAuth: config.server.proxy_auth
|
||||
? {
|
||||
enabled: config.server.proxy_auth.enabled,
|
||||
allowedCidrs: config.server.proxy_auth.allowed_cidrs,
|
||||
trustedProxyCidrs: config.server.proxy_auth.trusted_proxy_cidrs,
|
||||
ipHeader: config.server.proxy_auth.ip_header,
|
||||
userHeader: config.server.proxy_auth.user_header,
|
||||
emailHeader: config.server.proxy_auth.email_header,
|
||||
nameHeader: config.server.proxy_auth.name_header,
|
||||
pictureHeader: config.server.proxy_auth.picture_header,
|
||||
}
|
||||
: undefined,
|
||||
db,
|
||||
cookie: {
|
||||
name: "_hp_auth",
|
||||
@@ -70,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.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,
|
||||
roleClaim: config.oidc.role_claim,
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ export type HeadplaneUserInsert = typeof users.$inferInsert;
|
||||
|
||||
export const authSessions = sqliteTable("auth_sessions", {
|
||||
id: text("id").primaryKey(),
|
||||
kind: text("kind").notNull(), // 'oidc' | 'api_key' (proxy auth is request-scoped)
|
||||
kind: text("kind").notNull(), // 'oidc' | 'api_key'
|
||||
user_id: text("user_id"),
|
||||
api_key_hash: text("api_key_hash"),
|
||||
api_key_display: text("api_key_display"),
|
||||
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
});
|
||||
@@ -1,56 +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;
|
||||
|
||||
/**
|
||||
* The `POST /api/v1/node/register` endpoint expects the full
|
||||
* `hskey-authreq-<id>` AuthID as the `key` parameter. Pre-0.29
|
||||
* Headscale expected the raw 24-character registration ID without
|
||||
* the `hskey-authreq-` prefix. Introduced in 0.29.0.
|
||||
*/
|
||||
readonly registerKeyIncludesAuthReqPrefix: 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"),
|
||||
registerKeyIncludesAuthReqPrefix: gte(version, "0.29.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);
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
}));
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
-143
@@ -1,166 +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 { type AuthApi, makeAuthApi } from "./resources/auth";
|
||||
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;
|
||||
auth: AuthApi;
|
||||
}
|
||||
|
||||
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),
|
||||
auth: makeAuthApi(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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { Capabilities } from "../capabilities";
|
||||
import type { Transport } from "../transport";
|
||||
|
||||
export interface AuthApi {
|
||||
/**
|
||||
* Approve a pending Headscale authentication request.
|
||||
* Used by the Headplane agent to auto-approve its own registration.
|
||||
*/
|
||||
approve(authId: string): Promise<void>;
|
||||
}
|
||||
|
||||
export function makeAuthApi(
|
||||
transport: Transport,
|
||||
_capabilities: Capabilities,
|
||||
apiKey: string,
|
||||
): AuthApi {
|
||||
return {
|
||||
approve: async (authId) => {
|
||||
await transport.request({
|
||||
method: "POST",
|
||||
path: "v1/auth/approve",
|
||||
apiKey,
|
||||
body: { authId },
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,121 +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.
|
||||
// Pre-0.29 expects the raw 24-char registration ID; 0.29+ expects
|
||||
// the full `hskey-authreq-<id>` AuthID.
|
||||
const registerKey = capabilities.registerKeyIncludesAuthReqPrefix
|
||||
? key
|
||||
: key.replace(/^hskey-authreq-/, "");
|
||||
const qp = new URLSearchParams();
|
||||
qp.append("user", user);
|
||||
qp.append("key", registerKey);
|
||||
const { node } = await transport.request<{ node: RawMachine }>({
|
||||
method: "POST",
|
||||
path: `v1/node/register?${qp.toString()}`,
|
||||
apiKey,
|
||||
body: { user, key: registerKey },
|
||||
});
|
||||
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,125 +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.-]+))?$/;
|
||||
|
||||
// Go pseudo-version prerelease segment: `<14-digit timestamp>-<12-hex sha>`,
|
||||
// e.g. the prerelease part of `v0.0.0-20260703052708-048308511c72`. Untagged
|
||||
// Headscale builds (per-commit `main-*` / `development` Docker images) report
|
||||
// this instead of `dev`, and it parses as semver 0.0.0 — which would strip
|
||||
// every capability from a server that actually runs the newest code.
|
||||
const GO_PSEUDO_VERSION_PRERELEASE_RE = /^\d{14}-[0-9a-f]{12}$/;
|
||||
|
||||
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;
|
||||
// A Go pseudo-version (v0.0.0-<timestamp>-<sha>) is an untagged dev build,
|
||||
// not an ancient release: treat it like `dev` so capability checks assume
|
||||
// the modern code paths instead of gating everything off.
|
||||
if (
|
||||
Number(maj) === 0 &&
|
||||
Number(min) === 0 &&
|
||||
Number(pat) === 0 &&
|
||||
pre !== undefined &&
|
||||
GO_PSEUDO_VERSION_PRERELEASE_RE.test(pre)
|
||||
) {
|
||||
return {
|
||||
major: 0,
|
||||
minor: 0,
|
||||
patch: 0,
|
||||
prerelease: pre,
|
||||
build,
|
||||
raw,
|
||||
unknown: true,
|
||||
};
|
||||
}
|
||||
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]),
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -99,7 +99,7 @@ async function validateConfigPath(path: string) {
|
||||
try {
|
||||
await access(path, constants.F_OK | constants.W_OK);
|
||||
return { w: true, r: true };
|
||||
} catch {
|
||||
} catch (error) {
|
||||
log.warn("config", "Headscale DNS file at %s is not writable", path);
|
||||
return { w: false, r: true };
|
||||
}
|
||||
|
||||
@@ -1,381 +1,258 @@
|
||||
import { constants, access, readFile, writeFile } from "node:fs/promises";
|
||||
import { exit } from "node:process";
|
||||
import { setTimeout } from "node:timers/promises";
|
||||
|
||||
import * as v from "valibot";
|
||||
import { type } from "arktype";
|
||||
import { Document, parseDocument } from "yaml";
|
||||
|
||||
import log from "~/utils/log";
|
||||
|
||||
import { DNSRecord, HeadscaleDNSConfig, loadHeadscaleDNS } from "./config-dns";
|
||||
import { headscaleConfig } from "./config-schema";
|
||||
|
||||
interface PatchConfig {
|
||||
path: string;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
interface DNSConfigView {
|
||||
magicDns: boolean;
|
||||
baseDomain: string;
|
||||
nameservers: string[];
|
||||
splitDns: Record<string, string[]>;
|
||||
searchDomains: string[];
|
||||
overrideDns: boolean;
|
||||
extraRecords: DNSRecord[];
|
||||
}
|
||||
// We need a class for the config because we need to be able to
|
||||
// support retrieving it via a getter but also be able to
|
||||
// patch it and to query it for its mode
|
||||
class HeadscaleConfig {
|
||||
private config?: typeof headscaleConfig.infer;
|
||||
private document?: Document;
|
||||
private access: "rw" | "ro" | "no";
|
||||
private path?: string;
|
||||
private writeLock = false;
|
||||
private dns?: HeadscaleDNSConfig;
|
||||
|
||||
interface OIDCConfigView {
|
||||
issuer: string;
|
||||
allowedDomains: string[];
|
||||
allowedGroups: string[];
|
||||
allowedUsers: string[];
|
||||
}
|
||||
|
||||
interface ParsedDNSConfig {
|
||||
magic_dns: boolean;
|
||||
base_domain: string;
|
||||
nameservers: {
|
||||
global: string[];
|
||||
split: Record<string, string[]>;
|
||||
};
|
||||
search_domains: string[];
|
||||
override_local_dns: boolean;
|
||||
extra_records: DNSRecord[];
|
||||
extra_records_path?: string;
|
||||
}
|
||||
|
||||
const DNS_CONFIG_DEFAULTS: ParsedDNSConfig = {
|
||||
magic_dns: true,
|
||||
base_domain: "",
|
||||
nameservers: {
|
||||
global: [],
|
||||
split: {},
|
||||
},
|
||||
search_domains: [],
|
||||
override_local_dns: true,
|
||||
extra_records: [],
|
||||
};
|
||||
|
||||
const stringSchema = v.string();
|
||||
const stringArraySchema = v.array(v.string());
|
||||
const stringArrayRecordSchema = v.record(v.string(), stringArraySchema);
|
||||
const goBooleanSchema = v.pipe(
|
||||
v.union([v.boolean(), v.picklist(["true", "false"])]),
|
||||
v.transform((value) => value === true || value === "true"),
|
||||
);
|
||||
const dnsRecordsSchema = v.array(
|
||||
v.object({
|
||||
name: v.string(),
|
||||
type: v.string(),
|
||||
value: v.string(),
|
||||
}),
|
||||
);
|
||||
const nameserversSchema = v.object({
|
||||
global: v.optional(v.fallback(stringArraySchema, []), []),
|
||||
split: v.optional(v.fallback(stringArrayRecordSchema, {}), {}),
|
||||
});
|
||||
const dnsConfigSchema = v.object({
|
||||
magic_dns: v.optional(
|
||||
v.fallback(goBooleanSchema, DNS_CONFIG_DEFAULTS.magic_dns),
|
||||
DNS_CONFIG_DEFAULTS.magic_dns,
|
||||
),
|
||||
base_domain: v.optional(
|
||||
v.fallback(stringSchema, DNS_CONFIG_DEFAULTS.base_domain),
|
||||
DNS_CONFIG_DEFAULTS.base_domain,
|
||||
),
|
||||
nameservers: v.optional(
|
||||
v.fallback(nameserversSchema, DNS_CONFIG_DEFAULTS.nameservers),
|
||||
DNS_CONFIG_DEFAULTS.nameservers,
|
||||
),
|
||||
search_domains: v.optional(
|
||||
v.fallback(stringArraySchema, DNS_CONFIG_DEFAULTS.search_domains),
|
||||
DNS_CONFIG_DEFAULTS.search_domains,
|
||||
),
|
||||
override_local_dns: v.optional(
|
||||
v.fallback(goBooleanSchema, DNS_CONFIG_DEFAULTS.override_local_dns),
|
||||
DNS_CONFIG_DEFAULTS.override_local_dns,
|
||||
),
|
||||
extra_records: v.optional(
|
||||
v.fallback(dnsRecordsSchema, DNS_CONFIG_DEFAULTS.extra_records),
|
||||
DNS_CONFIG_DEFAULTS.extra_records,
|
||||
),
|
||||
extra_records_path: v.optional(v.string()),
|
||||
});
|
||||
const headscaleConfigSchema = v.fallback(
|
||||
v.object({
|
||||
dns: v.optional(v.fallback(dnsConfigSchema, DNS_CONFIG_DEFAULTS), DNS_CONFIG_DEFAULTS),
|
||||
}),
|
||||
{ dns: DNS_CONFIG_DEFAULTS },
|
||||
);
|
||||
const oidcConfigSchema = v.object({
|
||||
issuer: v.string(),
|
||||
allowed_domains: v.optional(v.fallback(stringArraySchema, []), []),
|
||||
allowed_groups: v.optional(v.fallback(stringArraySchema, []), []),
|
||||
allowed_users: v.optional(v.fallback(stringArraySchema, []), []),
|
||||
});
|
||||
const rawOIDCConfigSchema = v.fallback(
|
||||
v.object({
|
||||
oidc: v.optional(v.unknown()),
|
||||
}),
|
||||
{},
|
||||
);
|
||||
const extraRecordsConflictSchema = v.object({
|
||||
dns: v.optional(
|
||||
v.object({
|
||||
extra_records: v.optional(v.array(v.unknown())),
|
||||
extra_records_path: v.optional(v.string()),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
interface HeadscaleConfigState {
|
||||
document?: Document;
|
||||
config: unknown;
|
||||
access: "rw" | "ro" | "no";
|
||||
path?: string;
|
||||
writeQueue: Promise<void>;
|
||||
dns?: HeadscaleDNSConfig;
|
||||
}
|
||||
|
||||
interface HeadscaleConfig {
|
||||
readable: () => boolean;
|
||||
writable: () => boolean;
|
||||
getDNSConfig: () => DNSConfigView;
|
||||
getMagicDNSBaseDomain: () => string | undefined;
|
||||
getOIDCConfig: () => OIDCConfigView | undefined;
|
||||
hasOIDCConfig: () => boolean;
|
||||
dnsRecords: () => DNSRecord[];
|
||||
patch: (patches: PatchConfig[]) => Promise<void>;
|
||||
addDNS: (record: DNSRecord) => Promise<boolean | void>;
|
||||
removeDNS: (record: DNSRecord) => Promise<boolean | void>;
|
||||
}
|
||||
|
||||
function createHeadscaleConfig(
|
||||
access: "rw" | "ro" | "no",
|
||||
dns?: HeadscaleDNSConfig,
|
||||
document?: Document,
|
||||
path?: string,
|
||||
): HeadscaleConfig {
|
||||
const state: HeadscaleConfigState = {
|
||||
access,
|
||||
config: document?.toJSON() ?? {},
|
||||
document,
|
||||
path,
|
||||
writeQueue: Promise.resolve(),
|
||||
dns,
|
||||
};
|
||||
|
||||
return {
|
||||
readable: () => readable(state),
|
||||
writable: () => writable(state),
|
||||
getDNSConfig: () => getDNSConfig(state),
|
||||
getMagicDNSBaseDomain: () => getMagicDNSBaseDomain(state),
|
||||
getOIDCConfig: () => getOIDCConfig(state),
|
||||
hasOIDCConfig: () => hasOIDCConfig(state),
|
||||
dnsRecords: () => dnsRecords(state),
|
||||
patch: (patches) => patchHeadscaleConfig(state, patches),
|
||||
addDNS: (record) => addDNS(state, record),
|
||||
removeDNS: (record) => removeDNS(state, record),
|
||||
};
|
||||
}
|
||||
|
||||
function readable(config: HeadscaleConfigState) {
|
||||
return config.access !== "no";
|
||||
}
|
||||
|
||||
function writable(config: HeadscaleConfigState) {
|
||||
return config.access === "rw";
|
||||
}
|
||||
|
||||
function getDNSConfig(config: HeadscaleConfigState): DNSConfigView {
|
||||
const dns = v.parse(headscaleConfigSchema, config.config).dns;
|
||||
|
||||
return {
|
||||
magicDns: dns.magic_dns,
|
||||
baseDomain: dns.base_domain,
|
||||
nameservers: dns.nameservers.global,
|
||||
splitDns: dns.nameservers.split ?? {},
|
||||
searchDomains: dns.search_domains,
|
||||
overrideDns: dns.override_local_dns,
|
||||
extraRecords: dnsRecords(config),
|
||||
};
|
||||
}
|
||||
|
||||
function getMagicDNSBaseDomain(config: HeadscaleConfigState) {
|
||||
if (!readable(config)) return;
|
||||
const dns = getDNSConfig(config);
|
||||
return dns.magicDns && dns.baseDomain ? dns.baseDomain : undefined;
|
||||
}
|
||||
|
||||
function getOIDCConfig(config: HeadscaleConfigState): OIDCConfigView | undefined {
|
||||
const oidc = v.safeParse(oidcConfigSchema, v.parse(rawOIDCConfigSchema, config.config).oidc);
|
||||
if (!oidc.success) return;
|
||||
|
||||
return {
|
||||
issuer: oidc.output.issuer,
|
||||
allowedDomains: oidc.output.allowed_domains,
|
||||
allowedGroups: oidc.output.allowed_groups,
|
||||
allowedUsers: oidc.output.allowed_users,
|
||||
};
|
||||
}
|
||||
|
||||
function hasOIDCConfig(config: HeadscaleConfigState) {
|
||||
return getOIDCConfig(config) !== undefined;
|
||||
}
|
||||
|
||||
function dnsRecords(config: HeadscaleConfigState) {
|
||||
if (config.dns) {
|
||||
return config.dns.r;
|
||||
constructor(
|
||||
access: "rw" | "ro" | "no",
|
||||
dns?: HeadscaleDNSConfig,
|
||||
config?: typeof headscaleConfig.infer,
|
||||
document?: Document,
|
||||
path?: string,
|
||||
) {
|
||||
this.access = access;
|
||||
this.config = config;
|
||||
this.document = document;
|
||||
this.path = path;
|
||||
this.dns = dns;
|
||||
}
|
||||
|
||||
return v.parse(headscaleConfigSchema, config.config).dns.extra_records;
|
||||
}
|
||||
|
||||
async function patchHeadscaleConfig(config: HeadscaleConfigState, patches: PatchConfig[]) {
|
||||
if (!config.path || !config.document || !readable(config) || !writable(config)) {
|
||||
return;
|
||||
readable() {
|
||||
return this.access !== "no";
|
||||
}
|
||||
|
||||
const write = config.writeQueue.then(() => writePatches(config, patches));
|
||||
config.writeQueue = write.catch(() => undefined);
|
||||
await write;
|
||||
}
|
||||
writable() {
|
||||
return this.access === "rw";
|
||||
}
|
||||
|
||||
async function writePatches(config: HeadscaleConfigState, patches: PatchConfig[]) {
|
||||
if (!config.path || !config.document) return;
|
||||
get c() {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
log.debug("config", "Patching Headscale configuration");
|
||||
for (const patch of patches) {
|
||||
const { path, value } = patch;
|
||||
log.debug("config", "Patching %s with %o", path, value);
|
||||
|
||||
const key = splitPatchPath(path);
|
||||
if (value === null) {
|
||||
config.document.deleteIn(key);
|
||||
continue;
|
||||
get d() {
|
||||
if (this.dns) {
|
||||
return this.dns.r;
|
||||
}
|
||||
|
||||
config.document.setIn(key, value);
|
||||
return this.config?.dns.extra_records ?? [];
|
||||
}
|
||||
|
||||
log.debug("config", "Writing updated Headscale configuration to %s", config.path);
|
||||
await writeFile(config.path, config.document.toString(), "utf8");
|
||||
config.config = config.document.toJSON();
|
||||
}
|
||||
|
||||
function splitPatchPath(path: string) {
|
||||
const key = [];
|
||||
let current = "";
|
||||
let quote = false;
|
||||
|
||||
for (const char of path) {
|
||||
if (char === '"') {
|
||||
quote = !quote;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "." && !quote) {
|
||||
key.push(current);
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
current += char;
|
||||
}
|
||||
|
||||
key.push(current);
|
||||
return key;
|
||||
}
|
||||
|
||||
async function addDNS(config: HeadscaleConfigState, record: DNSRecord) {
|
||||
if (config.dns) {
|
||||
if (!config.dns.readable() || !config.dns.writable()) {
|
||||
log.debug("config", "DNS config is not writable");
|
||||
async patch(patches: PatchConfig[]) {
|
||||
if (!this.path || !this.document || !this.readable() || !this.writable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const records = config.dns.r;
|
||||
if (records.some((i) => i.name === record.name && i.type === record.type)) {
|
||||
log.debug("config", "Patching Headscale configuration");
|
||||
for (const patch of patches) {
|
||||
const { path, value } = patch;
|
||||
log.debug("config", "Patching %s with %o", path, value);
|
||||
|
||||
// If the key is something like `test.bar."foo.bar"`, then we treat
|
||||
// the foo.bar as a single key, and not as two keys, so that needs
|
||||
// to be split correctly.
|
||||
|
||||
// Iterate through each character, and if we find a dot, we check if
|
||||
// the next character is a quote, and if it is, we skip until the next
|
||||
// quote, and then we skip the next character, which should be a dot.
|
||||
// If it's not a quote, we split it.
|
||||
const key = [];
|
||||
let current = "";
|
||||
let quote = false;
|
||||
|
||||
for (const char of path) {
|
||||
if (char === '"') {
|
||||
quote = !quote;
|
||||
}
|
||||
|
||||
if (char === "." && !quote) {
|
||||
key.push(current);
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
current += char;
|
||||
}
|
||||
|
||||
key.push(current.replaceAll('"', ""));
|
||||
if (value === null) {
|
||||
this.document.deleteIn(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.document.setIn(key, value);
|
||||
}
|
||||
|
||||
// Revalidate our configuration and update the config
|
||||
// object with the new configuration
|
||||
log.info("config", "Revalidating Headscale configuration");
|
||||
const config = validateConfig(this.document.toJSON());
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug("config", "Writing updated Headscale configuration to %s", this.path);
|
||||
|
||||
// We need to lock the writeLock so that we don't try to write
|
||||
// to the file while we're already writing to it
|
||||
while (this.writeLock) {
|
||||
await setTimeout(100);
|
||||
}
|
||||
|
||||
this.writeLock = true;
|
||||
await writeFile(this.path, this.document.toString(), "utf8");
|
||||
this.config = config;
|
||||
this.writeLock = false;
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a DNS record to the Headscale configuration.
|
||||
* Differentiates between the file mode and config mode automatically.
|
||||
* @param record The DNS record to add.
|
||||
* @returns True if we need to restart the integration.
|
||||
*/
|
||||
async addDNS(record: DNSRecord) {
|
||||
if (this.dns) {
|
||||
if (!this.dns.readable() || !this.dns.writable()) {
|
||||
log.debug("config", "DNS config is not writable");
|
||||
return;
|
||||
}
|
||||
|
||||
const records = this.dns.r;
|
||||
if (records.some((i) => i.name === record.name && i.type === record.type)) {
|
||||
log.debug("config", "DNS record already exists");
|
||||
return;
|
||||
}
|
||||
|
||||
return this.dns.patch([...records, record]);
|
||||
}
|
||||
|
||||
// If we get here, we need to add to the main config instead of
|
||||
// a separate file (which requires an integration restart)
|
||||
const existing = this.config?.dns.extra_records ?? [];
|
||||
if (existing.some((i) => i.name === record.name && i.type === record.type)) {
|
||||
log.debug("config", "DNS record already exists");
|
||||
return;
|
||||
}
|
||||
|
||||
return config.dns.patch([...records, record]);
|
||||
await this.patch([
|
||||
{
|
||||
path: "dns.extra_records",
|
||||
value: Array.from(new Set([...existing, record])),
|
||||
},
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const existing = dnsRecords(config);
|
||||
if (existing.some((i) => i.name === record.name && i.type === record.type)) {
|
||||
log.debug("config", "DNS record already exists");
|
||||
return;
|
||||
}
|
||||
/**
|
||||
* Removes a DNS record from the Headscale configuration.
|
||||
* Differentiates between the file mode and config mode automatically.
|
||||
* @param records The DNS record to remove.
|
||||
* @returns True if we need to restart the integration.
|
||||
*/
|
||||
async removeDNS(record: DNSRecord) {
|
||||
// In this case we need to check both the main config and the DNS config
|
||||
// to see if the record exists, and if it does, we need to remove it
|
||||
// from both places.
|
||||
|
||||
await patchHeadscaleConfig(config, [
|
||||
{
|
||||
path: "dns.extra_records",
|
||||
value: [...existing, record],
|
||||
},
|
||||
]);
|
||||
if (this.dns) {
|
||||
if (!this.dns.readable() || !this.dns.writable()) {
|
||||
log.debug("config", "DNS config is not writable");
|
||||
return;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
const records = this.dns.r.filter((i) => i.name !== record.name || i.type !== record.type);
|
||||
|
||||
async function removeDNS(config: HeadscaleConfigState, record: DNSRecord) {
|
||||
if (config.dns) {
|
||||
if (!config.dns.readable() || !config.dns.writable()) {
|
||||
log.debug("config", "DNS config is not writable");
|
||||
return this.dns.patch(records);
|
||||
}
|
||||
|
||||
// If we get here, we need to remove from the main config instead of
|
||||
// a separate file (which requires an integration restart)
|
||||
const existing = this.config?.dns.extra_records ?? [];
|
||||
const filtered = existing.filter((i) => i.name !== record.name || i.type !== record.type);
|
||||
|
||||
// If the length of the existing records is the same as the filtered
|
||||
// records, then we don't need to do anything
|
||||
if (existing.length === filtered.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const records = config.dns.r.filter((i) => i.name !== record.name || i.type !== record.type);
|
||||
return config.dns.patch(records);
|
||||
await this.patch([
|
||||
{
|
||||
path: "dns.extra_records",
|
||||
value: existing.filter((i) => i.name !== record.name || i.type !== record.type),
|
||||
},
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const existing = dnsRecords(config);
|
||||
const filtered = existing.filter((i) => i.name !== record.name || i.type !== record.type);
|
||||
if (existing.length === filtered.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
await patchHeadscaleConfig(config, [
|
||||
{
|
||||
path: "dns.extra_records",
|
||||
value: filtered,
|
||||
},
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function loadHeadscaleConfig(path?: string, dnsPath?: string) {
|
||||
export async function loadHeadscaleConfig(path?: string, strict = true, dnsPath?: string) {
|
||||
if (!path) {
|
||||
log.debug("config", "No Headscale configuration file was provided");
|
||||
return createHeadscaleConfig("no");
|
||||
return new HeadscaleConfig("no");
|
||||
}
|
||||
|
||||
log.debug("config", "Loading Headscale configuration file: %s", path);
|
||||
const { r, w } = await validateConfigPath(path);
|
||||
if (!r) {
|
||||
return createHeadscaleConfig("no");
|
||||
return new HeadscaleConfig("no");
|
||||
}
|
||||
|
||||
const document = await loadConfigFile(path);
|
||||
if (!document) {
|
||||
return createHeadscaleConfig("no");
|
||||
return new HeadscaleConfig("no");
|
||||
}
|
||||
|
||||
const rawConfig = document.toJSON();
|
||||
const parsedConfig = v.parse(headscaleConfigSchema, rawConfig);
|
||||
const conflict = v.safeParse(extraRecordsConflictSchema, rawConfig);
|
||||
const extraRecordsPath = parsedConfig.dns.extra_records_path;
|
||||
|
||||
if (conflict.success && conflict.output.dns?.extra_records && extraRecordsPath) {
|
||||
log.warn(
|
||||
"config",
|
||||
"Both dns.extra_records and dns.extra_records_path are set; Headplane will use the JSON records file",
|
||||
if (!strict) {
|
||||
return new HeadscaleConfig(
|
||||
w ? "rw" : "ro",
|
||||
new HeadscaleDNSConfig("no"),
|
||||
augmentUnstrictConfig(document.toJSON()),
|
||||
document,
|
||||
path,
|
||||
);
|
||||
}
|
||||
|
||||
const dns = await loadHeadscaleDNS(dnsPath ?? extraRecordsPath);
|
||||
if (dns && !extraRecordsPath) {
|
||||
const config = validateConfig(document.toJSON());
|
||||
if (!config) {
|
||||
return new HeadscaleConfig("no");
|
||||
}
|
||||
|
||||
if (config.dns.extra_records && config.dns.extra_records_path) {
|
||||
log.warn("config", "Both extra_records and extra_records_path are set, Headscale will crash");
|
||||
|
||||
log.warn("config", "Please remove one of them from the configuration file");
|
||||
return new HeadscaleConfig("no");
|
||||
}
|
||||
|
||||
const dns = await loadHeadscaleDNS(dnsPath);
|
||||
if (dns && !config.dns.extra_records_path) {
|
||||
log.error(
|
||||
"config",
|
||||
"Using separate DNS config file but dns.extra_records_path is not set in Headscale config",
|
||||
@@ -386,7 +263,7 @@ export async function loadHeadscaleConfig(path?: string, dnsPath?: string) {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
return createHeadscaleConfig(w ? "rw" : "ro", dns, document, path);
|
||||
return new HeadscaleConfig(w ? "rw" : "ro", dns, config, document, path);
|
||||
}
|
||||
|
||||
async function validateConfigPath(path: string) {
|
||||
@@ -402,7 +279,7 @@ async function validateConfigPath(path: string) {
|
||||
try {
|
||||
await access(path, constants.F_OK | constants.W_OK);
|
||||
return { w: true, r: true };
|
||||
} catch {
|
||||
} catch (error) {
|
||||
log.warn("config", "Headscale configuration file at %s is not writable", path);
|
||||
return { w: false, r: true };
|
||||
}
|
||||
@@ -429,3 +306,68 @@ async function loadConfigFile(path: string) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function validateConfig(config: unknown) {
|
||||
log.debug("config", "Validating Headscale configuration");
|
||||
const result = headscaleConfig(config);
|
||||
if (result instanceof type.errors) {
|
||||
log.error("config", "Error validating Headscale configuration:");
|
||||
for (const [number, error] of result.entries()) {
|
||||
log.error("config", ` - (${number}): ${error.toString()}`);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// If config_strict is false, we set the defaults and disable
|
||||
// the schema checking for the values that are not present
|
||||
function augmentUnstrictConfig(loaded: Partial<typeof headscaleConfig.infer>) {
|
||||
log.debug("config", "Augmenting Headscale configuration in non-strict mode");
|
||||
const config = {
|
||||
...loaded,
|
||||
tls_letsencrypt_cache_dir: loaded.tls_letsencrypt_cache_dir ?? "/var/www/cache",
|
||||
tls_letsencrypt_challenge_type: loaded.tls_letsencrypt_challenge_type ?? "HTTP-01",
|
||||
grpc_listen_addr: loaded.grpc_listen_addr ?? ":50443",
|
||||
grpc_allow_insecure: loaded.grpc_allow_insecure ?? false,
|
||||
randomize_client_port: loaded.randomize_client_port ?? false,
|
||||
unix_socket: loaded.unix_socket ?? "/var/run/headscale/headscale.sock",
|
||||
unix_socket_permission: loaded.unix_socket_permission ?? "0770",
|
||||
|
||||
log: loaded.log ?? {
|
||||
level: "info",
|
||||
format: "text",
|
||||
},
|
||||
|
||||
logtail: loaded.logtail ?? {
|
||||
enabled: false,
|
||||
},
|
||||
|
||||
prefixes: loaded.prefixes ?? {
|
||||
allocation: "sequential",
|
||||
v4: "",
|
||||
v6: "",
|
||||
},
|
||||
|
||||
dns: loaded.dns ?? {
|
||||
nameservers: {
|
||||
global: [],
|
||||
split: {},
|
||||
},
|
||||
search_domains: [],
|
||||
extra_records: [],
|
||||
magic_dns: false,
|
||||
base_domain: "headscale.net",
|
||||
},
|
||||
};
|
||||
|
||||
log.warn("config", "Headscale configuration was loaded in non-strict mode");
|
||||
log.warn("config", "This is very dangerous and comes with a few caveats:");
|
||||
log.warn("config", " - Headplane could very easily crash");
|
||||
log.warn("config", " - Headplane could break your Headscale installation");
|
||||
log.warn("config", " - The UI could throw random errors/show incorrect data");
|
||||
|
||||
return config as typeof headscaleConfig.infer;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { type } from "arktype";
|
||||
|
||||
const goBool = type('boolean | "true" | "false"').pipe((v) => {
|
||||
if (v === "true") return true;
|
||||
if (v === "false") return false;
|
||||
return v;
|
||||
});
|
||||
|
||||
const goDuration = type("0 | string").pipe((v) => {
|
||||
return v.toString();
|
||||
});
|
||||
|
||||
const databaseConfig = type({
|
||||
type: '"sqlite" | "sqlite3"',
|
||||
sqlite: {
|
||||
path: "string",
|
||||
write_ahead_log: goBool.default(true),
|
||||
wal_autocheckpoint: "number = 1000",
|
||||
},
|
||||
})
|
||||
.or({
|
||||
type: '"postgres"',
|
||||
postgres: {
|
||||
host: "string",
|
||||
port: 'number | ""',
|
||||
name: "string",
|
||||
user: "string",
|
||||
pass: "string",
|
||||
max_open_conns: "number = 10",
|
||||
max_idle_conns: "number = 10",
|
||||
conn_max_idle_time_secs: "number = 3600",
|
||||
ssl: goBool.default(false),
|
||||
},
|
||||
})
|
||||
.merge({
|
||||
debug: goBool.default(false),
|
||||
"gorm?": {
|
||||
prepare_stmt: goBool.default(true),
|
||||
parameterized_queries: goBool.default(true),
|
||||
skip_err_record_not_found: goBool.default(true),
|
||||
slow_threshold: "number = 1000",
|
||||
},
|
||||
});
|
||||
|
||||
// Not as strict parsing because we just need the values
|
||||
// to be slightly truthy enough to safely modify them
|
||||
export type HeadscaleConfig = typeof headscaleConfig.infer;
|
||||
export const headscaleConfig = type({
|
||||
server_url: "string",
|
||||
listen_addr: "string",
|
||||
"metrics_listen_addr?": "string",
|
||||
grpc_listen_addr: 'string = ":50433"',
|
||||
grpc_allow_insecure: goBool.default(false),
|
||||
noise: {
|
||||
private_key_path: "string",
|
||||
},
|
||||
prefixes: {
|
||||
v4: "string?",
|
||||
v6: "string?",
|
||||
allocation: '"sequential" | "random" = "sequential"',
|
||||
},
|
||||
derp: {
|
||||
server: {
|
||||
enabled: goBool.default(true),
|
||||
region_id: "number?",
|
||||
region_code: "string?",
|
||||
region_name: "string?",
|
||||
stun_listen_addr: "string?",
|
||||
private_key_path: "string?",
|
||||
ipv4: "string?",
|
||||
ipv6: "string?",
|
||||
automatically_add_embedded_derp_region: goBool.default(true),
|
||||
},
|
||||
urls: "string[]?",
|
||||
paths: "string[]?",
|
||||
auto_update_enabled: goBool.default(true),
|
||||
update_frequency: goDuration.default("24h"),
|
||||
},
|
||||
|
||||
disable_check_updates: goBool.default(false),
|
||||
ephemeral_node_inactivity_timeout: goDuration.default("30m"),
|
||||
database: databaseConfig,
|
||||
|
||||
acme_url: 'string = "https://acme-v02.api.letsencrypt.org/directory"',
|
||||
acme_email: 'string = ""',
|
||||
tls_letsencrypt_hostname: 'string = ""',
|
||||
tls_letsencrypt_cache_dir: 'string = "/var/lib/headscale/cache"',
|
||||
tls_letsencrypt_challenge_type: 'string = "HTTP-01"',
|
||||
tls_letsencrypt_listen: 'string = ":http"',
|
||||
"tls_cert_path?": "string",
|
||||
"tls_key_path?": "string",
|
||||
|
||||
log: type({
|
||||
format: 'string = "text"',
|
||||
level: 'string = "info"',
|
||||
}).default(() => ({ format: "text", level: "info" })),
|
||||
|
||||
"policy?": {
|
||||
mode: '"database" | "file" = "file"',
|
||||
path: "string?",
|
||||
},
|
||||
|
||||
dns: {
|
||||
magic_dns: goBool.default(true),
|
||||
base_domain: 'string = "headscale.net"',
|
||||
override_local_dns: goBool.default(false),
|
||||
nameservers: type({
|
||||
global: type("string[]").default(() => []),
|
||||
split: type("Record<string, string[]>").default(() => ({})),
|
||||
}).default(() => ({ global: [], split: {} })),
|
||||
search_domains: type("string[]").default(() => []),
|
||||
extra_records: type({
|
||||
name: "string",
|
||||
value: "string",
|
||||
type: 'string | "A"',
|
||||
})
|
||||
.array()
|
||||
.optional(),
|
||||
extra_records_path: "string?",
|
||||
},
|
||||
|
||||
unix_socket: "string?",
|
||||
unix_socket_permission: 'string = "0770"',
|
||||
|
||||
"oidc?": {
|
||||
only_start_if_oidc_is_available: goBool.default(false),
|
||||
issuer: "string",
|
||||
client_id: "string",
|
||||
client_secret: "string?",
|
||||
client_secret_path: "string?",
|
||||
expiry: goDuration.default("180d"),
|
||||
use_expiry_from_token: goBool.default(false),
|
||||
scope: type("string[]").default(() => ["openid", "email", "profile"]),
|
||||
extra_params: "Record<string, string>?",
|
||||
allowed_domains: "string[]?",
|
||||
allowed_groups: "string[]?",
|
||||
allowed_users: "string[]?",
|
||||
"pkce?": {
|
||||
enabled: goBool.default(false),
|
||||
method: 'string = "S256"',
|
||||
},
|
||||
map_legacy_users: goBool.default(false),
|
||||
},
|
||||
|
||||
"logtail?": {
|
||||
enabled: goBool.default(false),
|
||||
},
|
||||
|
||||
randomize_client_port: goBool.default(false),
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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/`)
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
+19
-79
@@ -1,5 +1,5 @@
|
||||
import { type ChildProcess, spawn } from "node:child_process";
|
||||
import { access, constants, mkdir, stat } from "node:fs/promises";
|
||||
import { access, constants, mkdir, rm, stat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
|
||||
@@ -11,16 +11,11 @@ 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>>;
|
||||
lastSync(): {
|
||||
syncedAt: Date | null;
|
||||
nodeCount: number;
|
||||
error?: string;
|
||||
authUrl?: string;
|
||||
};
|
||||
lastSync(): { syncedAt: Date | null; nodeCount: number; error?: string };
|
||||
agentNodeKey(): string | undefined;
|
||||
triggerSync(): Promise<void>;
|
||||
dispose(): void;
|
||||
@@ -37,7 +32,6 @@ interface SyncState {
|
||||
nodeCount: number;
|
||||
selfKey?: string;
|
||||
error?: string;
|
||||
authUrl?: string;
|
||||
}
|
||||
|
||||
async function hasExistingState(workDir: string): Promise<boolean> {
|
||||
@@ -52,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> {
|
||||
@@ -104,17 +98,12 @@ export async function createAgentManager(
|
||||
let responseHandler: ((line: string) => void) | null = null;
|
||||
let disposed = false;
|
||||
let consecutiveErrors = 0;
|
||||
let approvingAuthId: string | undefined;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -129,9 +118,6 @@ export async function createAgentManager(
|
||||
|
||||
if (authKey) {
|
||||
env.HEADPLANE_AGENT_TS_AUTHKEY = authKey;
|
||||
log.info("agent", "Spawning agent with pre-auth key (prefix: %s)", authKey.slice(0, 16));
|
||||
} else {
|
||||
log.info("agent", "Spawning agent without pre-auth key (reusing existing state)");
|
||||
}
|
||||
|
||||
const child = spawn(executablePath, [], {
|
||||
@@ -141,40 +127,8 @@ export async function createAgentManager(
|
||||
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
const text = chunk.toString().trim();
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug("agent", "%s", text);
|
||||
|
||||
// tsnet prints an auth URL when it falls back to interactive login.
|
||||
// Capture it so the UI can surface the approval link if needed, and try
|
||||
// to auto-approve the agent using Headplane's admin API access.
|
||||
const authMatch = text.match(
|
||||
/To start this tsnet server, restart with TS_AUTHKEY set, or go to: (https:\/\/\S+)/,
|
||||
);
|
||||
if (authMatch) {
|
||||
state.authUrl = authMatch[1];
|
||||
log.warn("agent", "Agent is waiting for interactive approval; visit: %s", state.authUrl);
|
||||
|
||||
const authId = state.authUrl.split("/").pop();
|
||||
if (authId && authId !== approvingAuthId) {
|
||||
approvingAuthId = authId;
|
||||
log.info("agent", "Attempting to auto-approve auth request %s", authId);
|
||||
apiClient.auth
|
||||
.approve(authId)
|
||||
.then(() => {
|
||||
log.info("agent", "Auto-approved auth request %s", authId);
|
||||
})
|
||||
.catch((error) => {
|
||||
log.warn(
|
||||
"agent",
|
||||
"Failed to auto-approve auth request %s: %s",
|
||||
authId,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
});
|
||||
}
|
||||
if (text) {
|
||||
log.debug("agent", "%s", text);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -190,13 +144,6 @@ export async function createAgentManager(
|
||||
child.on("exit", (code, signal) => {
|
||||
if (!disposed) {
|
||||
log.warn("agent", "Agent process exited (code=%s, signal=%s)", code, signal);
|
||||
} else {
|
||||
log.info(
|
||||
"agent",
|
||||
"Agent process exited during disposal (code=%s, signal=%s)",
|
||||
code,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
proc = null;
|
||||
|
||||
@@ -218,18 +165,13 @@ export async function createAgentManager(
|
||||
}
|
||||
|
||||
const stateExists = await hasExistingState(workDir);
|
||||
const authKey = await generateAuthKey();
|
||||
if (stateExists) {
|
||||
log.debug("agent", "Reusing existing tsnet identity");
|
||||
log.info(
|
||||
"agent",
|
||||
"Existing state found; agent will use it and fall back to the pre-auth key if needed",
|
||||
);
|
||||
} else {
|
||||
log.info("agent", "No tsnet state found, agent will register with a pre-auth key");
|
||||
return spawnAgent("");
|
||||
}
|
||||
|
||||
return spawnAgent(authKey);
|
||||
log.info("agent", "No tsnet state found, generating pre-auth key");
|
||||
return spawnAgent(await generateAuthKey());
|
||||
}
|
||||
|
||||
function sendSync(child: ChildProcess): Promise<string> {
|
||||
@@ -268,9 +210,10 @@ export async function createAgentManager(
|
||||
log.error("agent", "Sync error from agent (%d/5): %s", consecutiveErrors, output.error);
|
||||
|
||||
if (consecutiveErrors >= 5 && proc) {
|
||||
log.warn("agent", "Too many consecutive errors, killing agent process for retry");
|
||||
log.warn("agent", "Too many consecutive errors, killing agent and clearing state");
|
||||
proc.kill("SIGTERM");
|
||||
proc = null;
|
||||
await rm(join(workDir, "tailscaled.state"), { force: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -311,10 +254,8 @@ export async function createAgentManager(
|
||||
log.error("agent", "Sync failed (%d/5): %s", consecutiveErrors, message);
|
||||
|
||||
if (consecutiveErrors >= 5) {
|
||||
log.warn(
|
||||
"agent",
|
||||
"Too many consecutive failures; agent state is being preserved to avoid creating a new host",
|
||||
);
|
||||
log.warn("agent", "Too many consecutive failures, clearing state for next attempt");
|
||||
await rm(join(workDir, "tailscaled.state"), { force: true });
|
||||
}
|
||||
} finally {
|
||||
isSyncing = false;
|
||||
@@ -331,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) {
|
||||
@@ -357,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) {
|
||||
@@ -397,7 +338,6 @@ export async function createAgentManager(
|
||||
syncedAt: state.syncedAt,
|
||||
nodeCount: state.nodeCount,
|
||||
error: state.error,
|
||||
authUrl: state.authUrl,
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
+2
-51
@@ -8,72 +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;
|
||||
|
||||
const runtimeLogger = {
|
||||
info: (message: string, ...args: unknown[]) => log.info("server", message, ...args),
|
||||
error: (message: string, ...args: unknown[]) => log.error("server", message, ...args),
|
||||
};
|
||||
|
||||
startHttpServer({
|
||||
host: config.server.host,
|
||||
port: config.server.port,
|
||||
tls,
|
||||
logger: runtimeLogger,
|
||||
listenFile,
|
||||
listener: composeListener({
|
||||
basename: __PREFIX__,
|
||||
staticRoot: clientDir,
|
||||
immutableAssets: true,
|
||||
logger: runtimeLogger,
|
||||
requestListener,
|
||||
}),
|
||||
onShutdown: dispose,
|
||||
});
|
||||
|
||||
@@ -23,7 +23,6 @@ export interface OidcConfig {
|
||||
usePkce?: boolean;
|
||||
scope?: string;
|
||||
subjectClaims?: string[];
|
||||
roleClaim?: string;
|
||||
allowWeakRsaKeys?: boolean;
|
||||
extraParams?: Record<string, string>;
|
||||
profilePictureSource?: "oidc" | "gravatar";
|
||||
@@ -52,7 +51,6 @@ export interface OidcIdentity {
|
||||
username: string;
|
||||
email?: string;
|
||||
picture?: string;
|
||||
role?: string;
|
||||
idToken?: string;
|
||||
}
|
||||
|
||||
@@ -75,13 +73,6 @@ export interface OidcError {
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export function logOidcError(context: string, error: OidcError): void {
|
||||
log.error("auth", "%s [%s]: %s", context, error.code, error.message);
|
||||
if (error.hint) {
|
||||
log.error("auth", "Hint: %s", error.hint);
|
||||
}
|
||||
}
|
||||
|
||||
type JwksResolver = (
|
||||
protectedHeader?: JWSHeaderParameters,
|
||||
token?: FlattenedJWSInput,
|
||||
@@ -115,7 +106,6 @@ interface OidcClaims extends JWTPayload {
|
||||
preferred_username?: string;
|
||||
email?: string;
|
||||
picture?: string;
|
||||
[claim: string]: unknown;
|
||||
}
|
||||
|
||||
interface TokenResponse {
|
||||
@@ -461,15 +451,14 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
|
||||
body: URLSearchParams,
|
||||
method: "client_secret_basic" | "client_secret_post",
|
||||
): Promise<Result<TokenResponse, OidcError>> {
|
||||
const requestBody = new URLSearchParams(body);
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
};
|
||||
|
||||
if (method === "client_secret_post") {
|
||||
requestBody.set("client_id", config.clientId);
|
||||
requestBody.set("client_secret", config.clientSecret);
|
||||
body.set("client_id", config.clientId);
|
||||
body.set("client_secret", config.clientSecret);
|
||||
} else {
|
||||
const credentials = btoa(
|
||||
`${encodeURIComponent(config.clientId)}:${encodeURIComponent(config.clientSecret)}`,
|
||||
@@ -483,7 +472,7 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
|
||||
response = await fetch(tokenEndpoint, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: requestBody.toString(),
|
||||
body: body.toString(),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
} catch (cause) {
|
||||
@@ -706,11 +695,7 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
|
||||
const needsEnrichment =
|
||||
!claims.name && !claims.email && !claims.picture && !!resolveSubject(claims);
|
||||
const needsSubjectEnrichment = !resolveSubject(claims);
|
||||
const needsRoleEnrichment = !!config.roleClaim && claims[config.roleClaim] === undefined;
|
||||
if (
|
||||
(!needsEnrichment && !needsSubjectEnrichment && !needsRoleEnrichment) ||
|
||||
!ep.userinfoEndpoint
|
||||
) {
|
||||
if ((!needsEnrichment && !needsSubjectEnrichment) || !ep.userinfoEndpoint) {
|
||||
return claims;
|
||||
}
|
||||
|
||||
@@ -729,9 +714,6 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
|
||||
}
|
||||
|
||||
const userInfo = (await response.json()) as Record<string, unknown>;
|
||||
const roleClaimValue = config.roleClaim
|
||||
? (claims[config.roleClaim] ?? userInfo[config.roleClaim])
|
||||
: undefined;
|
||||
const subjectClaimValues = Object.fromEntries(
|
||||
getSubjectClaimOrder()
|
||||
.filter((claim) => claim !== "sub")
|
||||
@@ -743,9 +725,6 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
|
||||
return {
|
||||
...claims,
|
||||
...subjectClaimValues,
|
||||
...(config.roleClaim && roleClaimValue !== undefined
|
||||
? { [config.roleClaim]: roleClaimValue }
|
||||
: {}),
|
||||
name: claims.name ?? (userInfo.name as string | undefined),
|
||||
given_name: claims.given_name ?? (userInfo.given_name as string | undefined),
|
||||
family_name: claims.family_name ?? (userInfo.family_name as string | undefined),
|
||||
@@ -797,7 +776,6 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
|
||||
username,
|
||||
email: claims.email,
|
||||
picture,
|
||||
role: config.roleClaim ? resolveRoleClaim(claims, config.roleClaim) : undefined,
|
||||
idToken,
|
||||
};
|
||||
}
|
||||
@@ -852,24 +830,6 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveRoleClaim(claims: OidcClaims, claimName: string): string | undefined {
|
||||
const value = claims[claimName];
|
||||
if (typeof value === "string") {
|
||||
return value.trim() || undefined;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const roles = new Set(value.filter((v): v is string => typeof v === "string"));
|
||||
for (const role of ["admin", "network_admin", "it_admin", "auditor", "viewer", "member"]) {
|
||||
if (roles.has(role)) {
|
||||
return role;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
discover,
|
||||
|
||||
+81
-353
@@ -1,5 +1,4 @@
|
||||
import { createHash, createHmac } from "node:crypto";
|
||||
import { isIP } from "node:net";
|
||||
|
||||
import { eq, lt, sql } from "drizzle-orm";
|
||||
import { NodeSQLiteDatabase } from "drizzle-orm/node-sqlite";
|
||||
@@ -18,36 +17,23 @@ export type Principal =
|
||||
displayName: string;
|
||||
apiKey: string;
|
||||
}
|
||||
| UserPrincipal;
|
||||
|
||||
export type UserPrincipal = {
|
||||
kind: "oidc" | "proxy";
|
||||
sessionId: string;
|
||||
idToken?: string;
|
||||
user: {
|
||||
id: string;
|
||||
subject: string;
|
||||
role: Role;
|
||||
headscaleUserId: string | undefined;
|
||||
};
|
||||
profile: {
|
||||
name: string;
|
||||
email?: string;
|
||||
username?: string;
|
||||
picture?: string;
|
||||
};
|
||||
};
|
||||
|
||||
interface ProxyAuthOptions {
|
||||
enabled: boolean;
|
||||
allowedCidrs?: string[];
|
||||
trustedProxyCidrs?: string[];
|
||||
ipHeader?: string;
|
||||
userHeader?: string;
|
||||
emailHeader?: string;
|
||||
nameHeader?: string;
|
||||
pictureHeader?: string;
|
||||
}
|
||||
| {
|
||||
kind: "oidc";
|
||||
sessionId: string;
|
||||
idToken?: string;
|
||||
user: {
|
||||
id: string;
|
||||
subject: string;
|
||||
role: Role;
|
||||
headscaleUserId: string | undefined;
|
||||
};
|
||||
profile: {
|
||||
name: string;
|
||||
email?: string;
|
||||
username?: string;
|
||||
picture?: string;
|
||||
};
|
||||
};
|
||||
|
||||
interface CookiePayload {
|
||||
sid: string;
|
||||
@@ -62,7 +48,6 @@ interface CookiePayload {
|
||||
export interface AuthServiceOptions {
|
||||
secret: string;
|
||||
headscaleApiKey?: string;
|
||||
proxyAuth?: ProxyAuthOptions;
|
||||
db: NodeSQLiteDatabase;
|
||||
cookie: {
|
||||
name: string;
|
||||
@@ -73,7 +58,6 @@ export interface AuthServiceOptions {
|
||||
}
|
||||
|
||||
export interface AuthService {
|
||||
registerRequestClientAddress(request: Request, address: string | undefined): void;
|
||||
require(request: Request): Promise<Principal>;
|
||||
can(principal: Principal, capabilities: Capabilities): boolean;
|
||||
canManageNode(principal: Principal, node: Machine): boolean;
|
||||
@@ -89,168 +73,24 @@ export interface AuthService {
|
||||
findOrCreateUser(
|
||||
subject: string,
|
||||
profile?: { name?: string; email?: string; picture?: string },
|
||||
options?: { initialRole?: string; syncRole?: string },
|
||||
): Promise<string>;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function isUserPrincipal(principal: Principal): principal is UserPrincipal {
|
||||
return principal.kind === "oidc" || principal.kind === "proxy";
|
||||
}
|
||||
|
||||
interface CidrRange {
|
||||
family: 4 | 6;
|
||||
base: bigint;
|
||||
mask: bigint;
|
||||
}
|
||||
|
||||
const DEFAULT_PROXY_AUTH_CIDRS = ["127.0.0.1/32", "::1/128"];
|
||||
const DEFAULT_PROXY_AUTH_USER_HEADER = "Remote-User";
|
||||
|
||||
function normalizeIpAddress(address: string): string {
|
||||
if (address.startsWith("::ffff:")) {
|
||||
const mapped = address.slice("::ffff:".length);
|
||||
if (isIP(mapped) === 4) {
|
||||
return mapped;
|
||||
}
|
||||
}
|
||||
|
||||
return address;
|
||||
}
|
||||
|
||||
function parseIpv4(address: string): bigint | undefined {
|
||||
const parts = address.split(".");
|
||||
if (parts.length !== 4) {
|
||||
return;
|
||||
}
|
||||
|
||||
let value = 0n;
|
||||
for (const part of parts) {
|
||||
if (!/^\d+$/.test(part)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const byte = Number(part);
|
||||
if (byte < 0 || byte > 255) {
|
||||
return;
|
||||
}
|
||||
|
||||
value = (value << 8n) + BigInt(byte);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseIpv6(address: string): bigint | undefined {
|
||||
const sections = address.split("::");
|
||||
if (sections.length > 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
const head = sections[0] ? sections[0].split(":") : [];
|
||||
const tail = sections.length === 2 && sections[1] ? sections[1].split(":") : [];
|
||||
const missing = 8 - head.length - tail.length;
|
||||
if (missing < 0 || (sections.length === 1 && missing !== 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const groups = [...head, ...Array<string>(missing).fill("0"), ...tail];
|
||||
if (groups.length !== 8) {
|
||||
return;
|
||||
}
|
||||
|
||||
let value = 0n;
|
||||
for (const group of groups) {
|
||||
if (!/^[0-9a-fA-F]{1,4}$/.test(group)) {
|
||||
return;
|
||||
}
|
||||
|
||||
value = (value << 16n) + BigInt(parseInt(group, 16));
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseIpAddress(address: string): { family: 4 | 6; value: bigint } | undefined {
|
||||
const normalized = normalizeIpAddress(address);
|
||||
const family = isIP(normalized);
|
||||
if (family === 4) {
|
||||
const value = parseIpv4(normalized);
|
||||
return value === undefined ? undefined : { family, value };
|
||||
}
|
||||
if (family === 6) {
|
||||
const value = parseIpv6(normalized);
|
||||
return value === undefined ? undefined : { family, value };
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
function parseCidr(cidr: string): CidrRange {
|
||||
const parts = cidr.trim().split("/");
|
||||
if (parts.length > 2) {
|
||||
throw new Error(`Invalid proxy auth CIDR: ${cidr}`);
|
||||
}
|
||||
|
||||
const [rawAddress, rawPrefix] = parts;
|
||||
const address = parseIpAddress(rawAddress);
|
||||
if (!address) {
|
||||
throw new Error(`Invalid proxy auth CIDR address: ${cidr}`);
|
||||
}
|
||||
|
||||
const maxBits = address.family === 4 ? 32 : 128;
|
||||
const prefix = rawPrefix === undefined ? maxBits : Number(rawPrefix);
|
||||
if (!Number.isInteger(prefix) || prefix < 0 || prefix > maxBits) {
|
||||
throw new Error(`Invalid proxy auth CIDR prefix: ${cidr}`);
|
||||
}
|
||||
|
||||
const bits = BigInt(maxBits);
|
||||
const hostBits = BigInt(maxBits - prefix);
|
||||
const allOnes = (1n << bits) - 1n;
|
||||
const mask = prefix === 0 ? 0n : (allOnes << hostBits) & allOnes;
|
||||
|
||||
return {
|
||||
family: address.family,
|
||||
base: address.value & mask,
|
||||
mask,
|
||||
};
|
||||
}
|
||||
|
||||
function cidrContains(range: CidrRange, address: string): boolean {
|
||||
const parsed = parseIpAddress(address);
|
||||
if (!parsed || parsed.family !== range.family) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (parsed.value & range.mask) === range.base;
|
||||
}
|
||||
|
||||
export function createAuthService(opts: AuthServiceOptions): AuthService {
|
||||
const requestCache = new WeakMap<Request, Promise<Principal>>();
|
||||
const clientAddresses = new WeakMap<Request, string>();
|
||||
const proxyAuthCidrs = opts.proxyAuth?.enabled
|
||||
? (opts.proxyAuth.allowedCidrs?.length
|
||||
? opts.proxyAuth.allowedCidrs
|
||||
: DEFAULT_PROXY_AUTH_CIDRS
|
||||
).map(parseCidr)
|
||||
: [];
|
||||
const trustedProxyCidrs = opts.proxyAuth?.enabled
|
||||
? (opts.proxyAuth.trustedProxyCidrs?.length
|
||||
? opts.proxyAuth.trustedProxyCidrs
|
||||
: DEFAULT_PROXY_AUTH_CIDRS
|
||||
).map(parseCidr)
|
||||
: [];
|
||||
let pruneTimer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
async function encodeCookie(payload: CookiePayload, maxAge: number): Promise<string> {
|
||||
@@ -301,139 +141,7 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
|
||||
return createHash("sha256").update(key).digest("hex");
|
||||
}
|
||||
|
||||
function registerRequestClientAddress(request: Request, address: string | undefined): void {
|
||||
if (address) {
|
||||
clientAddresses.set(request, address);
|
||||
}
|
||||
}
|
||||
|
||||
function getForwardedClientAddress(request: Request): string | undefined {
|
||||
const headerName = opts.proxyAuth?.ipHeader;
|
||||
if (!headerName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = request.headers.get(headerName)?.trim();
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const first = value.split(",")[0]?.trim();
|
||||
if (!first) {
|
||||
return;
|
||||
}
|
||||
|
||||
return parseIpAddress(first) ? first : undefined;
|
||||
}
|
||||
|
||||
function getProxyAuthClientAddress(request: Request): string | undefined {
|
||||
const directAddress = clientAddresses.get(request);
|
||||
if (!directAddress) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!opts.proxyAuth?.ipHeader) {
|
||||
return directAddress;
|
||||
}
|
||||
|
||||
const directPeerTrusted = trustedProxyCidrs.some((cidr) => cidrContains(cidr, directAddress));
|
||||
if (!directPeerTrusted) {
|
||||
return;
|
||||
}
|
||||
|
||||
return getForwardedClientAddress(request);
|
||||
}
|
||||
|
||||
async function resolveUserPrincipal(options: {
|
||||
kind: UserPrincipal["kind"];
|
||||
sessionId: string;
|
||||
userId: string;
|
||||
idToken?: string;
|
||||
profile?: {
|
||||
name?: string;
|
||||
email?: string;
|
||||
username?: string;
|
||||
};
|
||||
}): Promise<UserPrincipal> {
|
||||
const [user] = await opts.db.select().from(users).where(eq(users.id, options.userId)).limit(1);
|
||||
|
||||
if (!user) {
|
||||
throw new Error("User record not found");
|
||||
}
|
||||
|
||||
const role = (user.role in Roles ? user.role : "member") as Role;
|
||||
return {
|
||||
kind: options.kind,
|
||||
sessionId: options.sessionId,
|
||||
idToken: options.idToken,
|
||||
user: {
|
||||
id: user.id,
|
||||
subject: user.sub,
|
||||
role,
|
||||
headscaleUserId: user.headscale_user_id ?? undefined,
|
||||
},
|
||||
profile: {
|
||||
name: options.profile?.name ?? user.name ?? user.sub,
|
||||
email: options.profile?.email ?? user.email ?? undefined,
|
||||
username: options.profile?.username,
|
||||
picture: user.picture ?? undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveProxyAuthPrincipal(request: Request): Promise<Principal | undefined> {
|
||||
if (!opts.proxyAuth?.enabled) {
|
||||
return;
|
||||
}
|
||||
if (!opts.headscaleApiKey) {
|
||||
throw new Error("Proxy authentication requires headscale.api_key to be configured");
|
||||
}
|
||||
|
||||
const clientAddress = getProxyAuthClientAddress(request);
|
||||
if (!clientAddress || !proxyAuthCidrs.some((cidr) => cidrContains(cidr, clientAddress))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userHeader = opts.proxyAuth.userHeader ?? DEFAULT_PROXY_AUTH_USER_HEADER;
|
||||
const proxyUser = request.headers.get(userHeader)?.trim();
|
||||
if (!proxyUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
const email = opts.proxyAuth.emailHeader
|
||||
? request.headers.get(opts.proxyAuth.emailHeader)?.trim()
|
||||
: undefined;
|
||||
const name = opts.proxyAuth.nameHeader
|
||||
? request.headers.get(opts.proxyAuth.nameHeader)?.trim()
|
||||
: undefined;
|
||||
const picture = opts.proxyAuth.pictureHeader
|
||||
? request.headers.get(opts.proxyAuth.pictureHeader)?.trim()
|
||||
: undefined;
|
||||
const subject = `proxy:${proxyUser}`;
|
||||
const userId = await findOrCreateUser(subject, {
|
||||
name: name || proxyUser,
|
||||
email: email || undefined,
|
||||
picture: picture || undefined,
|
||||
});
|
||||
|
||||
return resolveUserPrincipal({
|
||||
kind: "proxy",
|
||||
sessionId: "proxy-auth",
|
||||
userId,
|
||||
profile: {
|
||||
name: name || proxyUser,
|
||||
email: email || undefined,
|
||||
username: proxyUser,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function resolve(request: Request): Promise<Principal> {
|
||||
const proxyPrincipal = await resolveProxyAuthPrincipal(request);
|
||||
if (proxyPrincipal) {
|
||||
return proxyPrincipal;
|
||||
}
|
||||
|
||||
const payload = await decodeCookie(request);
|
||||
|
||||
const [session] = await opts.db
|
||||
@@ -468,17 +176,30 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
|
||||
throw new Error("OIDC session missing user_id");
|
||||
}
|
||||
|
||||
return resolveUserPrincipal({
|
||||
const [user] = await opts.db.select().from(users).where(eq(users.id, session.user_id)).limit(1);
|
||||
|
||||
if (!user) {
|
||||
throw new Error("User record not found");
|
||||
}
|
||||
|
||||
const role = (user.role in Roles ? user.role : "member") as Role;
|
||||
return {
|
||||
kind: "oidc",
|
||||
sessionId: session.id,
|
||||
idToken: session.oidc_id_token ?? undefined,
|
||||
userId: session.user_id,
|
||||
profile: {
|
||||
name: payload.profile?.name,
|
||||
email: payload.profile?.email,
|
||||
username: payload.profile?.username,
|
||||
user: {
|
||||
id: user.id,
|
||||
subject: user.sub,
|
||||
role,
|
||||
headscaleUserId: user.headscale_user_id ?? undefined,
|
||||
},
|
||||
});
|
||||
profile: {
|
||||
name: payload.profile?.name ?? user.name ?? user.sub,
|
||||
email: payload.profile?.email ?? user.email ?? undefined,
|
||||
username: payload.profile?.username,
|
||||
picture: user.picture ?? undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function require(request: Request): Promise<Principal> {
|
||||
@@ -521,7 +242,7 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
|
||||
}
|
||||
|
||||
if (!opts.headscaleApiKey) {
|
||||
throw new Error("User sessions require headscale.api_key to be configured");
|
||||
throw new Error("OIDC sessions require headscale.api_key to be configured");
|
||||
}
|
||||
|
||||
return opts.headscaleApiKey;
|
||||
@@ -583,21 +304,16 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
|
||||
async function findOrCreateUser(
|
||||
subject: string,
|
||||
profile?: { name?: string; email?: string; picture?: string },
|
||||
options?: { initialRole?: string; syncRole?: string },
|
||||
): Promise<string> {
|
||||
const [existing] = await opts.db.select().from(users).where(eq(users.sub, subject)).limit(1);
|
||||
|
||||
if (existing) {
|
||||
const syncedRole = normalizeInitialRole(options?.syncRole);
|
||||
await opts.db
|
||||
.update(users)
|
||||
.set({
|
||||
name: profile?.name,
|
||||
email: profile?.email,
|
||||
picture: profile?.picture,
|
||||
...(syncedRole && existing.role !== "owner"
|
||||
? { role: syncedRole, caps: capsForRole(syncedRole) }
|
||||
: {}),
|
||||
last_login_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
})
|
||||
@@ -605,7 +321,6 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
|
||||
return existing.id;
|
||||
}
|
||||
|
||||
const initialRole = normalizeInitialRole(options?.initialRole) ?? "member";
|
||||
const id = ulid();
|
||||
await opts.db.insert(users).values({
|
||||
id,
|
||||
@@ -613,8 +328,8 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
|
||||
name: profile?.name,
|
||||
email: profile?.email,
|
||||
picture: profile?.picture,
|
||||
role: initialRole,
|
||||
caps: capsForRole(initialRole),
|
||||
role: "member",
|
||||
caps: capsForRole("member"),
|
||||
});
|
||||
|
||||
const [{ count }] = await opts.db.select({ count: sql<number>`count(*)` }).from(users);
|
||||
@@ -629,14 +344,6 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
|
||||
return id;
|
||||
}
|
||||
|
||||
function normalizeInitialRole(role: string | undefined): Exclude<Role, "owner"> | undefined {
|
||||
if (role && role !== "owner" && role in Roles) {
|
||||
return role as Exclude<Role, "owner">;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function linkHeadscaleUser(userId: string, headscaleUserId: string): Promise<boolean> {
|
||||
const [existing] = await opts.db
|
||||
.select({ id: users.id })
|
||||
@@ -663,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);
|
||||
}
|
||||
@@ -704,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") {
|
||||
@@ -724,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;
|
||||
}
|
||||
|
||||
@@ -744,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;
|
||||
}
|
||||
@@ -774,7 +502,6 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
|
||||
}
|
||||
|
||||
return {
|
||||
registerRequestClientAddress,
|
||||
require: require,
|
||||
can,
|
||||
canManageNode,
|
||||
@@ -785,12 +512,13 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
|
||||
findOrCreateUser,
|
||||
linkHeadscaleUser,
|
||||
unlinkHeadscaleUser,
|
||||
linkHeadscaleUserBySubject,
|
||||
listUsers,
|
||||
claimedHeadscaleUserIds,
|
||||
roleForSubject,
|
||||
roleForHeadscaleUser,
|
||||
transferOwnership,
|
||||
reassignUser,
|
||||
reassignSubject,
|
||||
pruneExpiredSessions,
|
||||
start,
|
||||
stop,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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();
|
||||
}
|
||||
+20
-34
@@ -1,58 +1,44 @@
|
||||
// MARK: Side-Effects
|
||||
// This module contains a side-effect because log levels are read from
|
||||
// the environment once at module initialization.
|
||||
|
||||
import pino from "pino";
|
||||
// This module contains a side-effect because everything running here
|
||||
// is static and logger is later modified in `app/server/index.ts` to
|
||||
// disable debug logging if the `HEADPLANE_DEBUG_LOG` specifies as such.
|
||||
|
||||
const levels = ["info", "warn", "error", "debug"] as const;
|
||||
type Category = "server" | "config" | "agent" | "api" | "auth" | "sse";
|
||||
type Level = (typeof levels)[number];
|
||||
|
||||
export interface Logger extends Record<
|
||||
Level,
|
||||
(typeof levels)[number],
|
||||
(category: Category, message: string, ...args: unknown[]) => void
|
||||
> {
|
||||
debugEnabled: boolean;
|
||||
}
|
||||
|
||||
const rootLogger = createRootLogger();
|
||||
const logLevels = getLogLevels();
|
||||
export default {
|
||||
debugEnabled: rootLogger.isLevelEnabled("debug"),
|
||||
info: (category, msg, ...args) => rootLogger.info({ component: category }, msg, ...args),
|
||||
warn: (category, msg, ...args) => rootLogger.warn({ component: category }, msg, ...args),
|
||||
error: (category, msg, ...args) => rootLogger.error({ component: category }, msg, ...args),
|
||||
debug: (category, msg, ...args) => rootLogger.debug({ component: category }, msg, ...args),
|
||||
debugEnabled: logLevels.includes("debug"),
|
||||
debug: (..._: Parameters<Logger["debug"]>) => {},
|
||||
...Object.fromEntries(
|
||||
logLevels.map((level) => [
|
||||
level,
|
||||
(category: Category, message: string, ...args: unknown[]) => {
|
||||
const date = new Date().toISOString();
|
||||
console.log(`${date} [${category}] ${level.toUpperCase()}: ${message}`, ...args);
|
||||
},
|
||||
]),
|
||||
),
|
||||
} as Logger;
|
||||
|
||||
function createRootLogger() {
|
||||
const options = {
|
||||
level: getLogLevel(),
|
||||
timestamp: () => `,"timestamp":"${new Date().toISOString()}"`,
|
||||
formatters: {
|
||||
level: (label: string) => ({ level: label }),
|
||||
},
|
||||
} satisfies pino.LoggerOptions;
|
||||
|
||||
if (process.env.NODE_ENV === "test") {
|
||||
return pino(options);
|
||||
}
|
||||
|
||||
const destination = pino.destination({ dest: 1, sync: false });
|
||||
process.on("exit", () => destination.flushSync());
|
||||
return pino(options, destination);
|
||||
}
|
||||
|
||||
function getLogLevel(): Level {
|
||||
function getLogLevels() {
|
||||
const debugLog = process.env.HEADPLANE_DEBUG_LOG;
|
||||
if (debugLog == null) {
|
||||
return "info";
|
||||
return ["info", "warn", "error"];
|
||||
}
|
||||
|
||||
const normalized = debugLog.trim().toLowerCase();
|
||||
const truthyValues = ["1", "true", "yes", "on"];
|
||||
if (!truthyValues.includes(normalized)) {
|
||||
return "info";
|
||||
return ["info", "warn", "error"];
|
||||
}
|
||||
|
||||
return "debug";
|
||||
return ["info", "warn", "error", "debug"];
|
||||
}
|
||||
|
||||
@@ -48,94 +48,3 @@ export function mapNodes(
|
||||
export function sortNodeTags(nodes: Machine[]): string[] {
|
||||
return Array.from(new Set(nodes.flatMap((node) => node.tags))).sort();
|
||||
}
|
||||
|
||||
export function sortAssignableTags(nodes: Machine[], policy?: string): string[] {
|
||||
return Array.from(new Set([...sortNodeTags(nodes), ...extractTagOwnerTags(policy)])).sort();
|
||||
}
|
||||
|
||||
export function extractTagOwnerTags(policy: string | undefined): string[] {
|
||||
if (!policy) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(stripJsonCommentsAndTrailingCommas(policy)) as unknown;
|
||||
if (parsed == null || typeof parsed !== "object" || !("tagOwners" in parsed)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const tagOwners = (parsed as { tagOwners?: unknown }).tagOwners;
|
||||
if (tagOwners == null || typeof tagOwners !== "object" || Array.isArray(tagOwners)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.keys(tagOwners)
|
||||
.filter((tag) => tag.startsWith("tag:"))
|
||||
.sort();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function stripJsonCommentsAndTrailingCommas(input: string): string {
|
||||
let output = "";
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
let inLineComment = false;
|
||||
let inBlockComment = false;
|
||||
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const char = input[i];
|
||||
const next = input[i + 1];
|
||||
|
||||
if (inLineComment) {
|
||||
if (char === "\n" || char === "\r") {
|
||||
inLineComment = false;
|
||||
output += char;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inBlockComment) {
|
||||
if (char === "*" && next === "/") {
|
||||
inBlockComment = false;
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inString) {
|
||||
output += char;
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
} else if (char === "\\") {
|
||||
escaped = true;
|
||||
} else if (char === '"') {
|
||||
inString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"') {
|
||||
inString = true;
|
||||
output += char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "/" && next === "/") {
|
||||
inLineComment = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "/" && next === "*") {
|
||||
inBlockComment = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
output += char;
|
||||
}
|
||||
|
||||
return output.replace(/,\s*([}\]])/g, "$1");
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
const authRequestKeyPattern = /^hskey-authreq-[A-Za-z0-9_-]{16,}$/;
|
||||
const authRequestKeySearchPattern = /hskey-authreq-[A-Za-z0-9_-]{16,}/;
|
||||
const legacyMachineKeyPattern = /^mkey:[A-Za-z0-9_-]{16,}$/;
|
||||
const registerUrlPattern = /\/register\/([^\s?#]+)/;
|
||||
const registrationKeySuffixPattern = /^[A-Za-z0-9_-]{24,}$/;
|
||||
|
||||
export function normalizeRegistrationKey(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const registerUrlMatch = trimmed.match(registerUrlPattern);
|
||||
if (registerUrlMatch) {
|
||||
const token = normalizeRegistrationToken(registerUrlMatch[1]);
|
||||
if (token) {
|
||||
return token;
|
||||
}
|
||||
}
|
||||
|
||||
const authRequestKeyMatch = trimmed.match(authRequestKeySearchPattern);
|
||||
if (authRequestKeyMatch) {
|
||||
return authRequestKeyMatch[0];
|
||||
}
|
||||
|
||||
return normalizeRegistrationToken(trimmed);
|
||||
}
|
||||
|
||||
function normalizeRegistrationToken(value: string): string | null {
|
||||
let token: string;
|
||||
try {
|
||||
token = decodeURIComponent(value.trim());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (authRequestKeyPattern.test(token) || legacyMachineKeyPattern.test(token)) {
|
||||
return token;
|
||||
}
|
||||
|
||||
if (registrationKeySuffixPattern.test(token)) {
|
||||
return `hskey-authreq-${token}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -138,8 +138,8 @@ build_wasm() {
|
||||
"$(dirname "$WASM_OUTPUT")/wasm_exec.js"
|
||||
|
||||
# Vendor dependencies and apply the DERP port patch.
|
||||
# Tailscale's browser WebSocket and netcheck URL builders ignore
|
||||
# DERPPort, which breaks WASM connections to non-443 DERP servers.
|
||||
# Tailscale's derphttp WebSocket URL builder ignores DERPPort,
|
||||
# which breaks WASM connections to non-443 DERP servers.
|
||||
echo "==> Vendoring Go dependencies for WASM patch"
|
||||
go mod vendor
|
||||
|
||||
|
||||
@@ -30,17 +30,6 @@ func main() {
|
||||
}
|
||||
|
||||
log.SetDebug(cfg.Debug)
|
||||
|
||||
if cfg.TSAuthKey == "" {
|
||||
log.Info("No TS_AUTHKEY provided; tsnet will use existing state or prompt for interactive login")
|
||||
} else {
|
||||
prefix := cfg.TSAuthKey
|
||||
if len(prefix) > 16 {
|
||||
prefix = prefix[:16]
|
||||
}
|
||||
log.Info("TS_AUTHKEY provided (prefix: %s); connecting with pre-auth key", prefix)
|
||||
}
|
||||
|
||||
agent := tsnet.NewAgent(cfg)
|
||||
defer agent.Shutdown()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user