Compare commits

...

56 Commits

Author SHA1 Message Date
Aarnav Tale 87a9219fd3 feat: add some basic mutations 2026-05-16 21:30:16 -04:00
Aarnav Tale ad7e58570f feat: add fate vite integration 2026-05-16 20:37:57 -04:00
Aarnav Tale 04ff2138d2 feat: initial fate based SPA 2026-05-16 20:15:04 -04:00
Aarnav Tale fb4b0b1404 chore: v0.7.0-beta.3 2026-05-14 13:50:54 -04:00
Aarnav Tale 1e0ff7ead6 fix: encode headscale rename path segments
(cherry picked from commit 623e7c03f1)
2026-05-14 13:47:04 -04:00
github-actions[bot] c6b6cbc122 chore: update nix pnpm deps hash 2026-04-27 03:56:34 +00:00
Aarnav Tale deb284e2b4 feat: ditch hono 2026-04-26 23:52:49 -04:00
Aarnav Tale 5a2098eea5 chore: format everything with oxfmt 2026-04-26 20:38:45 -04:00
Aarnav Tale b961b339bb feat: add support for OIDC logouts
Closes HP-407.
2026-04-26 20:36:52 -04:00
Aarnav Tale ac6f9e4f7e feat: support toggling light or dark color schemes
Fixes HP-375.
2026-04-26 20:33:48 -04:00
github-actions[bot] 3026b33834 chore: update flake.lock (#533)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-26 16:10:33 -04:00
Aarnav Tale ecd284b5d8 Merge pull request #537 from croatialu/feat/oidc-weak-rsa-fallback 2026-04-26 16:10:05 -04:00
Aarnav Tale 4cf4e5c040 fix: use thin scrollbars that actually work
Closes HP-536.
2026-04-26 10:15:44 -04:00
croatialu 9e5e5a613a fix: harden OIDC weak RSA fallback 2026-04-22 00:07:47 +08:00
Aarnav Tale 4d252833ef fix(ui): correctly handle mobile breakpoints for the navbar
Fixes HP-529.
2026-04-20 21:46:57 -04:00
Aarnav Tale 9238f69bfc Merge pull request #524 from tale/update_flake_lock_action 2026-04-17 16:59:14 -04:00
croatialu d110dd2bcb feat: add OIDC subject claim fallbacks 2026-04-16 18:07:57 +08:00
croatialu addef55f30 Add weak RSA OIDC verification fallback 2026-04-16 18:04:48 +08:00
github-actions[bot] 67c6c0b453 flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/8d8c1fa' (2026-04-02)
  → 'github:nixos/nixpkgs/1304392' (2026-04-11)
2026-04-12 08:51:58 +00:00
Aarnav Tale 418c3bc255 docs: use cf_account_id 2026-04-11 14:56:46 -04:00
Aarnav Tale 946921fff7 docs: fix changelog 2026-04-11 14:39:49 -04:00
Aarnav Tale e4030ed254 docs: deploy stable and beta docs 2026-04-11 14:32:21 -04:00
Aarnav Tale fccd2eefc4 feat: pull local endpoints/addresses from host info 2026-04-11 12:25:17 -04:00
Aarnav Tale 93be180479 Merge pull request #527 from eccgecko/fix/zero-time-expiry-display 2026-04-10 22:04:26 -04:00
github-actions[bot] 6582f8ae07 chore: update nix pnpm deps hash 2026-04-11 01:02:59 +00:00
Aarnav Tale f0f02b3c4c fix: remove unnecessary remix-utils dependency 2026-04-10 21:01:07 -04:00
Aarnav Tale c030d1fbe4 feat: de-uglify the ACL editor 2026-04-10 21:00:52 -04:00
github-actions[bot] 724e454466 chore: update nix pnpm deps hash 2026-04-11 00:39:54 +00:00
Aarnav Tale dc80c93184 chore: update deps 2026-04-10 20:38:26 -04:00
eccgecko c164e07336 fix: show 'Never' for Go zero-time expiry on detail page
The 'Key expiry' attribute only checked node.expiry !== null, showing a
garbled date for the '0001-01-01T00:00:00Z' zero-time that headscale
may return for tagged nodes (juanfont/headscale#3170).
2026-04-11 00:08:28 +01:00
eccgecko d26c23313c fix: show "No expiry" badge for Go zero-time expiry
uiTagsForNode() only checked node.expiry === null, missing Go
zero-time that headscale returns for tagged nodes after a
restart (juanfont/headscale#3170).

The !node.expired guard is technically redundant with isNoExpiry but
documents intent: "No expiry" is only shown for nodes that never
had an expiry set
2026-04-11 00:07:32 +01:00
eccgecko d5f76637f5 refactor: extract isNoExpiry utility for zero-time handling
Go's time.Time{} serialises to '0001-01-01T00:00:00Z' which headscale
may return instead of null for tagged nodes (juanfont/headscale#3170).

Commit extracts the the existing inline check into a shared function for reuse.

No behavior change — mapNodes() already handled both variants.
2026-04-11 00:00:37 +01:00
Aarnav Tale 272233ae62 fix: add patch command to docker build 2026-04-09 22:42:51 -04:00
Aarnav Tale 974c3b0e48 chore: v0.7.0-beta.2 2026-04-09 22:08:47 -04:00
github-actions[bot] acd3ff3403 chore: update nix pnpm deps hash 2026-04-10 02:06:23 +00:00
Aarnav Tale 1961608e12 docs: get rid of deleted png 2026-04-09 22:01:34 -04:00
Aarnav Tale c4ac28f90b fix: correct patch permissions for nix 2026-04-09 21:58:39 -04:00
Aarnav Tale a0b2077c7b chore: remove faker-js dependency 2026-04-09 21:57:46 -04:00
Aarnav Tale 0f19fdf0da feat: rebuild browser ssh from the ground up 2026-04-09 21:56:42 -04:00
Aarnav Tale d255098128 fix: make the ssh button look nicer 2026-04-07 00:36:04 -04:00
Aarnav Tale 98e0806e5a fix: handle the tag owner username 2026-04-07 00:32:59 -04:00
Aarnav Tale 44dffeaff0 fix: patch tailscale to support a custom DERP port 2026-04-07 00:24:50 -04:00
Aarnav Tale 33f7bbb0cf fix: handle empty ACLs 2026-04-07 00:24:50 -04:00
Aarnav Tale 10278d0cc9 fix: correctly expire pre-auth-keys in 0.28.0+ 2026-04-07 00:24:50 -04:00
Aarnav Tale bdcd4c5bad Merge pull request #518 from Kroppeb/fix/wrong-url-in-help-message 2026-04-06 21:42:48 -04:00
Aarnav Tale 43cff2f4b7 feat: i guess we're undoing agent work 2026-04-06 21:19:27 -04:00
Aarnav Tale 61e7303363 fix: handle URI components in provider ID 2026-04-06 21:19:27 -04:00
Aarnav Tale dc44cc4155 Merge pull request #521 from sinanmohd/chore/deprecated-package-attribute 2026-04-06 20:37:17 -04:00
Aarnav Tale 30ce5e2727 Merge pull request #514 from tale/update_flake_lock_action 2026-04-06 20:34:29 -04:00
sinanmohd 55a09476ab chore: deprecated package attributes 2026-04-05 17:50:37 +05:30
github-actions[bot] ac90b4e8bb flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/9cf7092' (2026-03-18)
  → 'github:nixos/nixpkgs/8d8c1fa' (2026-04-02)
2026-04-05 08:45:30 +00:00
Aarnav Tale 4b47b1bbed chore: update auth-service to not be class based 2026-04-03 16:57:39 -04:00
Aarnav Tale 003985d192 docs: document the modular architecture for services 2026-04-03 16:36:52 -04:00
Aarnav Tale 1259642f8a feat: replace openid-client with clean-room oidc system 2026-04-03 16:36:27 -04:00
Aarnav Tale 4cd0c1e206 fix: use headscale.api_key where possible 2026-04-03 16:34:50 -04:00
Robbe Pincket b2dfa773b0 fix: change oidc config error notice url 2026-04-01 22:17:11 +02:00
180 changed files with 11785 additions and 5964 deletions
+84
View File
@@ -0,0 +1,84 @@
name: Docs
on:
push:
tags:
- "v*"
branches:
- "main"
- "release/docs"
concurrency:
group: docs-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
beta:
name: Deploy Beta Docs
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build docs
run: pnpm docs:build
env:
HEADPLANE_BETA_DOCS: "true"
- name: Deploy to Cloudflare Pages
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
command: pages deploy docs/.vitepress/dist --project-name=headplane-docs-beta
stable:
name: Deploy Stable Docs
if: >
(startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-'))
|| github.ref == 'refs/heads/release/docs'
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build docs
run: pnpm docs:build
- name: Deploy to Cloudflare Pages
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
command: pages deploy docs/.vitepress/dist --project-name=headplane-docs
+3
View File
@@ -1,6 +1,8 @@
node_modules
/.fate
/.react-router
/.cache
/.data
/build
/test
.env
@@ -13,3 +15,4 @@ node_modules
/docs/.vitepress/dist/
/docs/.vitepress/cache/
/.direnv
/vendor
+1
View File
@@ -1 +1,2 @@
side-effects-cache = false
public-hoist-pattern[]=vue
+38 -23
View File
@@ -1,31 +1,46 @@
# 0.7.0-beta.1 (March 27, 2026)
# 0.7.0-beta.3 (May 14, 2026)
> This is a beta release. Please report any issues you encounter.
- **Migrated all UI components from react-aria/react-stately to @base-ui-components/react.**
- Removed `react-aria`, `react-stately`, and `tailwindcss-react-aria-components` as dependencies.
- **Rearchitected the Headplane Agent** from a long-running stdin/stdout daemon to a one-shot sync model (closes [#350](https://github.com/tale/headplane/issues/350), closes [#455](https://github.com/tale/headplane/issues/455)).
- The Go binary connects to the Tailnet, fetches all peer hostinfo as JSON, and exits.
- The Node.js manager auto-generates ephemeral tag-only pre-auth keys (requires Headscale 0.28+).
- Deprecated `integration.agent.pre_authkey` and `integration.agent.cache_path` config fields.
- Added `integration.agent.executable_path` config field.
- **Consolidated the Headscale API key** under `headscale.api_key` (and `headscale.api_key_path`).
- Deprecated `oidc.headscale_api_key` — it is still read as a fallback but will be removed in a future release.
- Both the agent and OIDC now use the same key from `headscale.api_key`.
- **Reworked the authentication system** with a new `AuthService` that consolidates session management and role enforcement (via [#489](https://github.com/tale/headplane/pull/489)).
- Added an agent status page at `/settings/agent` showing sync status, node count, errors, and a "Sync Now" button.
- Added additional machine list filters for user, tag, status, and route (via [#507](https://github.com/tale/headplane/pull/507), closes [#506](https://github.com/tale/headplane/issues/506)).
- Fixed GHSA-vgj6-hcf2-fqf6, a path traversal / RBAC bypass in Headscale node and user rename API calls.
---
# 0.6.3 (May 14, 2026)
- Fixed GHSA-vgj6-hcf2-fqf6, a path traversal / RBAC bypass in Headscale node and user rename API calls.
---
# 0.7.0-beta.2 (April 9, 2026)
> This is a beta release. Please report any issues you encounter.
- **Rebuilt the user model to enable "account linking" between Headplane and Headscale.** OIDC users are automatically linked to their Headscale counterparts based on subject and email. Users who cannot be automatically linked can claim an unlinked Headscale user during onboarding. See the [SSO docs](/features/sso) for details (via [#489](https://github.com/tale/headplane/pull/489)).
- **Rebuilt Browser SSH** with a new terminal powered by [Ghostty WASM](https://restty.dev), improved session handling, and support for custom DERP ports. See the [Browser SSH docs](/features/ssh) for details (closes [#515](https://github.com/tale/headplane/issues/515), closes [#386](https://github.com/tale/headplane/issues/386)).
- **Rearchitected the Headplane Agent** with a periodic sync model and extensive caching. The agent now auto-generates ephemeral pre-auth keys (requires Headscale 0.28+). See the [Agent docs](/features/agent) for details (closes [#350](https://github.com/tale/headplane/issues/350), closes [#455](https://github.com/tale/headplane/issues/455)).
- **Replaced `openid-client` with a new OIDC implementation.** Fixes `client_secret_basic` not working with Google SSO and other providers (closes [#493](https://github.com/tale/headplane/issues/493), closes [#516](https://github.com/tale/headplane/issues/516)).
- **Migrated all UI components from react-aria to [Base UI](https://base-ui.com).**
- **Consolidated the Headscale API key** under `headscale.api_key` (and `headscale.api_key_path`). Deprecated `oidc.headscale_api_key` — it is still read as a fallback but will be removed in a future release.
- Added machine list filters for user, tag, status, and route (via [#507](https://github.com/tale/headplane/pull/507), closes [#506](https://github.com/tale/headplane/issues/506)).
- Added self-service pre-auth key creation for auditor role users (via [#478](https://github.com/tale/headplane/pull/478), closes [#453](https://github.com/tale/headplane/issues/453)).
- Fetch OIDC profile pictures server-side when the URL requires authentication (via [#510](https://github.com/tale/headplane/pull/510), closes [#326](https://github.com/tale/headplane/issues/326)).
- Fixed first user not being assigned the owner role on OIDC login (via [#480](https://github.com/tale/headplane/pull/480), closes [#266](https://github.com/tale/headplane/issues/266)).
- Fixed login errors throwing an unexpected server error instead of showing form validation (via [#475](https://github.com/tale/headplane/pull/475), closes [#474](https://github.com/tale/headplane/issues/474)).
- Fixed agent HostInfo not refreshing periodically using `cache_ttl` (via [#477](https://github.com/tale/headplane/pull/477), closes [#427](https://github.com/tale/headplane/issues/427)).
- Fixed a race condition where the SSE controller could be used after being closed.
- Fixed WebSSH WASM prefix paths for correct asset loading (closes [#386](https://github.com/tale/headplane/issues/386)).
- Fixed Dockerfile WASM copy paths.
- Fixed CodeMirror version mismatch override in the ACL editor.
- Fixed cookie secret generation using incorrect byte length (via [#501](https://github.com/tale/headplane/pull/501)).
- Added an agent status page at `/settings/agent` showing sync status, node count, and errors.
- Added local endpoint and address information to the machine detail page.
- Improved the ACL editor appearance and fixed a CodeMirror version mismatch.
- Store OIDC profile pictures in the database to prevent cookie overflow (via [#510](https://github.com/tale/headplane/pull/510), closes [#326](https://github.com/tale/headplane/issues/326)).
- Detect unsupported Docker API versions early with a clear error message (via [#497](https://github.com/tale/headplane/pull/497)).
- Fixed "No expiry" badge not displaying for nodes with zero-time expiry values (via [#527](https://github.com/tale/headplane/pull/527), closes [#526](https://github.com/tale/headplane/issues/526)).
- Fixed first user not being assigned the owner role on OIDC login (via [#480](https://github.com/tale/headplane/pull/480), closes [#266](https://github.com/tale/headplane/issues/266)).
- Fixed login errors throwing a server error instead of showing form validation (via [#475](https://github.com/tale/headplane/pull/475), closes [#474](https://github.com/tale/headplane/issues/474)).
- Fixed pre-auth key expiration on Headscale 0.28+ (closes [#519](https://github.com/tale/headplane/issues/519)).
- Fixed OIDC subject matching for providers with special characters in user IDs, e.g. Auth0 (closes [#428](https://github.com/tale/headplane/issues/428)).
- Fixed `headscale.api_key` not being used consistently across all code paths.
- Fixed agent HostInfo not refreshing periodically using `cache_ttl` (via [#477](https://github.com/tale/headplane/pull/477), closes [#427](https://github.com/tale/headplane/issues/427)).
- Fixed agent working directory being wiped on restart.
- Fixed a race condition where the SSE controller could be used after being closed.
- Fixed cookie secret generation using incorrect byte length (via [#501](https://github.com/tale/headplane/pull/501)).
- Fixed OIDC configuration error troubleshooting link (via [#518](https://github.com/tale/headplane/pull/518), closes [#517](https://github.com/tale/headplane/issues/517)).
- Fixed deprecated Nix package attributes (via [#521](https://github.com/tale/headplane/pull/521)).
- Updated NixOS module options: removed deprecated agent fields, added `headscale.api_key_path` and `integration.agent.executable_path`.
---
+616
View File
@@ -0,0 +1,616 @@
# Headplane codebase findings
Date: 2026-05-16
Scope: server-side TypeScript, React Router route modules, client components, live-data/data-fetching paths, auth/session/RBAC, Headscale config/API integration, Go agent/WebSSH code, runtime/build/CI, and docs. This is an architecture and pattern review, not a completed security audit.
## Executive summary
Headplane's biggest codebase risks are not isolated style issues; they cluster around ownership boundaries:
- **Auth/session boundary:** API-key sessions put the raw Headscale API key in the auth cookie, and self-service account linking can claim arbitrary unclaimed Headscale users.
- **Live-data boundary:** the current live path polls globally, caches globally, and tells React Router to re-run whole active route loaders on any resource change.
- **Lifecycle boundary:** process-lifetime services have `setInterval`, child processes, SSE streams, Undici agents, and WASM/tsnet state, but there is no coordinated shutdown/disposal model.
- **Mutation boundary:** config-file edits and several admin/user mutations rely on UI constraints, mutable process state, non-transactional writes, or inconsistent server-side validation.
- **Framework/tooling boundary:** React Router loaders/actions currently carry a lot of app state orchestration. Fate could be a good fit for live `nodes`/`users` data, but it will not fix auth, authorization, config mutation, WebSSH, or agent lifecycle issues by itself.
## Highest-priority findings
### 1. OIDC self-linking can claim arbitrary unclaimed Headscale users
**Severity:** Critical
**Area:** AuthZ / account linking
**Evidence:**
- `app/routes/home.tsx:84-97` accepts a posted `headscale_user_id` and calls `context.auth.linkHeadscaleUser(principal.user.id, headscaleUserId)`.
- `app/server/web/auth.ts:225-237` grants machine ownership based on `principal.user.headscaleUserId === node.user?.id`.
- `app/routes/machines/machine-actions.ts:57-68` authorizes machine mutations through `canManageNode`.
**Why it matters:** a logged-in OIDC user can forge the form POST and link themselves to any unclaimed Headscale user ID. Once linked, they can manage that user's machines through the machine action authorization path.
**Suggested direction:** recompute allowed self-link targets server-side inside the action and reject anything not in that set. Prefer auto-linking only by verified OIDC subject/email match, and reserve arbitrary linking for admins.
### 2. API-key sessions put the raw Headscale API key in a signed cookie
**Severity:** Critical
**Area:** Auth/session security
**Evidence:**
- `app/server/web/auth.ts:96-105` manually base64url-encodes JSON and signs it with HMAC.
- `app/server/web/auth.ts:269-283` serializes `{ sid, api_key }` for API-key sessions.
- `app/server/web/auth.ts:144-173` resolves API-key principals from `payload.api_key`; the DB `api_key_hash` is not used to retrieve a server-side secret.
- `app/utils/oidc-state.ts:13-19` explicitly sets `httpOnly`, while the main auth cookie options are not explicit in `app/server/context.ts:49-54` / `app/server/web/auth.ts:96-105`.
- `docs/install/index.md` describes `server.cookie_secret` as encrypting cookies, but the implementation signs rather than encrypts.
**Why it matters:** cookie compromise becomes direct Headscale API compromise, not just Headplane session compromise. HMAC protects integrity, not confidentiality. The auth cookie should be an opaque session handle, not a transport for upstream credentials.
**Suggested direction:** store only `sid` in the cookie; keep the API key encrypted or server-side in the DB/secret store; verify `api_key_hash` if retaining API-key sessions; explicitly set `httpOnly`, `sameSite`, `secure`, `path`, and consider key rotation/versioning. Use timing-safe HMAC comparison if manual signatures remain.
## Live data and data-fetching findings
### 3. SSE events trigger whole-route revalidation instead of resource-scoped updates
**Severity:** High
**Area:** Client data fetching / live data
**Evidence:**
- `app/utils/live-data.tsx:35-39` calls `revalidator.revalidate()`.
- `app/utils/live-data.tsx:63-82` treats every `changed` event as a global route revalidation.
- `app/routes/util/live.ts:34-37` sends only `{ resource, version }`, not field/object-level deltas or subscribed views.
**Why it matters:** any node/user change can re-run every active loader for the current route tree. Pages such as machines/users load multiple resources and agent metadata, so frequent Headscale changes can become over-fetching and UI churn.
**Suggested direction:** short term, include enough resource metadata to revalidate only affected routes or debounce/coalesce revalidation. Long term, move live data to normalized object/list subscriptions instead of request/route invalidation.
### 4. Live-data pause state is global, boolean, and can stick disabled
**Severity:** High
**Area:** Client state / live data
**Evidence:**
- `app/root.tsx:41-66` mounts `LiveDataProvider` above all routes.
- `app/routes/auth/login/page.tsx:59-66` calls `pause()` with no dependency array and no cleanup/resume.
- `app/components/dialog.tsx:21-27` uses the same single boolean pause model for dialogs.
- `app/utils/live-data.tsx:136-141` exposes imperative `pause()` / `resume()` with no ownership token.
**Why it matters:** visiting `/login` can leave live updates disabled after navigating into the app. Multiple pause consumers can also fight: closing one dialog can resume live updates while another still expects them paused.
**Suggested direction:** add immediate cleanup in login (`pause(); return resume`) and a dependency array. Replace the boolean with a refcount/token model so each consumer releases only its own pause.
### 5. `hsLive` is process-global but stores one mutable API client
**Severity:** High
**Area:** Server live data / cache scoping
**Evidence:**
- `app/server/context.ts:86-101` creates one `hsLive` for the entire process.
- `app/server/headscale/live-store.ts:69` keeps `storedApiClient` in shared closure state.
- `app/server/headscale/live-store.ts:113-120` polling uses that shared client.
- `app/server/headscale/live-store.ts:143` and `app/server/headscale/live-store.ts:158` overwrite it from each caller.
**Why it matters:** the last request/action to touch `hsLive` controls which API key future background polls use. A bad/expired user-supplied API-key session can poison live refresh for everyone until another request resets it. Snapshots are also not principal/session scoped.
**Suggested direction:** poll with one stable server credential only, or scope cache/polling by principal/session. If adopting Fate, make this one of the first seams to replace.
### 6. Live store polling is coarse and lifecycle-unmanaged
**Severity:** Medium
**Area:** Server lifecycle / live data
**Evidence:**
- `app/server/headscale/live-store.ts:38-45` polls nodes every 5s and users every 15s.
- `app/server/headscale/live-store.ts:82-104` compares `JSON.stringify(data)` for the whole resource.
- `app/server/headscale/live-store.ts:113-123` starts intervals lazily but never stops them unless `dispose()` is called.
- `app/server/context.ts:94` creates the store, but no app shutdown path calls `hsLive.dispose()`.
**Why it matters:** whole-resource JSON comparison is order-sensitive and grows with tailnet size. The polling intervals continue for process lifetime and can leak during dev HMR or repeated context creation.
**Suggested direction:** introduce app-level lifecycle management and subscription-aware polling, or replace with event/object-scoped live subscriptions.
### 7. Loader data is mirrored into local state and can overwrite edits during revalidation
**Severity:** Medium
**Area:** Client state / React patterns
**Evidence:**
- `app/routes/acls/overview.tsx:30-41` mirrors `policy` into `codePolicy` and updates it when loader data changes.
- `app/routes/dns/components/manage-domains.tsx:25-31` mirrors `searchDomains` into local state.
**Why it matters:** global SSE revalidation can replace local edit state. The ACL editor is especially risky because an unsaved policy can be overwritten by background loader refresh.
**Suggested direction:** separate “initial server value” from “dirty draft” state; never overwrite dirty drafts on background revalidation without prompting.
### 8. Fetcher/dialog orchestration is imperative and duplicated
**Severity:** Medium
**Area:** Client forms / mutations
**Evidence:**
- `app/routes/machines/dialogs/tags.tsx:34-43` uses `submittingRef` + effects to close/unlock.
- `app/routes/settings/auth-keys/dialogs/add-auth-key.tsx:56-71` uses similar orchestration and directly assigns `fetcher.data = undefined`.
- `app/routes/machines/dialogs/routes.tsx:49-63` submits on switch changes without optimistic/error state.
**Why it matters:** each dialog reimplements mutation lifecycle handling. Direct mutation of `fetcher.data` fights router-owned state and can hide stale or failed submissions.
**Suggested direction:** centralize fetcher mutation state handling, or move to an action/mutation primitive with explicit pending/error/success states and optimistic updates.
## Auth, authorization, and route handling findings
### 9. Agent settings route/action lacks capability checks
**Severity:** High
**Area:** Authorization
**Evidence:**
- `app/routes/settings/agent.tsx:13-27` only authenticates.
- `app/routes/settings/agent.tsx:29-39` only authenticates before triggering sync.
**Why it matters:** any authenticated user can view agent status and trigger sync work if they know the URL, regardless of settings/admin capability.
**Suggested direction:** gate read and sync separately, probably using `read_feature`/`write_feature` or a dedicated agent capability.
### 10. App layout catches all loader errors and destroys sessions
**Severity:** High
**Area:** Error handling / auth UX
**Evidence:**
- `app/layout/app.tsx:32-103` wraps the whole loader in `try/catch` and redirects to `/login` with `destroySession()` for any error.
- `app/layout/app.tsx:50-67` handles one expected expired-API-key case, but the outer catch covers unrelated failures too.
**Why it matters:** network errors, programming errors, and transient Headscale failures can become silent logouts. It also hides diagnostics from route error boundaries.
**Suggested direction:** catch only expected auth/session errors; let operational/programming errors hit the error boundary or a health banner.
### 11. RBAC is inconsistent across routes/actions
**Severity:** Medium/High
**Area:** Authorization
**Evidence:**
- `app/routes/machines/machine-actions.ts:64-68` performs resource-specific checks via `canManageNode`.
- `app/routes/users/user-actions.ts:10-17` checks only broad `write_users` before operations.
- `app/routes/home.tsx:84-97` lets OIDC users link Headscale identities without revalidating target ownership.
- `app/routes/settings/auth-keys/actions.ts:23-35` has more careful self-service ownership checks, showing the pattern exists but is not universal.
**Why it matters:** permissions are enforced route-by-route with no single policy layer, so new actions can accidentally rely on UI hiding. The self-linking issue is one concrete outcome.
**Suggested direction:** create server-side action guards for common ownership/role decisions and require every mutation to call one.
### 12. Runtime role input is not validated and ownership updates are non-transactional
**Severity:** Medium
**Area:** Auth/RBAC data integrity
**Evidence:**
- `app/routes/users/user-actions.ts:82-104` casts `newRole as Role` from form data.
- `app/server/web/auth.ts:467-484` upserts that role into the DB.
- `app/server/web/auth.ts:430-464` transfers ownership with two separate updates.
- `app/server/web/auth.ts:324-342` makes the first user owner with a count-after-insert flow.
**Why it matters:** forged role values can reach storage, and failures/races in ownership transfer or first-login owner selection can produce invalid owner state.
**Suggested direction:** validate `newRole` against `Roles` at the route boundary, use transactions for ownership transfer, and make first-owner assignment atomic.
### 13. Login page has live-data and URL cleanup bugs
**Severity:** Medium
**Area:** Client route behavior
**Evidence:**
- `app/routes/auth/login/page.tsx:59-66` pauses live data on every render and never resumes.
- `app/routes/auth/login/page.tsx:68-84` uses `window.history.replaceState` manually.
- `app/routes/auth/login/page.tsx:78-80` builds `newUrl` with `` `{${window.location.pathname}?...` ``, leaving a literal `{` in the URL.
**Why it matters:** this can disable live updates and corrupt/uglify login URLs. Manual history mutation bypasses router state.
**Suggested direction:** use a loader redirect or router navigation where possible; otherwise fix the string and make the effect one-shot with cleanup.
## Headscale API and config mutation findings
### 14. Headscale API errors are collapsed into `502 Bad Gateway`
**Severity:** Medium/High
**Area:** API wrapper / error handling
**Evidence:**
- `app/server/headscale/api/index.ts:139-145` converts network errors to React Router `data(..., 502)`.
- `app/server/headscale/api/index.ts:183-203` converts every Headscale `>=400` response to outer status 502 while preserving the real status only inside the body.
- Many callers inspect raw strings/statuses manually, e.g. `app/routes/auth/login/action.ts:78-93` and `app/routes/acls/acl-action.ts:39-107`.
**Why it matters:** UI and route logic must understand wrapper internals. HTTP semantics are obscured, and handling becomes string-fragile.
**Suggested direction:** preserve upstream status classes where safe, expose typed domain errors, and make expected Headscale quirks explicit in one adapter layer.
### 15. OpenAPI polling interval has no disposal and returned interface exposes stale values
**Severity:** Medium
**Area:** Server lifecycle / API versioning
**Evidence:**
- `app/server/headscale/api/index.ts:241-250` starts a `setInterval` inside `createHeadscaleInterface`.
- `app/server/headscale/api/index.ts:252-272` returns `openapiHashes` and `apiVersion` as values captured at return time, while `clientHelpers.isAtleast` reads the mutable closure.
- No `dispose()` exists on the interface.
**Why it matters:** lifecycle leaks in dev/reload scenarios, and consumers reading `context.hsApi.apiVersion` can see stale data while helpers see updated data.
**Suggested direction:** add lifecycle, expose getters for mutable version state, or only detect once at startup.
### 16. Config patching is not safely serialized
**Severity:** Medium/High
**Area:** Config mutation / concurrency
**Evidence:**
- `app/server/headscale/config-loader.ts:63-127` mutates the YAML document before acquiring `writeLock`.
- `app/server/headscale/config-loader.ts:120-127` sets `writeLock = true` without `try/finally`; a failed write can leave the lock stuck.
- `app/server/headscale/config-dns.ts` has a similar write-lock pattern (see `writeLock` usage).
**Why it matters:** concurrent admin actions can interleave mutations, lose updates, or deadlock future writes after an exception.
**Suggested direction:** replace the boolean lock/spin loop with a promise queue or mutex; acquire before document mutation; release in `finally`; write atomically via temp file + rename.
### 17. DNS/config actions mutate shared config arrays and lack input validation
**Severity:** Medium
**Area:** Config mutation / validation
**Evidence:**
- `app/routes/dns/dns-actions.ts:103-118` pushes nameservers into arrays read from `context.hs.c`.
- `app/routes/dns/dns-actions.ts:147-164` pushes search domains into arrays read from `context.hs.c`.
- `app/routes/dns/dns-actions.ts:189-209` writes DNS record type/value from form data with minimal validation.
**Why it matters:** in-memory config can be mutated before persistence succeeds, and invalid values can be written directly to Headscale config.
**Suggested direction:** clone before modification, validate domain/IP/record types server-side, and return actionable typed errors.
### 18. Restrictions config actions fire integration restarts without awaiting them
**Severity:** Medium
**Area:** Config mutation / operational consistency
**Evidence:**
- `app/routes/settings/restrictions/actions.ts:51`, `:79`, `:100`, `:129`, `:150`, `:179` call `context.integration?.onConfigChange(api)` without `await`.
- `app/routes/dns/dns-actions.ts` generally awaits the same hook.
**Why it matters:** the response can report success while restart/reload fails in the background, and unhandled rejections can be lost.
**Suggested direction:** await the hook consistently or queue/retry restarts through an explicit background job with surfaced status.
## Server lifecycle and runtime findings
### 19. Process-lifetime services have no composition-root `stop()`
**Severity:** High
**Area:** Server lifecycle
**Evidence:**
- `app/server/context.ts:21-101` constructs DB, Headscale API, live store, auth service, OIDC, optional agent manager, and integration.
- `app/server/app.ts:38-40` starts auth pruning.
- `app/server/hp-agent.ts:319-359` starts an interval and child process with `dispose()`.
- `app/server/headscale/live-store.ts:182-191` has `dispose()`.
- `runtime/http.ts:192-200` handles SIGINT/SIGTERM by closing the HTTP server and exiting, but has no app context cleanup.
- `runtime/vite-plugin.ts:44-50` uses `ssrLoadModule` in dev; HMR can recreate modules/services without calling old disposers.
**Why it matters:** intervals, child processes, SSE listeners, and Undici agents can leak in dev and are force-killed in production instead of being drained.
**Suggested direction:** return an app runtime with `stop()` from the composition root; call `auth.stop()`, `hsLive.dispose()`, `agents.dispose()`, `hsApi.undiciAgent.close()`, and integration cleanup from production shutdown and Vite HMR dispose hooks.
### 20. Architecture docs describe patterns the code no longer fully follows
**Severity:** Medium
**Area:** Documentation / maintainability
**Evidence:**
- `docs/development/architecture.md:15-20` says all services are closure factories, no classes/globals.
- `app/server/headscale/config-loader.ts:21-213` uses a mutable class for Headscale config.
- `docs/development/architecture.md:162-218` describes `server/index.ts`, `AppRuntime`, and `context.runtime`, while current code uses `app/server/app.ts`, `createAppContext`, and direct `context.<service>`.
**Why it matters:** docs are important to this project. Stale architecture guidance makes future changes less consistent and harder for agents/contributors to follow.
**Suggested direction:** update the architecture docs to the current composition root and explicitly document exceptions such as config-file wrappers.
## Go agent and WebSSH findings
### 21. WebSSH WASM lacks a top-level disposal model and leaks JS functions
**Severity:** High
**Area:** WebSSH lifecycle / browser resources
**Evidence:**
- `app/routes/ssh/page.tsx:156-188` creates the WASM/IPN instance and cleanup only sets `cancelled = true`.
- `app/routes/ssh/wasm.client.ts:27-29` exposes `openTunnel` only; no `dispose()`.
- `cmd/hp_ssh/hp_ssh.go:16-80` creates JS functions but never releases them with `js.Func.Release()`.
- `internal/hp_ipn/ipnserver.go:107-133` starts backend/server work but exposes no shutdown API.
**Why it matters:** repeated SSH sessions can leave in-browser tsnet/backend resources and JS function handles alive until tab refresh.
**Suggested direction:** add top-level `dispose()` to the JS API, release Go `js.Func` handles, cancel contexts, close sessions/backend/server, and call dispose from React cleanup.
### 22. WebSSH disables SSH host key verification
**Severity:** Medium/High
**Area:** Security / WebSSH
**Evidence:**
- `internal/hp_ipn/ssh.go:59-63` returns `nil` from `HostKeyCallback`.
**Why it matters:** this accepts any host key and allows MITM within the network path. Tailscale identity reduces exposure, but SSH host identity is still bypassed.
**Suggested direction:** document the tradeoff clearly at minimum. Prefer known_hosts-style pinning, Tailscale SSH identity integration, or an explicit trust-on-first-use flow.
### 23. Go agent can panic on peers without Tailscale IPs
**Severity:** Medium
**Area:** Go agent robustness
**Evidence:**
- `internal/tsnet/peers.go:61` indexes `peer.TailscaleIPs[0]`.
- `internal/tsnet/peers.go:119` also indexes `peer.TailscaleIPs[0]` before checking `len(ip) == 0`.
**Why it matters:** transient or malformed peer state can crash host-info collection.
**Suggested direction:** check `len(peer.TailscaleIPs) > 0` before indexing in both paths.
### 24. Go agent preflight uses `http.Get` without timeout and does not close response bodies
**Severity:** Medium
**Area:** Go agent robustness
**Evidence:**
- `internal/config/preflight.go:40-56` calls `http.Get(testURL)` directly.
- `internal/config/preflight.go:47-54` never closes `resp.Body`.
**Why it matters:** startup can hang indefinitely on network issues, and response bodies leak.
**Suggested direction:** use `http.Client{Timeout: ...}` and `defer resp.Body.Close()`.
### 25. Go library-like code exits the process and logging is hand-rolled
**Severity:** Medium
**Area:** Go maintainability
**Evidence:**
- `internal/tsnet/server.go:22-72` uses `log.Fatal` inside `NewAgent`/`Connect` rather than returning errors.
- `internal/util/logger.go:25-29` defines `encoder` and `pool`, but `internal/util/logger.go:59-66` only writes plain stderr lines and exits on fatal.
**Why it matters:** callers cannot recover or return structured errors, and the logger has unused complexity while still lacking structured output.
**Suggested direction:** return errors from `NewAgent`/`Connect`, let `cmd/hp_agent` decide process exit, and simplify or replace the logger.
## Build, CI, and tooling findings
### 26. CI does not explicitly run typecheck or lint
**Severity:** Medium
**Area:** Tooling / quality gates
**Evidence:**
- `package.json` has `typecheck`, `lint`, and `format` scripts.
- `.github/workflows/build.yaml:32-37` runs `./build.sh --skip-pnpm-prune`, unit tests, and integration tests.
- `build.sh:157-173` runs `pnpm run build`, not `pnpm run typecheck` or `pnpm run lint`.
**Why it matters:** Vite/React Router builds transpile TypeScript but are not a substitute for `tsgo` typechecking. Lint-only issues can land despite local scripts existing.
**Suggested direction:** add `pnpm run typecheck` and `pnpm run lint` to CI. Consider `pnpm run format --check` if supported by oxfmt.
### 27. Build script leaves temporary `vendor/` behind if WASM build fails
**Severity:** Low/Medium
**Area:** Build hygiene
**Evidence:**
- `build.sh:143-154` runs `go mod vendor`, applies a patch, builds, then removes `vendor` only after success.
**Why it matters:** failed local builds can leave a large generated directory in the worktree, which is easy to accidentally inspect or commit around.
**Suggested direction:** add a trap around the vendoring step to remove `vendor` on failure.
## Fate/SPA/Vite+ migration assessment
### Updated direction: SPA first, not Void first
After comparing the shape of Headplane with Void's current platform/runtime direction, the better target is **not** a Void app. Headplane is a self-hosted local control-plane UI that needs a predictable Node process, local filesystem/config access, SQLite, child process/agent management, long-lived SSE, and a packaging story that can eventually compile into a Node static SEA.
The preferred target is now a one-way SPA cutover:
```text
Hono Node server + Vite SPA + TanStack Router + raw Fate
```
The important architectural choice is that **Fate becomes the data framework directly** while the app shell stays thin. The shell should own static assets, cookies/session plumbing, routing, and lifecycle. Fate should own reads, mutations/actions, normalized cache updates, and live object/list subscriptions. Do not build a Headplane-specific abstraction layer over Fate's live bus, view resolution, actions, or native HTTP handlers unless a concrete repeated problem appears after using the raw APIs.
Void may still be a useful reference implementation for Fate integration, but it should not drive Headplane's runtime architecture unless it later proves a first-class self-hosted Node mode that fits Headplane's install model.
### Current Fate fit
Fate is directly aimed at the pain Headplane is showing: declarative views, normalized cache, data masking, Async React, optimistic actions/mutations, and live views over SSE. Fate 1.0 says it now includes production-ready live views, Drizzle support, garbage collection, and native HTTP transport. The docs also still contain an alpha warning in the getting-started page, so I would treat the ecosystem as promising but still worth piloting behind a branch.
Headplane is already close on prerequisites:
- React is `19.2.5` and Fate requires React 19.2+.
- The app already uses Vite 8-era tooling, Vitest, Oxlint, Oxfmt, tsgo, and pnpm.
- Headplane already has Drizzle, but only for Headplane-local data (`users`, sessions, host info), while core tailnet data comes from the remote Headscale API.
### What Fate would improve
- Replace `LiveDataProvider` + `useRevalidator()` with object/list-level subscriptions (`useLiveView` / `useLiveListView`).
- Normalize `nodes`, `users`, pre-auth keys, and agent host info instead of passing large loader payloads around.
- Let components declare data needs near rendering rather than building large page-level loader DTOs.
- Make mutations return selected updated data and update dependent views without manual `context.hsLive.refresh(...)` calls.
### What Fate will not fix
- API-key-in-cookie session design.
- Self-linking authorization.
- Missing route/action capability checks.
- Headscale config-file write races.
- WebSSH/WASM lifecycle and SSH host-key verification.
- Go agent robustness issues.
These should be fixed before or alongside any framework migration.
### Headplane-specific adoption challenges
- **Remote API source:** Fate's Drizzle adapter helps for local DB rows, but `Machine`, `User`, `PreAuthKey`, ACL policy, and DNS/config data mostly come from Headscale REST/config files. A Headscale Fate source/adapter or custom native HTTP query layer would be needed.
- **Authorization:** Fate views must be scoped by the authenticated principal. Do not reproduce the current process-global cache or mutable API client.
- **Live events:** Headscale does not appear to push the exact object-level events Headplane needs, so Headplane may still need polling or mutation-triggered `live.update(...)` calls. The win is to send object/list updates to subscribed views, not to revalidate whole routes.
- **Server framework:** the desired end state is no longer another heavyweight metaframework. Hono is a good fit for the server shell because it uses the Fetch `Request`/`Response` model Fate already targets, while still running as a normal self-hosted Node process.
### Target runtime shape
```text
╭────────────────────────────────────────────╮
│ Node static SEA / Docker image │
│ - bundled server JS │
│ - embedded or adjacent Vite client assets │
╰───────────────────┬────────────────────────╯
╭────────────────────────────────────────────╮
│ Hono Node server │
│ - process lifecycle start/stop │
│ - static asset + SPA fallback serving │
│ - cookie/session middleware │
│ - /fate and /fate/live │
│ - /api, /events during transition │
╰───────────────────┬────────────────────────╯
╭────────────────────────────────────────────╮
│ Framework-neutral Headplane server core │
│ - auth/session/authorization │
│ - Headscale API/config adapters │
│ - agent manager │
│ - Fate live bus used directly │
╰───────────────────┬────────────────────────╯
╭────────────────────────────────────────────╮
│ Vite SPA │
│ - TanStack Router for navigation/search │
│ - raw Fate for all app data and mutations │
│ - no route loaders/actions/fetchers │
╰────────────────────────────────────────────╯
```
### Node SEA packaging implications
The SPA direction is a better fit for a Node static SEA than SSR framework mode because the runtime can become one server entry plus a finite static asset set. The server should be structured so that production can first serve assets from `build/client`, then later swap that out for an embedded asset manifest without changing application routing.
Practical constraints for the SEA target:
- keep one explicit production server entry instead of framework-generated adapter code;
- avoid dynamic runtime imports for route modules in production;
- make client assets addressable by a generated manifest rather than filesystem discovery;
- keep mutable data outside the SEA (`data_path`, Headscale config paths, SQLite, logs);
- preserve direct file serving for large WASM artifacts until we decide whether they should be embedded or adjacent assets.
### Raw Fate rule
Use Fate's public APIs directly:
- `createFateServer(...)` and the native HTTP handler for `/fate`;
- Fate's live bus directly for `live.update(...)`, `live.delete(...)`, and connection/list invalidations;
- Fate's context callback directly for request auth and Headscale API access;
- `FateClient`, `createClient` / `createHTTPTransport` while bootstrapping, then generated `createFateClient(...)` once the Fate Vite plugin has a real server module;
- `useRequest`, `useView`, `useLiveView`, `useLiveListView`, and Fate actions directly in React.
Do not add project-level wrappers such as `HeadplaneLivePublisher`, `HeadplaneDataContext`, or a custom Fate transport abstraction at the beginning. If raw Fate usage becomes repetitive, extract only the smallest local helper at the repetition site.
### Current scaffold on `tale/fate-spa`
- Removed the earlier `HeadplaneRuntime`, `HeadplaneDataContext`, and `HeadplaneLivePublisher` scaffolding.
- Added direct dependencies: `react-fate`, `@nkzw/fate`, `@tanstack/react-router`, `@tanstack/router-plugin`, and `@vitejs/plugin-react`.
- Added a plain Vite SPA `index.html` and `app/spa` entry with TanStack Router and the generated raw Fate client from `react-fate/client`.
- Moved the old React Router Vite config to `vite-old.config.ts` and replaced `vite.config.ts` with a clean SPA config.
- Added Hono and `@hono/node-server`, plus a minimal Hono server shell in `app/server/hono-app.ts`, `app/server/hono-dev.ts`, and `app/server/hono-main.ts`.
- Added `app/server/fate.ts`, which exports a raw Fate server and live bus. Hono mounts Fate's `createHonoFateHandler(fate)` at `/admin/fate` and `/admin/fate/*`, passing the existing app context through Hono variables.
- Wired the official `react-fate/vite` plugin to `app/server/fate.ts`, ignored generated `.fate/` output, and made `pnpm run typecheck` run `fate generate` before `tsgo` so generated `react-fate/client` typings exist from a clean checkout.
- Added the first real Fate read roots: `machines` and `users`. They use Fate `dataView(...)`, `list(...)`, and source executors directly, call the existing principal-scoped Headscale runtime API client, and enforce existing `read_machines` / `read_users` capabilities.
- Added minimal SPA `/machines` and `/users` routes that fetch with raw `useRequest(...)`, render records with `useLiveView(...)`, and subscribe to root list connections with `useLiveListView(...)`. These routes intentionally do not port filters, actions, or optimistic updates yet.
- Bridged existing `hsLive` resource changes directly to Fate connection invalidations: `nodes` invalidates the `machines` root connection and `users` invalidates the `users` root connection. This is a temporary seam so converted routes can exercise Fate live primitives before the old live store is deleted.
- Added the first raw Fate mutation, `machine.rename`. It reuses existing `canManageNode` authorization, calls the Headscale API, refreshes the transitional `hsLive` nodes resource, emits direct Fate entity/list live events, and returns the client-selected `Machine` view.
- `pnpm dev` now runs the Hono/Vite middleware shell with local `.data` storage for the example config; `pnpm build` now runs `vite build` for the SPA.
- Fate's Drizzle peer currently warns against the repo's Drizzle `1.0.0-beta.21`; avoid Fate's Drizzle adapter until that compatibility is resolved, and start with a direct Headscale source/resolver instead.
### Fate context decision
The current Fate request context should stay pragmatic rather than heavily decomposed:
- expose `api`, the principal-scoped Headscale runtime client, as the primary data access path for remote Headscale data;
- keep `principal` and `request` available for authorization and future audit/session needs;
- keep `app` available during the migration so resolvers can reuse the existing auth/config/agent services without inventing a new service layer first;
- do not pass an unstructured context into every helper by default once a data domain settles. If a `machines`, `users`, or `authKeys` module becomes large, give that module explicit functions that accept the concrete pieces it uses.
In other words: full app context is acceptable as migration scaffolding, but the resolver code should prefer the smallest direct dependency (`ctx.api`, `ctx.app.auth`, etc.) and should not become a new `HeadplaneRuntime` abstraction.
### Recommended migration sequence
1. **Remove transitional abstractions** and make the branch clearly one-way toward SPA + raw Fate.
2. **Install the direct dependencies**: `react-fate`, `@nkzw/fate`, `@tanstack/react-router`, `@tanstack/router-plugin`, and the plain Vite React plugin if the React Router plugin is removed.
3. **Replace the build/dev entry shape**:
- add a Vite SPA entry and TanStack route tree;
- stop generating new React Router route types;
- keep the existing Node server entry as the self-hosted process.
4. **Mount raw Fate endpoints** in the current Node request path:
- `/fate` for native RPC;
- `/fate/live` for SSE and subscription control;
- request context resolves auth using the existing auth service and calls `context.hsApi.getRuntimeClient(...)` directly.
5. **Convert the machines page first** using raw Fate views and live list/view hooks.
6. **Add live updates to the converted lists** by publishing Fate list/entity invalidations from the existing polling/mutation seams. Keep this raw Fate live bus usage, not a Headplane live wrapper.
7. **Port one mutation at a time**, starting with a low-risk machine mutation such as rename. The mutation should call the existing Headscale API, return the selected entity, and emit the relevant Fate live event.
8. **Delete the old React Router loader/action/SSE path for converted data**, rather than running duplicate data models side-by-side.
9. **Fix critical auth/session issues early**: self-linking, raw API-key cookie, agent-route authz.
10. **Keep runtime changes minimal** until the SPA actually needs them: Hono routes, static SPA fallback, Fate routes, and later SEA asset serving.
### Vite+ / VoidZero tooling assessment
Vite+ is a unified CLI (`vp`) for Vite, Vitest, Oxlint, Oxfmt, Rolldown, tsdown, type checking, package-manager/runtime management, and task caching. Headplane already uses most of these tools separately, so Vite+ would mostly consolidate tooling and improve task ergonomics; it will not solve the data-layer problems by itself.
Recommended Vite+ approach:
- Try `vp migrate` in a separate branch only after adding CI typecheck/lint gates.
- Expect manual work around the custom React Router SSR entry and `runtime/vite-plugin.ts`.
- Do not combine Vite+ migration with Fate/Void/router migration in the same PR.
## Suggested immediate backlog
1. Replace the temporary `hsLive` bridge with direct Fate events from converted mutations and, if needed, a principal-safe polling source.
2. Move the machine rename UI out of the throwaway table row controls once the permanent SPA machines page layout exists.
3. Port the next machine mutations: expire/delete/tags/routes, one at a time, each returning selected data or deleting/updating the normalized cache explicitly.
4. Fix OIDC self-linking authorization.
5. Make API-key sessions opaque/server-side and set explicit auth cookie flags.
6. Add capability checks to `/settings/agent` loader/action.
7. Fix live-data pause cleanup/refcounting for unconverted React Router routes.
8. Make `hsLive` use a stable server credential or principal-scoped cache while it still exists.
9. Serialize config patches with a real mutex/queue and clone config arrays before editing.
10. Add CI `pnpm run typecheck` and `pnpm run lint`.
11. Add WebSSH top-level dispose and release Go `js.Func` values.
+2
View File
@@ -1,7 +1,9 @@
FROM --platform=$BUILDPLATFORM golang:1.25.1 AS go-base
WORKDIR /run
RUN apt-get update && apt-get install -y --no-install-recommends patch && rm -rf /var/lib/apt/lists/*
COPY go.mod go.sum build.sh ./
COPY patches/ ./patches/
RUN go mod download
COPY cmd/ ./cmd/
+4
View File
@@ -1,4 +1,5 @@
# Headplane
> A feature-complete web UI for [Headscale](https://headscale.net)
<picture>
@@ -32,14 +33,17 @@ These are some of the features that Headplane offers:
- Configurability for Headscale's settings
## Deployment
Refer to the [website](https://headplane.net) for detailed installation instructions.
## Versioning
Headplane uses [semantic versioning](https://semver.org/) for its releases (since v0.6.0).
Pre-release builds are available under the `next` tag and get updated when a new release
PR is opened and actively in testing.
## Contributing
Headplane is an open-source project and contributions are welcome! If you have
any suggestions, bug reports, or feature requests, please open an issue. Also
refer to the [contributor guidelines](./docs/CONTRIBUTING.md) for more info.
+18 -12
View File
@@ -14,18 +14,18 @@ export interface AttributeProps {
export default function Attribute({ name, value, tooltip, isCopyable }: AttributeProps) {
return (
<dl className="flex items-center gap-1 text-sm">
<dl className="group/attr flex items-baseline gap-1 text-sm">
<dt
className={cn(
"w-1/3 sm:w-1/4 lg:w-1/3 shrink-0 min-w-0",
"text-mist-500 dark:text-mist-400",
tooltip ? "flex items-center gap-1" : undefined,
"text-mist-600 dark:text-mist-300",
tooltip ? "flex items-baseline gap-1" : undefined,
)}
>
{name}
{tooltip ? (
<Tooltip content={tooltip}>
<Info className="size-4" />
<Info className="size-3.5 translate-y-0.5 opacity-40 transition-opacity hover:opacity-100" />
</Tooltip>
) : undefined}
</dt>
@@ -64,16 +64,22 @@ export default function Attribute({ name, value, tooltip, isCopyable }: Attribut
<div suppressHydrationWarning className="truncate">
{value}
</div>
{isCopyable ? (
<div>
<Check className="hidden size-4 data-copied:block" />
<Copy className="block size-4 data-copied:hidden" />
</div>
) : undefined}
<div className="opacity-0 transition-opacity group-hover/attr:opacity-100">
<Check className="hidden size-3.5 data-copied:block" />
<Copy className="block size-3.5 data-copied:hidden" />
</div>
</button>
) : (
<div className="relative min-w-0 truncate" suppressHydrationWarning>
{value}
<div className="relative min-w-0" suppressHydrationWarning>
{value.includes("\n") ? (
value.split("\n").map((line) => (
<div key={line} className="truncate">
{line}
</div>
))
) : (
<div className="truncate">{value}</div>
)}
</div>
)}
</dd>
+3 -3
View File
@@ -29,7 +29,7 @@ function TabList({ children, className }: { children: ReactNode; className?: str
return (
<BaseTabs.List
className={cn(
"flex items-center rounded-t-lg w-fit max-w-full",
"flex items-center rounded-t-md w-fit max-w-full",
"border-mist-200 dark:border-mist-800",
"border-t border-x",
className,
@@ -56,7 +56,7 @@ function Tab({
"focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1 z-10",
"dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
"border-r border-mist-200 dark:border-mist-800",
"first:rounded-tl-lg last:rounded-tr-lg last:border-r-0",
"first:rounded-tl-md last:rounded-tr-md last:border-r-0",
className,
)}
>
@@ -76,7 +76,7 @@ function Panel({
value={value}
{...props}
className={cn(
"w-full overflow-clip rounded-b-lg rounded-r-lg",
"w-full overflow-clip rounded-b-md rounded-r-md",
"border border-mist-200 dark:border-mist-800",
className,
)}
+9 -9
View File
@@ -1,12 +1,12 @@
import { StrictMode, startTransition } from 'react';
import { hydrateRoot } from 'react-dom/client';
import { HydratedRouter } from 'react-router/dom';
import { StrictMode, startTransition } from "react";
import { hydrateRoot } from "react-dom/client";
import { HydratedRouter } from "react-router/dom";
startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<HydratedRouter />
</StrictMode>,
);
hydrateRoot(
document,
<StrictMode>
<HydratedRouter />
</StrictMode>,
);
});
+3 -3
View File
@@ -1,10 +1,10 @@
import type { RenderToPipeableStreamOptions } from "react-dom/server";
import type { AppLoadContext, EntryContext } from "react-router";
import { PassThrough } from "node:stream";
import { createReadableStreamFromReadable } from "@react-router/node";
import { isbot } from "isbot";
import { PassThrough } from "node:stream";
import type { RenderToPipeableStreamOptions } from "react-dom/server";
import { renderToPipeableStream } from "react-dom/server";
import type { AppLoadContext, EntryContext } from "react-router";
import { ServerRouter } from "react-router";
export const streamTimeout = 5_000;
+4
View File
@@ -0,0 +1,4 @@
// Globals replaced at build time by Vite (`define` in `vite.config.ts`).
declare const __PREFIX__: string;
declare const __VERSION__: string;
+2 -4
View File
@@ -2,7 +2,6 @@ import { Outlet, redirect, type ShouldRevalidateFunction } from "react-router";
import { ErrorBanner } from "~/components/error-banner";
import StatusBanner from "~/components/status-banner";
import { pruneEphemeralNodes } from "~/server/db/pruner";
import { isDataUnauthorizedError } from "~/server/headscale/api/error-client";
import { usersResource } from "~/server/headscale/live-store";
import { Capabilities } from "~/server/web/roles";
@@ -30,11 +29,11 @@ export const shouldRevalidate: ShouldRevalidateFunction = ({
return false;
};
export async function loader({ request, context, ...rest }: Route.LoaderArgs) {
export async function loader({ request, context }: Route.LoaderArgs) {
try {
const principal = await context.auth.require(request);
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
const user =
@@ -53,7 +52,6 @@ export async function loader({ request, context, ...rest }: Route.LoaderArgs) {
if (isHealthy) {
try {
await api.getApiKeys();
await pruneEphemeralNodes({ context, request, ...rest });
} catch (error) {
if (isDataUnauthorizedError(error)) {
const displayName =
+87 -7
View File
@@ -1,5 +1,17 @@
import { CircleQuestionMark, CircleUser, Globe, Lock, Server, Settings, Users } from "lucide-react";
import { NavLink, useSubmit } from "react-router";
import {
Check,
CircleQuestionMark,
CircleUser,
Globe,
Lock,
Monitor,
Moon,
Server,
Settings,
Sun,
Users,
} from "lucide-react";
import { NavLink, unstable_useRoute as useRoute, useLocation, useSubmit } from "react-router";
import Link from "~/components/link";
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "~/components/menu";
@@ -7,6 +19,7 @@ import logoBg from "~/logo/dark-bg.svg";
import logoDark from "~/logo/dark.svg";
import logoLight from "~/logo/light.svg";
import cn from "~/utils/cn";
import type { ColorScheme } from "~/utils/color-scheme";
export interface HeaderProps {
user: {
@@ -35,9 +48,26 @@ const tabs = [
{ to: "/settings", icon: Settings, label: "Settings", key: "settings" },
] as const;
const colorSchemes = [
{ value: "system", label: "System", icon: Monitor },
{ value: "light", label: "Light", icon: Sun },
{ value: "dark", label: "Dark", icon: Moon },
] as const satisfies ReadonlyArray<{
value: ColorScheme;
label: string;
icon: typeof Monitor;
}>;
export default function Header({ user, access, configAvailable }: HeaderProps) {
const submit = useSubmit();
const showTabs = access.ui;
const rootRoute = useRoute("root");
const currentColorScheme: ColorScheme = rootRoute?.loaderData?.colorScheme ?? "system";
// useLocation returns the path with the basename already stripped, which is
// what `redirect()` expects — react-router re-applies the basename when
// following the redirect on the client.
const location = useLocation();
const returnTo = location.pathname + location.search;
return (
<header
@@ -46,10 +76,10 @@ export default function Header({ user, access, configAvailable }: HeaderProps) {
"dark:border-b dark:border-mist-800 shadow-inner",
)}
>
<div className="container flex items-center justify-between py-4">
<div className="flex items-center gap-x-8">
<div className="container flex items-center gap-x-4 py-4">
<div className="flex min-w-0 items-center gap-x-4">
<div className="flex items-center gap-x-2">
<picture>
<picture className="min-w-8">
<source srcSet={logoLight} media="(prefers-color-scheme: dark)" />
<source srcSet={logoDark} media="(prefers-color-scheme: light)" />
<img src={logoBg} alt="Headplane logo" />
@@ -57,7 +87,7 @@ export default function Header({ user, access, configAvailable }: HeaderProps) {
<h1 className="text-2xl font-semibold">headplane</h1>
</div>
{showTabs && (
<nav className="hidden items-center gap-x-2 text-sm font-medium md:flex">
<nav className="hidden items-center gap-x-2 overflow-x-auto p-1 text-sm font-medium md:flex">
{tabs.map((tab) => {
if (!access[tab.key]) return null;
if ((tab.key === "dns" || tab.key === "settings") && !configAvailable) return null;
@@ -87,7 +117,7 @@ export default function Header({ user, access, configAvailable }: HeaderProps) {
</nav>
)}
</div>
<div className="grid grid-cols-2 gap-x-4">
<div className="ml-auto grid shrink-0 grid-cols-2 gap-x-4">
<Menu>
<MenuTrigger className="size-8 rounded-full p-1">
<CircleQuestionMark className="w-5" />
@@ -135,6 +165,24 @@ export default function Header({ user, access, configAvailable }: HeaderProps) {
</div>
</MenuItem>
<MenuSeparator />
{colorSchemes.map(({ value, label, icon: Icon }) => (
<MenuItem
key={value}
onClick={() =>
submit(
{ colorScheme: value, returnTo },
{ action: "/api/color-scheme", method: "POST" },
)
}
>
<div className="flex items-center gap-x-2">
<Icon className="size-4" />
<span className="flex-1">{label}</span>
{currentColorScheme === value && <Check className="size-4" />}
</div>
</MenuItem>
))}
<MenuSeparator />
<MenuItem
variant="danger"
onClick={() => submit({}, { action: "/logout", method: "POST" })}
@@ -145,6 +193,38 @@ export default function Header({ user, access, configAvailable }: HeaderProps) {
</Menu>
</div>
</div>
{showTabs && (
<div className="block overflow-x-auto p-2 md:hidden">
<nav className="flex items-center gap-x-2 text-sm font-medium">
{tabs.map((tab) => {
if (!access[tab.key]) return null;
if ((tab.key === "dns" || tab.key === "settings") && !configAvailable) return null;
return (
<NavLink
key={tab.to}
className={({ isActive }) =>
cn(
"relative px-3 py-1.5 flex items-center gap-x-1.5 rounded-md text-nowrap",
"hover:bg-mist-300/50 dark:hover:bg-mist-800",
"focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1",
"dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
"text-mist-600 dark:text-mist-300",
isActive &&
"text-mist-900 dark:text-mist-50 after:content-[''] after:absolute after:-bottom-2 after:inset-x-1 after:h-0.5 after:rounded-full after:bg-indigo-500",
)
}
prefetch="intent"
to={tab.to}
>
<tab.icon className="w-4" />
{tab.label}
</NavLink>
);
})}
</nav>
</div>
)}
</header>
);
}
+29 -9
View File
@@ -1,7 +1,12 @@
import type { LinksFunction, MetaFunction } from "react-router";
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router";
import "@fontsource-variable/inter";
import { ExternalScripts } from "remix-utils/external-scripts";
import type { MetaFunction } from "react-router";
import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
unstable_useRoute as useRoute,
} from "react-router";
import { LiveDataProvider } from "~/utils/live-data";
import ToastProvider from "~/utils/toast-provider";
@@ -9,7 +14,9 @@ import ToastProvider from "~/utils/toast-provider";
import type { Route } from "./+types/root";
import { ErrorBanner } from "./components/error-banner";
import stylesheet from "~/tailwind.css?url";
import "@fontsource-variable/inter/opsz.css";
import "./tailwind.css";
import { getColorScheme } from "./utils/color-scheme";
export const meta: MetaFunction = () => [
{ title: "Headplane" },
@@ -19,15 +26,29 @@ export const meta: MetaFunction = () => [
},
];
export const links: LinksFunction = () => [{ rel: "stylesheet", href: stylesheet }];
export async function loader({ request }: Route.LoaderArgs) {
const colorScheme = await getColorScheme(request);
return { colorScheme };
}
export function Layout({ children }: { readonly children: React.ReactNode }) {
const { loaderData } = useRoute("root");
// LiveDataProvider is wrapped at the top level since dialogs and things
// that control its state are usually open in portal containers which
// are not a part of the normal React tree.
return (
<LiveDataProvider>
<html lang="en">
<html
lang="en"
className={
loaderData?.colorScheme === "dark"
? "dark"
: loaderData?.colorScheme === "light"
? "light"
: ""
}
>
<head>
<meta charSet="utf-8" />
<meta content="width=device-width, initial-scale=1" name="viewport" />
@@ -35,12 +56,11 @@ export function Layout({ children }: { readonly children: React.ReactNode }) {
<Links />
<link href={`${__PREFIX__}/favicon.ico`} rel="icon" />
</head>
<body className="overflow-x-hidden overscroll-none dark:bg-mist-900 dark:text-mist-50">
<body className="w-full overflow-x-hidden overscroll-none dark:bg-mist-900 dark:text-mist-50">
{children}
<ToastProvider />
<ScrollRestoration />
<Scripts />
<ExternalScripts />
</body>
</html>
</LiveDataProvider>
+5 -2
View File
@@ -5,7 +5,10 @@ export default [
route("/healthz", "routes/util/healthz.ts"),
// API Routes
...prefix("/api", [route("/info", "routes/util/info.ts")]),
...prefix("/api", [
route("/info", "routes/util/info.ts"),
route("/color-scheme", "routes/util/color-scheme.ts"),
]),
...prefix("/events", [route("/live", "routes/util/live.ts")]),
// Authentication Routes
@@ -13,7 +16,7 @@ export default [
route("/logout", "routes/auth/logout.ts"),
route("/oidc/callback", "routes/auth/oidc-callback.ts"),
route("/oidc/start", "routes/auth/oidc-start.ts"),
route("/ssh", "routes/ssh/console.tsx"),
route("/ssh/:id", "routes/ssh/page.tsx"),
// All the main logged-in routes
layout("layout/app.tsx", [
+1 -1
View File
@@ -26,7 +26,7 @@ export async function aclAction({ request, context }: Route.ActionArgs) {
});
}
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
try {
const { policy, updatedAt } = await api.setPolicy(policyData);
+5 -2
View File
@@ -29,7 +29,7 @@ export async function aclLoader({ request, context }: Route.LoaderArgs) {
};
// Try to load the ACL policy from the API.
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
try {
const { policy, updatedAt } = await api.getPolicy();
@@ -38,8 +38,11 @@ export async function aclLoader({ request, context }: Route.LoaderArgs) {
return flags;
} catch (error) {
if (isDataWithApiError(error)) {
// Headscale returns "acl policy not found" when the policy mode is
// set to file but no file exists, and returns a 500 when database
// mode is used but the policies table is empty.
// https://github.com/juanfont/headscale/blob/c4600346f9c29b514dc9725ac103efb9d0381f23/hscontrol/types/policy.go#L10
if (error.data.rawData.includes("acl policy not found")) {
if (error.data.rawData.includes("acl policy not found") || error.data.statusCode === 500) {
flags.policy = "";
flags.writable = true;
return flags;
+61 -100
View File
@@ -1,112 +1,73 @@
import * as shopify from '@shopify/lang-jsonc';
import { xcodeDark, xcodeLight } from '@uiw/codemirror-theme-xcode';
import CodeMirror from '@uiw/react-codemirror';
import { BookCopy, CircleX } from 'lucide-react';
import { useEffect, useState } from 'react';
import Merge from 'react-codemirror-merge';
import { ErrorBoundary } from 'react-error-boundary';
import { ClientOnly } from 'remix-utils/client-only';
import Fallback from './fallback';
import * as shopify from "@shopify/lang-jsonc";
import CodeMirror from "@uiw/react-codemirror";
import { BookCopy, CircleX } from "lucide-react";
import Merge from "react-codemirror-merge";
import { ErrorBoundary } from "react-error-boundary";
import { headplaneTheme } from "./theme";
interface EditorProps {
isDisabled?: boolean;
value: string;
onChange: (value: string) => void;
isDisabled?: boolean;
value: string;
onChange: (value: string) => void;
}
// TODO: Remove ClientOnly
export function Editor(props: EditorProps) {
const [light, setLight] = useState(false);
useEffect(() => {
const theme = window.matchMedia('(prefers-color-scheme: light)');
setLight(theme.matches);
theme.addEventListener('change', (theme) => {
setLight(theme.matches);
});
});
return (
<div className="overflow-y-scroll h-editor text-sm">
<ErrorBoundary
fallback={
<div className="flex flex-col items-center gap-2.5 py-8">
<CircleX />
<p className="text-lg font-semibold">Failed to load the editor.</p>
</div>
}
>
<ClientOnly fallback={<Fallback acl={props.value} />}>
{() => (
<CodeMirror
editable={!props.isDisabled}
extensions={[shopify.jsonc()]} // Allow editing unless disabled
height="100%" // Use readOnly if disabled
onChange={(value) => props.onChange(value)}
readOnly={props.isDisabled}
style={{ height: '100%' }}
theme={light ? xcodeLight : xcodeDark}
value={props.value}
/>
)}
</ClientOnly>
</ErrorBoundary>
</div>
);
return (
<div className="text-sm">
<ErrorBoundary
fallback={
<div className="flex flex-col items-center gap-2.5 py-8">
<CircleX />
<p className="text-lg font-semibold">Failed to load the editor.</p>
</div>
}
>
<CodeMirror
editable={!props.isDisabled}
extensions={[shopify.jsonc()]}
minHeight="24rem"
maxHeight="var(--height-editor)"
onChange={(value) => props.onChange(value)}
readOnly={props.isDisabled}
theme={headplaneTheme}
value={props.value}
/>
</ErrorBoundary>
</div>
);
}
interface DifferProps {
left: string;
right: string;
left: string;
right: string;
}
export function Differ(props: DifferProps) {
const [light, setLight] = useState(false);
useEffect(() => {
const theme = window.matchMedia('(prefers-color-scheme: light)');
setLight(theme.matches);
theme.addEventListener('change', (theme) => {
setLight(theme.matches);
});
});
return (
<div className="text-sm">
{props.left === props.right ? (
<div className="flex flex-col items-center gap-2.5 py-8">
<BookCopy />
<p className="text-lg font-semibold">No changes</p>
</div>
) : (
<div className="h-editor overflow-y-scroll">
<ErrorBoundary
fallback={
<div className="flex flex-col items-center gap-2.5 py-8">
<CircleX />
<p className="text-lg font-semibold">
Failed to load the editor.
</p>
</div>
}
>
<ClientOnly fallback={<Fallback acl={props.right} />}>
{() => (
<Merge orientation="a-b" theme={light ? xcodeLight : xcodeDark}>
<Merge.Original
extensions={[shopify.jsonc()]}
readOnly
value={props.left}
/>
<Merge.Modified
extensions={[shopify.jsonc()]}
readOnly
value={props.right}
/>
</Merge>
)}
</ClientOnly>
</ErrorBoundary>
</div>
)}
</div>
);
return (
<div className="text-sm">
{props.left === props.right ? (
<div className="flex flex-col items-center gap-2.5 py-8">
<BookCopy />
<p className="text-lg font-semibold">No changes</p>
</div>
) : (
<div className="h-editor">
<ErrorBoundary
fallback={
<div className="flex flex-col items-center gap-2.5 py-8">
<CircleX />
<p className="text-lg font-semibold">Failed to load the editor.</p>
</div>
}
>
<Merge orientation="a-b" theme={headplaneTheme}>
<Merge.Original extensions={[shopify.jsonc()]} readOnly value={props.left} />
<Merge.Modified extensions={[shopify.jsonc()]} readOnly value={props.right} />
</Merge>
</ErrorBoundary>
</div>
)}
</div>
);
}
+11 -29
View File
@@ -1,36 +1,18 @@
import { Loader2 } from "lucide-react";
import cn from "~/utils/cn";
interface Props {
readonly acl: string;
}
export default function Fallback({ acl }: Props) {
export default function Fallback() {
return (
<div className="h-editor relative flex w-full">
<div
className={cn(
"h-full w-8 flex justify-center p-1",
"border-r border-mist-400 dark:border-mist-800",
)}
>
<div
aria-hidden
className={cn(
"h-5 w-5 animate-spin rounded-full",
"border-mist-900 dark:border-mist-100",
"border-2 border-t-transparent dark:border-t-transparent",
)}
/>
<div
className={cn("h-editor overflow-hidden rounded-md", "bg-[var(--cm-bg)] text-[var(--cm-fg)]")}
>
<div className="flex h-full items-center justify-center">
<div className="flex flex-col items-center gap-2 text-[var(--cm-gutter-fg)]">
<Loader2 className="size-5 animate-spin" />
<p className="text-sm">Loading editor</p>
</div>
</div>
<textarea
className={cn(
"w-full h-editor font-mono resize-none text-sm",
"bg-mist-50 dark:bg-mist-950 opacity-60",
"pl-1 pt-1 leading-snug",
)}
readOnly
value={acl}
/>
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
import { EditorView } from "@codemirror/view";
import { tags as t } from "@lezer/highlight";
const editorTheme = EditorView.theme({
"&": {
backgroundColor: "var(--cm-bg)",
color: "var(--cm-fg)",
},
"&.cm-editor.cm-focused": {
outline: "none",
},
".cm-content": {
caretColor: "var(--cm-caret)",
fontFamily: "var(--font-mono, ui-monospace, monospace)",
},
"&.cm-editor .cm-scroller": {
fontFamily: "var(--font-mono, ui-monospace, monospace)",
},
".cm-cursor, .cm-dropCursor": {
borderLeftColor: "var(--cm-caret)",
},
"&.cm-focused .cm-selectionBackground, & .cm-line::selection, & .cm-selectionLayer .cm-selectionBackground, .cm-content ::selection":
{
background: "var(--cm-selection) !important",
},
"& .cm-selectionMatch": {
backgroundColor: "var(--cm-selection-match)",
},
".cm-activeLine": {
backgroundColor: "var(--cm-line-highlight)",
},
".cm-gutters": {
backgroundColor: "var(--cm-gutter-bg)",
color: "var(--cm-gutter-fg)",
borderRight: "1px solid var(--cm-gutter-border)",
},
".cm-activeLineGutter": {
backgroundColor: "var(--cm-line-highlight)",
color: "var(--cm-gutter-fg-active)",
},
".cm-scroller": {
scrollbarColor: "var(--cm-gutter-border) transparent",
scrollbarWidth: "auto",
},
});
const highlightStyle = HighlightStyle.define([
{ tag: [t.comment, t.quote], color: "var(--cm-comment)" },
{ tag: [t.keyword], color: "var(--cm-keyword)", fontWeight: "bold" },
{ tag: [t.string, t.meta], color: "var(--cm-string)" },
{ tag: [t.typeName, t.typeOperator], color: "var(--cm-type)" },
{ tag: [t.definition(t.variableName)], color: "var(--cm-definition)" },
{ tag: [t.name], color: "var(--cm-name)" },
{ tag: [t.variableName], color: "var(--cm-variable)" },
{ tag: [t.propertyName], color: "var(--cm-property)" },
{ tag: [t.atom, t.bool, t.special(t.variableName)], color: "var(--cm-atom)" },
{ tag: [t.number], color: "var(--cm-number)" },
{ tag: [t.regexp, t.link], color: "var(--cm-link)" },
{ tag: [t.bracket], color: "var(--cm-bracket)" },
]);
export const headplaneTheme = [editorTheme, syntaxHighlighting(highlightStyle)];
+15 -4
View File
@@ -1,5 +1,5 @@
import { AlertCircle, Construction, Eye, FlaskConical, Pencil } from "lucide-react";
import { useEffect, useState } from "react";
import { Suspense, lazy, useEffect, useState } from "react";
import { isRouteErrorResponse, useFetcher, useRevalidator } from "react-router";
import Button from "~/components/button";
@@ -15,7 +15,14 @@ import toast from "~/utils/toast";
import type { Route } from "./+types/overview";
import { aclAction } from "./acl-action";
import { aclLoader } from "./acl-loader";
import { Differ, Editor } from "./components/cm.client";
import Fallback from "./components/fallback";
const LazyEditor = lazy(() =>
import("./components/cm.client").then((m) => ({ default: m.Editor })),
);
const LazyDiffer = lazy(() =>
import("./components/cm.client").then((m) => ({ default: m.Differ })),
);
export const loader = aclLoader;
export const action = aclAction;
@@ -101,10 +108,14 @@ export default function Page({ loaderData: { access, writable, policy } }: Route
</TabsTab>
</TabsList>
<TabsPanel value="edit">
<Editor isDisabled={disabled} onChange={setCodePolicy} value={codePolicy} />
<Suspense fallback={<Fallback />}>
<LazyEditor isDisabled={disabled} onChange={setCodePolicy} value={codePolicy} />
</Suspense>
</TabsPanel>
<TabsPanel value="diff">
<Differ left={policy} right={codePolicy} />
<Suspense fallback={<Fallback />}>
<LazyDiffer left={policy} right={codePolicy} />
</Suspense>
</TabsPanel>
<TabsPanel value="preview">
<div className="flex flex-col items-center py-8">
+14 -40
View File
@@ -3,7 +3,7 @@ import { AlertCircle, CloudOff } from "lucide-react";
import Card from "~/components/card";
import Code from "~/components/code";
import Link from "~/components/link";
import type { OidcConnectorError } from "~/server/web/oidc-connector";
import type { OidcErrorCode } from "~/server/oidc/provider";
export function OidcDiscoveryFailedNotice() {
return (
@@ -20,7 +20,7 @@ export function OidcDiscoveryFailedNotice() {
);
}
export function OidcConfigErrorNotice({ errors }: { errors: OidcConnectorError[] }) {
export function OidcConfigErrorNotice({ errors }: { errors: OidcErrorCode[] }) {
return (
<Card className="m-4 mb-4 max-w-md border border-red-500 sm:m-0 sm:mb-4">
<div className="flex items-center justify-between gap-4">
@@ -34,7 +34,7 @@ export function OidcConfigErrorNotice({ errors }: { errors: OidcConnectorError[]
<li key={code.key}>{code.node}</li>
))}
</ul>{" "}
<Link external styled to="https://headplane.net/configuration/sso#troubleshooting">
<Link external styled to="https://headplane.net/features/sso#troubleshooting">
Learn more
</Link>
</Card.Text>
@@ -42,7 +42,7 @@ export function OidcConfigErrorNotice({ errors }: { errors: OidcConnectorError[]
);
}
function mapOidcErrorsToMessages(errors: OidcConnectorError[]) {
function mapOidcErrorsToMessages(errors: OidcErrorCode[]) {
const messages: {
key: string;
node: React.ReactNode;
@@ -50,7 +50,7 @@ function mapOidcErrorsToMessages(errors: OidcConnectorError[]) {
for (const error of errors) {
switch (error) {
case "INVALID_API_KEY": {
case "invalid_api_key": {
messages.push({
key: error,
node: (
@@ -63,65 +63,39 @@ function mapOidcErrorsToMessages(errors: OidcConnectorError[]) {
break;
}
case "MISSING_AUTHORIZATION_ENDPOINT": {
case "missing_endpoints": {
messages.push({
key: error,
node: (
<Card.Text className="inline">
The OIDC provided does not have a configured <Code>authorization_endpoint</Code>.
Ensure discovery URL or manual configuration is correct.
The OIDC provider is missing required endpoints. Ensure the discovery URL is correct
or provide manual endpoint overrides in your configuration.
</Card.Text>
),
});
break;
}
case "MISSING_TOKEN_ENDPOINT": {
case "discovery_failed": {
messages.push({
key: error,
node: (
<Card.Text className="inline">
The OIDC provided does not have a configured <Code>token_endpoint</Code>. Ensure
discovery URL or manual configuration is correct.
Unable to reach the OIDC provider for discovery. SSO will retry on the next login
attempt.
</Card.Text>
),
});
break;
}
case "MISSING_USERINFO_ENDPOINT": {
default: {
messages.push({
key: error,
node: (
<Card.Text className="inline">
The OIDC provided does not have a configured <Code>user_endpoint</Code>. Ensure
discovery URL or manual configuration is correct.
</Card.Text>
),
});
break;
}
case "MISSING_REQUIRED_CLAIMS": {
messages.push({
key: error,
node: (
<Card.Text className="inline">
The OIDC provider does not support the <Code>sub</Code> claim, which is required for
authentication. Your OIDC provider may be misconfigured.
</Card.Text>
),
});
break;
}
case "UNKNOWN_ERROR": {
messages.push({
key: error,
node: (
<Card.Text className="inline">
An unknown error occurred during OIDC configuration. Please check the Headplane logs
for more information.
An unknown OIDC configuration error occurred. Please check the Headplane logs for more
information.
</Card.Text>
),
});
+11 -7
View File
@@ -24,16 +24,20 @@ export async function loader({ request, context }: Route.LoaderArgs) {
const qp = new URL(request.url).searchParams;
const urlState = qp.get("s") ?? undefined;
const oidcConnector = await context.oidc?.connector.get();
const oidcService = context.oidc?.service;
const oidcStatus = oidcService
? await oidcService.discover().then(
(r) => (r.ok ? oidcService.status() : oidcService.status()),
() => oidcService.status(),
)
: undefined;
// MARK: This works because the OIDC connector will always return false
// For `isExclusive` if the OIDC config isn't usable.
if (oidcConnector?.isExclusive && urlState !== "logout") {
if (context.oidc?.disableApiKeyLogin && oidcStatus?.state === "ready" && urlState !== "logout") {
return redirect("/oidc/start");
}
const isOidcConnectorEnabled = oidcConnector?.isValid;
const oidcErrorCodes = !isOidcConnectorEnabled ? (oidcConnector?.errors ?? []) : [];
const isOidcConnectorEnabled = oidcStatus?.state === "ready";
const oidcErrorCodes = oidcStatus?.state === "error" ? [oidcStatus.error.code] : [];
return {
isCookieSecureEnabled: context.config.server.cookie_secure,
@@ -88,7 +92,7 @@ export default function Page({ loaderData, actionData }: Route.ComponentProps) {
<div>
{urlState?.startsWith("error_") ? (
<OidcErrorNotice code={urlState} />
) : oidcErrorCodes.includes("DISCOVERY_FAILED") ? (
) : oidcErrorCodes.includes("discovery_failed") ? (
<OidcDiscoveryFailedNotice />
) : oidcErrorCodes.length > 0 ? (
<OidcConfigErrorNotice errors={oidcErrorCodes} />
+25 -5
View File
@@ -1,21 +1,41 @@
import { type ActionFunctionArgs, redirect } from "react-router";
import type { LoadContext } from "~/server";
import type { AppContext } from "~/server/context";
export async function loader() {
return redirect("/machines");
}
export async function action({ request, context }: ActionFunctionArgs<LoadContext>) {
export async function action({ request, context }: ActionFunctionArgs<AppContext>) {
let principal: Awaited<ReturnType<typeof context.auth.require>> | undefined;
try {
await context.auth.require(request);
principal = await context.auth.require(request);
} catch {
redirect("/login");
return redirect("/login");
}
// When API key is disabled, we need to explicitly redirect
// with a logout state to prevent auto login again.
const url = context.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" && 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 context.oidc.service.discover();
}
const endSessionUrl = context.oidc.service.buildEndSessionUrl(principal.idToken);
if (endSessionUrl) {
url = endSessionUrl;
}
}
return redirect(url, {
headers: {
+50 -135
View File
@@ -1,6 +1,3 @@
import { createHash } from "node:crypto";
import * as oidc from "openid-client";
import { data, redirect } from "react-router";
import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity";
@@ -10,8 +7,8 @@ import { createOidcStateCookie } from "~/utils/oidc-state";
import type { Route } from "./+types/oidc-callback";
export async function loader({ request, context }: Route.LoaderArgs) {
const oidcConnector = await context.oidc?.connector.get();
if (!oidcConnector?.isValid) {
const service = context.oidc?.service;
if (!service) {
throw data("OIDC is not enabled or misconfigured", { status: 501 });
}
@@ -34,138 +31,56 @@ export async function loader({ request, context }: Route.LoaderArgs) {
return redirect("/login?s=error_invalid_session");
}
try {
const callbackUrl = new URL(redirect_uri);
const currentUrl = new URL(request.url);
callbackUrl.search = currentUrl.search;
const flowState = {
state,
nonce,
codeVerifier: verifier,
redirectUri: redirect_uri,
};
const tokens = await oidc.authorizationCodeGrant(oidcConnector.client, callbackUrl, {
expectedState: state,
expectedNonce: nonce,
...(oidcConnector.usePKCE ? { pkceCodeVerifier: verifier } : {}),
});
const claims = tokens.claims();
if (claims?.sub == null) {
log.warn("auth", "No subject found in OIDC claims");
return redirect("/login?s=error_no_sub");
}
const userInfo = await oidc.fetchUserInfo(
oidcConnector.client,
tokens.access_token,
claims.sub,
);
// We have defaults that closely follow what Headscale uses, maybe we
// can make it configurable in the future, but for now we only need the
// `sub` claim.
const username = userInfo.preferred_username ?? userInfo.email?.split("@")[0] ?? "user";
const name =
userInfo.name ??
(userInfo.given_name && userInfo.family_name
? `${userInfo.given_name} ${userInfo.family_name}`
: (userInfo.preferred_username ?? "SSO User"));
const picture = await (async () => {
if (context.config.oidc?.profile_picture_source === "gravatar") {
if (!userInfo.email) {
return undefined;
}
const emailHash = userInfo.email.trim().toLowerCase();
const hash = createHash("sha256").update(emailHash).digest("hex");
return `https://www.gravatar.com/avatar/${hash}?s=200&d=identicon&r=x`;
}
if (!userInfo.picture) {
return undefined;
}
try {
const response = await fetch(userInfo.picture, {
headers: { Authorization: `Bearer ${tokens.access_token}` },
});
if (response.ok) {
const contentType = response.headers.get("content-type");
if (contentType?.startsWith("image/")) {
const buffer = await response.arrayBuffer();
const base64 = Buffer.from(buffer).toString("base64");
return `data:${contentType};base64,${base64}`;
}
}
} catch {}
return userInfo.picture;
})();
const userId = await context.auth.findOrCreateUser(claims.sub, {
name,
email: userInfo.email,
picture,
});
try {
const hsApi = context.hsApi.getRuntimeClient(context.oidc!.apiKey);
const hsUsers = await hsApi.getUsers();
const hsUser = findHeadscaleUserBySubject(hsUsers, claims.sub, userInfo.email);
if (hsUser) {
await context.auth.linkHeadscaleUser(userId, hsUser.id);
}
} catch (error) {
log.warn("auth", "Failed to link Headscale user: %s", String(error));
}
return redirect("/", {
headers: {
"Set-Cookie": await context.auth.createOidcSession(userId, {
name,
email: userInfo.email,
username,
}),
},
});
} catch (error) {
if (error instanceof oidc.ResponseBodyError) {
log.error("auth", "Got an OIDC response error body: %s", JSON.stringify(error.cause));
// Check for PKCE-related errors
if (
error.error.toLowerCase().includes("code_verifier") ||
error.error.toLowerCase().includes("code verifier") ||
error.error.toLowerCase().includes("pkce")
) {
log.error(
"auth",
"PKCE error detected. Your OIDC provider may require PKCE to be enabled. Current setting: use_pkce=%s",
oidcConnector.usePKCE,
);
if (!oidcConnector.usePKCE) {
log.error(
"auth",
"Consider setting oidc.use_pkce=true in your configuration if your provider requires PKCE",
);
}
}
} else if (error instanceof oidc.AuthorizationResponseError) {
log.error("auth", "Got an OIDC authorization response error: %s", error.error);
} else if (error instanceof oidc.WWWAuthenticateChallengeError) {
log.error("auth", "Got an OIDC WWW-Authenticate challenge error");
} else if (error instanceof oidc.ClientError) {
log.error(
"auth",
"Got an OIDC authorization client error: %s",
error.cause instanceof Error ? error.cause.message : String(error.cause),
);
} else {
log.error(
"auth",
"Got an OIDC error: %s",
error instanceof Error && error.cause ? JSON.stringify(error.cause) : String(error),
);
const result = await service.handleCallback(url.searchParams, flowState);
if (!result.ok) {
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 userId = await context.auth.findOrCreateUser(identity.subject, {
name: identity.name,
email: identity.email,
picture: identity.picture,
});
try {
const hsApi = context.hsApi.getRuntimeClient(context.headscaleApiKey!);
const hsUsers = await hsApi.getUsers();
const hsUser = findHeadscaleUserBySubject(hsUsers, identity.subject, identity.email);
if (hsUser) {
await context.auth.linkHeadscaleUser(userId, hsUser.id);
}
} catch (error) {
log.warn("auth", "Failed to link Headscale user: %s", String(error));
}
// Only persist the id_token when RP-initiated logout is enabled — otherwise
// we'd be storing a credential we never use.
const idToken = context.oidc?.useEndSession ? identity.idToken : undefined;
return redirect("/", {
headers: {
"Set-Cookie": await context.auth.createOidcSession(
userId,
{
name: identity.name,
email: identity.email,
username: identity.username,
},
{ idToken },
),
},
});
}
+13 -57
View File
@@ -1,7 +1,5 @@
import * as oidc from "openid-client";
import { data, redirect } from "react-router";
import { HeadplaneConfig } from "~/server/config/config-schema";
import { createOidcStateCookie } from "~/utils/oidc-state";
import type { Route } from "./+types/oidc-start";
@@ -12,70 +10,28 @@ export async function loader({ request, context }: Route.LoaderArgs) {
return redirect("/");
} catch {}
const oidcConnector = await context.oidc?.connector.get();
if (!oidcConnector?.isValid) {
const service = context.oidc?.service;
if (!service) {
throw data("OIDC is not enabled or misconfigured", { status: 501 });
}
const result = await service.startFlow();
if (!result.ok) {
return redirect(`/login?s=${result.error.code}`);
}
const { url, flowState } = result.value;
const cookie = createOidcStateCookie(context.config);
const redirect_uri = getRedirectUri(context.config, request);
const nonce = oidc.randomNonce();
const verifier = oidc.randomPKCECodeVerifier();
const state = oidc.randomState();
const url = oidc.buildAuthorizationUrl(oidcConnector.client, {
...oidcConnector.extraParams,
scope: oidcConnector.scope,
redirect_uri,
state,
nonce,
...(oidcConnector.usePKCE
? {
code_challenge_method: "S256",
code_challenge: await oidc.calculatePKCECodeChallenge(verifier),
}
: {}),
});
return redirect(url.href, {
return redirect(url, {
status: 302,
headers: {
"Set-Cookie": await cookie.serialize({
state,
nonce,
verifier,
redirect_uri,
state: flowState.state,
nonce: flowState.nonce,
verifier: flowState.codeVerifier,
redirect_uri: flowState.redirectUri,
}),
},
});
}
function getRedirectUri(config: HeadplaneConfig, req: Request): string {
if (config.server.base_url != null) {
const url = new URL(`${__PREFIX__}/oidc/callback`, config.server.base_url);
return url.href;
}
if (config.oidc?.redirect_uri != null) {
const url = new URL(`${__PREFIX__}/oidc/callback`, config.oidc.redirect_uri);
return url.href;
}
const url = new URL(`${__PREFIX__}/oidc/callback`, req.url);
let host = req.headers.get("Host");
if (!host) {
host = req.headers.get("X-Forwarded-Host");
}
if (!host) {
throw data("Cannot determine redirect URI: no Host or X-Forwarded-Host header", {
status: 500,
});
}
const proto = req.headers.get("X-Forwarded-Proto");
url.protocol = proto ?? "http:";
url.host = host;
return url.href;
}
+2 -2
View File
@@ -4,7 +4,7 @@ import { useLoaderData } from "react-router";
import Code from "~/components/code";
import Notice from "~/components/notice";
import PageError from "~/components/page-error";
import type { LoadContext } from "~/server";
import type { AppContext } from "~/server/context";
import { Capabilities } from "~/server/web/roles";
import ManageDomains from "./components/manage-domains";
@@ -15,7 +15,7 @@ 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 }: LoaderFunctionArgs<LoadContext>) {
export async function loader({ request, context }: LoaderFunctionArgs<AppContext>) {
if (!context.hs.readable()) {
throw new Error("No configuration is available");
}
+2 -2
View File
@@ -28,7 +28,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
principal.kind === "oidc" &&
!principal.user.headscaleUserId
) {
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
let headscaleUsers: { id: string; name: string }[] = [];
@@ -64,7 +64,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
}
// No UI access — show the download/connect page
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
let linkedUserName: string | undefined;
@@ -13,7 +13,7 @@ import { TailscaleSSHTag } from "~/components/tags/TailscaleSSH";
import type { User } from "~/types";
import cn from "~/utils/cn";
import * as hinfo from "~/utils/host-info";
import type { PopulatedNode } from "~/utils/node-info";
import { isNoExpiry, type PopulatedNode } from "~/utils/node-info";
import { formatTimeDelta } from "~/utils/time";
import toast from "~/utils/toast";
import { getUserDisplayName } from "~/utils/user";
@@ -50,8 +50,8 @@ export default function MachineRow({
}, [magic, node.ipAddresses]);
return (
<tr className="group hover:bg-mist-50 dark:hover:bg-mist-950" key={node.id}>
<td className="py-2 pl-0.5 focus-within:ring-3">
<tr className="group hover:bg-mist-100 dark:hover:bg-mist-800" key={node.id}>
<td className="py-2 pl-2 focus-within:ring-3">
<Link className={cn("group/link h-full focus:outline-hidden")} to={`/machines/${node.id}`}>
<p
className={cn(
@@ -156,7 +156,7 @@ export function uiTagsForNode(node: PopulatedNode, isAgent?: boolean) {
uiTags.push("expired");
}
if (node.expiry === null) {
if (!node.expired && isNoExpiry(node.expiry)) {
uiTags.push("no-expiry");
}
+4 -7
View File
@@ -107,7 +107,7 @@ export default function MachineMenu({
// in a new WINDOW since href can only
// do a new TAB.
window.open(
`${__PREFIX__}/ssh?hostname=${node.givenName}`,
`${__PREFIX__}/ssh/${node.givenName}`,
"_blank",
"noopener,noreferrer,width=800,height=600",
);
@@ -120,17 +120,14 @@ export default function MachineMenu({
) : (
<Button
className={cn(
"py-0.5",
"py-0.5 rounded-lg",
"opacity-0 pointer-events-none group-hover:opacity-100",
"group-hover:pointer-events-auto",
)}
variant="ghost"
variant="light"
onClick={() => {
// We need to use JS to open the SSH URL
// in a new WINDOW since href can only
// do a new TAB.
window.open(
`${__PREFIX__}/ssh?hostname=${node.givenName}`,
`${__PREFIX__}/ssh/${node.givenName}`,
"_blank",
"noopener,noreferrer,width=800,height=600",
);
+1 -3
View File
@@ -10,9 +10,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
const principal = await context.auth.require(request);
const formData = await request.formData();
const api = context.hsApi.getRuntimeClient(
context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey),
);
const api = context.hsApi.getRuntimeClient(context.auth.getHeadscaleApiKey(principal));
const action = formData.get("action_id")?.toString();
if (!action) {
+12 -7
View File
@@ -12,7 +12,7 @@ import Tooltip from "~/components/tooltip";
import { nodesResource, usersResource } from "~/server/headscale/live-store";
import cn from "~/utils/cn";
import { getOSInfo, getTSVersion } from "~/utils/host-info";
import { mapNodes, sortNodeTags } from "~/utils/node-info";
import { isNoExpiry, mapNodes, sortNodeTags } from "~/utils/node-info";
import { getUserDisplayName } from "~/utils/user";
import type { Route } from "./+types/machine";
@@ -38,9 +38,7 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
}
}
const api = context.hsApi.getRuntimeClient(
context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey),
);
const api = context.hsApi.getRuntimeClient(context.auth.getHeadscaleApiKey(principal));
const [nodesSnap, usersSnap] = await Promise.all([
context.hsLive.get(nodesResource, api),
context.hsLive.get(usersResource, api),
@@ -283,14 +281,16 @@ export default function Page({
/>
<Attribute
name="Key expiry"
value={node.expiry !== null ? new Date(node.expiry).toLocaleString() : "Never"}
value={!isNoExpiry(node.expiry) ? new Date(node.expiry!).toLocaleString() : "Never"}
/>
{magic ? (
<Attribute isCopyable name="Domain" value={`${node.givenName}.${magic}`} />
) : undefined}
</div>
<div className="flex flex-col gap-1">
<p className="text-sm font-semibold uppercase opacity-75">Addresses</p>
<p className="text-sm font-semibold text-mist-600 uppercase dark:text-mist-300">
Addresses
</p>
<Attribute
isCopyable
name="Tailscale IPv4"
@@ -317,9 +317,14 @@ export default function Page({
value={`${node.givenName}.${magic}`}
/>
) : undefined}
{stats?.Endpoints ? (
<Attribute name="Endpoints" value={stats?.Endpoints?.join("\n") ?? "—"} />
) : undefined}
{stats ? (
<>
<p className="mt-4 text-sm font-semibold uppercase opacity-75">Client Connectivity</p>
<p className="mt-4 text-sm font-semibold text-mist-600 uppercase dark:text-mist-300">
Client Connectivity
</p>
<Attribute
name="Varies"
tooltip="Whether the machine is behind a difficult NAT that varies the machines IP address depending on the destination."
+1 -3
View File
@@ -30,9 +30,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
const writablePermission = context.auth.can(principal, Capabilities.write_machines);
const api = context.hsApi.getRuntimeClient(
context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey),
);
const api = context.hsApi.getRuntimeClient(context.auth.getHeadscaleApiKey(principal));
const [nodesSnap, usersSnap] = await Promise.all([
context.hsLive.get(nodesResource, api),
context.hsLive.get(usersResource, api),
+8 -5
View File
@@ -2,12 +2,13 @@ import { data } from "react-router";
import { getOidcSubject } from "~/server/web/headscale-identity";
import { Capabilities } from "~/server/web/roles";
import type { PreAuthKey } from "~/types";
import type { Route } from "./+types/overview";
export async function authKeysAction({ request, context }: Route.ActionArgs) {
const principal = await context.auth.require(request);
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
const canGenerateAny = context.auth.can(principal, Capabilities.generate_authkeys);
@@ -95,10 +96,12 @@ export async function authKeysAction({ request, context }: Route.ActionArgs) {
return data({ success: true as const, key: key.key });
}
case "expire_preauthkey": {
const keyId = formData.get("key_id")?.toString();
const key = formData.get("key")?.toString();
if (!key) {
return data("Missing `key` in the form data.", {
if (!keyId || !key) {
return data("Missing `key_id` or `key` in the form data.", {
status: 400,
});
}
@@ -111,10 +114,10 @@ export async function authKeysAction({ request, context }: Route.ActionArgs) {
}
await checkSelfServiceOwnership(user);
await api.expirePreAuthKey(user, key);
await api.expirePreAuthKey(user, { id: keyId, key } as unknown as PreAuthKey);
return data("Pre-auth key expired");
}
default:
return data("Invalid action", {
status: 400,
@@ -29,7 +29,8 @@ function findCurrentUser(users: User[], subject: string | undefined): User | und
if (u.provider !== "oidc" || !u.providerId) {
return false;
}
return u.providerId.split("/").pop() === subject;
const segment = u.providerId.split("/").pop();
return segment ? decodeURIComponent(segment) === subject : false;
});
}
@@ -17,6 +17,7 @@ export default function ExpireAuthKey({ authKey, user }: ExpireAuthKeyProps) {
<Title>Expire auth key?</Title>
<input name="action_id" type="hidden" value="expire_preauthkey" />
<input name="user_id" type="hidden" value={user.id} />
<input name="key_id" type="hidden" value={authKey.id} />
<input name="key" type="hidden" value={authKey.key} />
<Text>
Expiring this authentication key will immediately prevent it from being used to
+1 -1
View File
@@ -20,7 +20,7 @@ import AddAuthKey from "./dialogs/add-auth-key";
export async function loader({ request, context }: Route.LoaderArgs) {
const principal = await context.auth.require(request);
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
const usersSnap = await context.hsLive.get(usersResource, api);
+1 -2
View File
@@ -6,10 +6,9 @@ import PageError from "~/components/page-error";
import type { Route } from "./+types/overview";
export async function loader({ context }: Route.LoaderArgs) {
const oidcConnector = await context.oidc?.connector.get();
return {
config: context.hs.writable(),
isOidcEnabled: oidcConnector?.isValid ?? false,
isOidcEnabled: context.oidc?.service.status().state === "ready",
};
}
-278
View File
@@ -1,278 +0,0 @@
import { faker } from "@faker-js/faker";
import { eq } from "drizzle-orm";
import { Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import { data, type ShouldRevalidateFunction, useSubmit } from "react-router";
import { ExternalScriptsHandle } from "remix-utils/external-scripts";
import { EphemeralNodeInsert, ephemeralNodes } from "~/server/db/schema";
import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity";
import { useLiveData } from "~/utils/live-data";
import log from "~/utils/log";
import type { Route } from "./+types/console";
import UserPrompt from "./user-prompt";
import XTerm from "./xterm.client";
export const shouldRevalidate: ShouldRevalidateFunction = () => {
return false;
};
export async function loader({ request, context }: Route.LoaderArgs) {
const origin = new URL(request.url).origin;
const assets = [`${__PREFIX__}/wasm_exec.js`, `${__PREFIX__}/hp_ssh.wasm`];
const missing: string[] = [];
for (const file of assets) {
const res = await fetch(`${origin}${file}`, { method: "HEAD" });
if (!res.ok) missing.push(file);
}
if (missing.length > 0) {
throw data("WebSSH is not configured in this build.", 405);
}
if (!context.agents) {
throw data("WebSSH is only available with the Headplane agent integration", 400);
}
const principal = await context.auth.require(request);
if (principal.kind === "api_key") {
throw data("Only OAuth users are allowed to use WebSSH", 403);
}
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const api = context.hsApi.getRuntimeClient(apiKey);
const users = await api.getUsers();
// MARK: This assumes that a user has authenticated with Headscale first
// Since the only way to enforce permissions via ACLs is to generate a
// pre-authkey which REQUIRES a user ID, meaning the user has to have
// authenticated with Headscale first.
const lookup = findHeadscaleUserBySubject(users, principal.user.subject, principal.profile.email);
if (!lookup) {
throw data(`User with subject ${principal.user.subject} not found within Headscale`, 404);
}
const preAuthKey = await api.createPreAuthKey(
lookup.id,
true, // ephemeral
false, // reusable
new Date(Date.now() + 60 * 1000), // expiration: 1 minute
null, // aclTags
);
// TODO: Enable config to enforce generate_authkeys capability
// For now, any user is capable of WebSSH connections
// const check = await context.sessions.check(
// request,
// Capabilities.generate_authkeys,
// );
const qp = new URL(request.url).searchParams;
const username = qp.get("username") || undefined;
const hostname = qp.get("hostname") || undefined;
if (!hostname) {
throw data("Missing required parameter: hostname", 400);
}
if (!username) {
return {
ipnDetails: undefined,
sshDetails: {
username,
hostname,
},
};
}
// We're making a request to <url>/key?v=116 to check the CORS headers
const u = context.config.headscale.public_url ?? context.config.headscale.url;
// const res = await fetch(`${u}/key?v=116`, {
// method: 'GET',
// });
// const corsOrigin = res.headers.get('Access-Control-Allow-Origin');
// const corsMethods = res.headers.get('Access-Control-Allow-Methods');
// const corsHeaders = res.headers.get('Access-Control-Allow-Headers');
// console.log(corsOrigin, corsMethods, corsHeaders);
// if (!corsOrigin || !corsMethods || !corsHeaders) {
// throw data(
// 'Headscale server does not have the required CORS headers for WebSSH',
// 500,
// );
// }
const nodes = await api.getNodes();
const lookupNode = nodes.find((n) => n.givenName === hostname);
if (!lookupNode) {
throw data(`Node with hostname ${hostname} not found`, 404);
}
// Last thing is keeping track of the ephemeral node in the database
// because Headscale doesn't automatically delete ephemeral nodes???
const [_ephemeralNode] = await context.db
.insert(ephemeralNodes)
.values({
auth_key: preAuthKey.key,
} satisfies EphemeralNodeInsert)
.returning();
return {
ipnDetails: {
PreAuthKey: preAuthKey.key,
Hostname: generateHostname(username),
ControlURL: u,
},
sshDetails: {
username,
hostname,
},
};
}
function generateHostname(username: string) {
const adjective = faker.word.adjective({
length: {
min: 3,
max: 6,
},
});
const noun = faker.word.noun({
length: {
min: 3,
max: 6,
},
});
return `ssh-${adjective}-${noun}-${username}`;
}
export async function action({ request, context }: Route.ActionArgs) {
await context.auth.require(request);
if (!context.agents) {
throw data("WebSSH is only available with the Headplane agent integration", 400);
}
const form = await request.formData();
const nodeKey = form.get("node_key");
const authKey = form.get("auth_key");
if (nodeKey === null || typeof nodeKey !== "string") {
throw data("Missing node_key", 400);
}
if (authKey === null || typeof authKey !== "string") {
throw data("Missing auth_key", 400);
}
await context.db
.update(ephemeralNodes)
.set({
node_key: nodeKey,
})
.where(eq(ephemeralNodes.auth_key, authKey));
context.agents?.triggerSync().catch((err) => {
log.debug("agent", "Background agent sync failed: %s", err);
});
}
export const links: Route.LinksFunction = () => [
{
rel: "preload",
href: `${__PREFIX__}/hp_ssh.wasm`,
as: "fetch",
type: "application/wasm",
crossOrigin: "anonymous",
},
];
export const handle: ExternalScriptsHandle = {
scripts: [
{
src: `${__PREFIX__}/wasm_exec.js`,
crossOrigin: "anonymous",
preload: true,
},
],
};
export default function Page({ loaderData: { ipnDetails, sshDetails } }: Route.ComponentProps) {
const submit = useSubmit();
const { pause } = useLiveData();
const [ipn, setIpn] = useState<TsWasmNet | null>(null);
const [nodeKey, setNodeKey] = useState<string | null>(null);
useEffect(() => {
if (!ipnDetails) {
return;
}
pause();
const go = new Go(); // Go is defined by wasm_exec.js
WebAssembly.instantiateStreaming(fetch(`${__PREFIX__}/hp_ssh.wasm`), go.importObject).then(
(value) => {
go.run(value.instance);
const handle = TsWasmNet(ipnDetails, {
NotifyState: (state) => {
console.log("State changed:", state);
if (state === "Running") {
setIpn(handle);
}
},
NotifyNetMap: (netmap) => {
// Only set NodeKey if it is not already set and then
// also dispatch that to the backend to track the
// ephemeral node.
//
// We open an SSE connection to the backend
// so that when the connection is closed,
// the backend can delete the ephemeral node.
if (nodeKey === null) {
setNodeKey(netmap.NodeKey);
submit(
{
node_key: netmap.NodeKey,
auth_key: ipnDetails.PreAuthKey,
},
{ method: "POST" },
);
}
},
NotifyBrowseToURL: (url) => {
console.log("Browse to URL:", url);
},
NotifyPanicRecover: (message) => {
console.error("Panic recover:", message);
},
});
handle.Start();
},
);
}, []);
if (!sshDetails.username) {
return <UserPrompt hostname={sshDetails.hostname} />;
}
return (
<div className="h-screen w-screen bg-mist-900">
{ipn === null ? (
<div className="mx-auto flex h-screen items-center justify-center">
<Loader2 className="size-10 animate-spin text-mist-50" />
</div>
) : (
<div className="flex h-screen flex-col">
<XTerm hostname={sshDetails.hostname} ipn={ipn} username={sshDetails.username} />
</div>
)}
</div>
);
}
+77
View File
@@ -0,0 +1,77 @@
import { AlertCircle } from "lucide-react";
import Card from "~/components/card";
import Link from "~/components/link";
export const sshErrors = {
wasm_missing: {
title: "Browser SSH is not available",
message: "This version of Headplane was not built with browser SSH support.",
anchor: "#ssh-not-available",
},
agent_required: {
title: "Browser SSH requires the Headplane agent",
message: "Browser SSH is only available when the Headplane agent integration is enabled.",
anchor: "#agent-required",
},
oidc_required: {
title: "Browser SSH requires OIDC authentication",
message: "Browser SSH is only available when OIDC authentication is enabled.",
anchor: "#oidc-required",
},
node_not_found: (hostname: string) => ({
title: "Node not found",
message: `No node found with hostname ${hostname}.`,
anchor: "#node-not-found",
}),
user_not_linked: {
title: "User account not linked",
message:
"You'll need to link your user account to a Headscale user before you can use Browser SSH.",
anchor: "#user-not-linked",
},
} as const;
interface SSHErrorBoundaryProps {
title: string;
message: string;
anchor: string;
}
export function isSSHError(error: unknown): error is SSHErrorBoundaryProps {
return (
typeof error === "object" &&
error !== null &&
"title" in error &&
"message" in error &&
"anchor" in error &&
typeof error.title === "string" &&
typeof error.message === "string" &&
typeof error.anchor === "string"
);
}
const DOCS_BASE = "https://headplane.net/features/ssh";
export function SSHErrorBoundary({ title, message, anchor }: SSHErrorBoundaryProps) {
return (
<Card className="w-screen" variant="flat">
<div className="flex items-center justify-between gap-4">
<Card.Title>{title}</Card.Title>
<AlertCircle className="mb-2 h-6 w-6 text-red-500" />
</div>
<Card.Text>
{message}
<br />
<br />
<Link to={`${DOCS_BASE}${anchor}`} external styled>
Headplane SSH Documentation
</Link>{" "}
</Card.Text>
</Card>
);
}
+137
View File
@@ -0,0 +1,137 @@
import { useEffect, useRef } from "react";
import { Restty } from "restty";
import type { GhosttyTheme } from "restty";
import type { PtyTransport } from "restty/internal";
import type { HeadplaneSSH, TunnelSession } from "./wasm.client";
const FONT_BASE = `${__PREFIX__}/fonts`;
// Ghostty's default canvas background is rgb(20,23,26) — a dark gray, not black.
// Override it so the terminal matches the page and pane container backgrounds.
const HEADPLANE_THEME: GhosttyTheme = {
colors: {
background: { r: 0, g: 0, b: 0 },
foreground: { r: 235, g: 237, b: 242 },
palette: [],
},
raw: {},
};
function createSSHTransport(ssh: HeadplaneSSH, ipAddress: string, username: string): PtyTransport {
let session: TunnelSession | null = null;
return {
connect(options) {
session = ssh.openTunnel({
ipAddress,
username,
onData: (data) => options.callbacks.onData?.(data),
onConnect: () => options.callbacks.onConnect?.(),
onDisconnect: () => {
options.callbacks.onDisconnect?.();
session = null;
},
});
if (options.cols && options.rows) {
session.resize(options.cols, options.rows);
}
},
disconnect() {
session?.close();
session = null;
},
sendInput(data) {
session?.writeInput(data);
return session != null;
},
resize(cols, rows) {
session?.resize(cols, rows);
return session != null;
},
isConnected() {
return session != null;
},
destroy() {
session?.close();
session = null;
},
};
}
interface GhosttyProps {
ssh: HeadplaneSSH;
ipAddress: string;
username: string;
onConnected: () => void;
}
export default function Ghostty({ ssh, ipAddress, username, onConnected }: GhosttyProps) {
const divRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!divRef.current) return;
const transport = createSSHTransport(ssh, ipAddress, username);
const restty = new Restty({
root: divRef.current,
createInitialPane: true,
defaultContextMenu: false,
shortcuts: false,
searchUi: false,
paneStyles: {
inactivePaneOpacity: 1,
activePaneOpacity: 1,
},
appOptions: {
fontSize: 20,
ligatures: true,
fontPreset: "none",
fontSources: [
{
type: "url",
url: `${FONT_BASE}/JetBrainsMonoNLNerdFontMono-Regular.ttf`,
label: "JetBrains Mono Nerd Font",
},
{
type: "url",
url: `${FONT_BASE}/JetBrainsMonoNLNerdFontMono-Bold.ttf`,
label: "JetBrains Mono Nerd Font Bold",
},
{
type: "url",
url: `${FONT_BASE}/JetBrainsMonoNLNerdFontMono-Italic.ttf`,
label: "JetBrains Mono Nerd Font Italic",
},
{
type: "url",
url: `${FONT_BASE}/JetBrainsMonoNLNerdFontMono-BoldItalic.ttf`,
label: "JetBrains Mono Nerd Font Bold Italic",
},
{
type: "url",
url: `${FONT_BASE}/SymbolsNerdFontMono-Regular.ttf`,
label: "Symbols Nerd Font",
},
],
ptyTransport: transport,
callbacks: {
onPtyStatus: (status) => {
if (status === "connected") onConnected();
},
},
},
});
restty.applyTheme(HEADPLANE_THEME);
restty.updateSize(true);
restty.connectPty();
return () => {
restty.destroy();
};
}, [ssh, ipAddress, username]);
return <div className="min-h-0 min-w-0 flex-1 overflow-hidden bg-black" ref={divRef} />;
}
-57
View File
@@ -1,57 +0,0 @@
declare function TsWasmNet(
options: TsWasmNetOptions,
callbacks: TsWasmNetCallbacks,
): TsWasmNet;
interface TsWasmNetOptions {
ControlURL: string;
PreAuthKey: string;
Hostname: string;
}
interface TsWasmNetCallbacks {
NotifyState: (state: IPNState) => void;
NotifyNetMap: (netmap: TsWasmNetMap) => void;
NotifyBrowseToURL: (url: string) => void;
NotifyPanicRecover: (err: string) => void;
}
interface TsWasmNetMap {
NodeKey: string;
}
interface TsWasmNet {
Start: () => void;
OpenSSH: (
hostname: string,
username: string,
options: XtermConfig,
) => SSHSession;
}
type IPNState =
| 'NoState'
| 'InUseOtherUser'
| 'NeedsLogin'
| 'NeedsMachineAuth'
| 'Stopped'
| 'Starting'
| 'Running';
interface XtermConfig {
rows: number;
cols: number;
timeout?: number;
onStdout: (data: Uint8Array) => void;
onStderr: (data: Uint8Array) => void;
onStdin: (func: (input: Uint8Array) => void) => void;
onConnect: () => void;
onDisconnect: () => void;
}
interface SSHSession {
Close(): boolean;
Resize(rows: number, cols: number): boolean;
}
+229
View File
@@ -0,0 +1,229 @@
import { Loader2, WifiOff } from "lucide-react";
import { useEffect, useState } from "react";
import { data, isRouteErrorResponse, type ShouldRevalidateFunction } from "react-router";
import Button from "~/components/button";
import Card from "~/components/card";
import Code from "~/components/code";
import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity";
import type { Route } from "./+types/page";
import { isSSHError, SSHErrorBoundary, sshErrors } from "./errors";
import Ghostty from "./ghostty.client";
import UserPrompt from "./user-prompt";
import type { HeadplaneSSH } from "./wasm.client";
import { loadHeadplaneWASM } from "./wasm.client";
const WASM_MODULE_URL = `${__PREFIX__}/hp_ssh.wasm`;
const WASM_HELPER_URL = `${__PREFIX__}/wasm_exec.js`;
export const shouldRevalidate: ShouldRevalidateFunction = () => {
return false;
};
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[] = [];
for (const file of assets) {
const res = await fetch(`${origin}${file}`, { method: "HEAD" });
if (!res.ok) {
missing.push(file);
}
}
if (missing.length > 0) {
throw data(sshErrors.wasm_missing, 405);
}
if (context.agents == null) {
throw data(sshErrors.agent_required, 400);
}
const principal = await context.auth.require(request);
if (principal.kind === "api_key") {
throw data(sshErrors.oidc_required, 403);
}
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
const hostname = params.id;
const username = new URL(request.url).searchParams.get("user") || undefined;
const nodes = await api.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 };
}
if (!username) {
return { hostname, username: undefined, offline: false, node: undefined };
}
// The user must exist within Headscale to generate a pre-auth key
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.createPreAuthKey(
hsUser.id,
true,
false,
new Date(Date.now() + 60 * 1000), // 1 minute expiry
null,
);
const controlURL = context.config.headscale.public_url ?? context.config.headscale.url;
return {
hostname,
username,
offline: false,
node: {
ipAddress: node.ipAddresses[0],
controlURL,
preAuthKey: preAuthKey.key,
ephemeralHostname: generateHostname(username),
},
};
}
function generateHostname(username: string) {
const hex = crypto.randomUUID().replace(/-/g, "").slice(0, 8);
return `ssh-${hex}-${username}`;
}
export const links: Route.LinksFunction = () => [
{
rel: "preload",
href: WASM_MODULE_URL,
as: "fetch",
type: "application/wasm",
crossOrigin: "anonymous",
},
];
export default function Page({ loaderData }: Route.ComponentProps) {
const { hostname, username, offline, node } = loaderData;
if (offline) {
return (
<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 <UserPrompt hostname={hostname} />;
}
return <SSHConsole hostname={hostname} username={username} node={node} />;
}
function SSHConsole({
hostname,
username,
node,
}: {
hostname: string;
username: string;
node: { ipAddress: string; controlURL: string; preAuthKey: string; ephemeralHostname: string };
}) {
const [ssh, setSsh] = useState<HeadplaneSSH | null>(null);
const [connected, setConnected] = useState(false);
const [status, setStatus] = useState("Starting tunnel…");
useEffect(() => {
let cancelled = false;
console.log("[ssh] Loading WASM factory");
loadHeadplaneWASM().then((create) => {
console.log("[ssh] Factory loaded, creating IPN", create);
if (cancelled) {
return;
}
setStatus("Joining Tailnet…");
const instance = create({
controlURL: node.controlURL,
preAuthKey: node.preAuthKey,
hostname: node.ephemeralHostname,
onReady: () => {
console.log("[ssh] IPN ready (Running)");
if (!cancelled) {
setStatus(`Connecting to ${hostname}`);
setSsh(instance);
}
},
onError: (msg) => console.error("[ssh] IPN error:", msg),
});
console.log("[ssh] IPN instance created", instance);
});
return () => {
cancelled = true;
};
}, [node]);
return (
<div className="fixed inset-0 flex flex-col bg-black">
{!connected && (
<div className="absolute inset-0 z-50 flex items-center justify-center">
<div className="flex flex-col items-center gap-3">
<Loader2 className="size-8 animate-spin text-mist-200" />
<p className="text-sm text-mist-400">{status}</p>
</div>
</div>
)}
{ssh && (
<Ghostty
ssh={ssh}
username={username}
ipAddress={node.ipAddress}
onConnected={() => setConnected(true)}
/>
)}
</div>
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
const routeError = isRouteErrorResponse(error) ? error.data : null;
if (routeError == null || !isSSHError(routeError)) {
// Pass through further down the tree to the global error boundary
throw error;
}
return (
<div className="flex h-screen w-screen items-center justify-center">
<SSHErrorBoundary
title={routeError.title}
message={routeError.message}
anchor={routeError.anchor}
/>
</div>
);
}
+40 -21
View File
@@ -1,17 +1,16 @@
import { useState } from "react";
import { Form } from "react-router";
import Button from "~/components/button";
import Card from "~/components/card";
import Code from "~/components/code";
import Input from "~/components/input";
import Link from "~/components/link";
interface UserPromptProps {
hostname: string;
}
export default function UserPrompt({ hostname }: UserPromptProps) {
const [username, setUsername] = useState("");
return (
<div className="flex h-screen items-center justify-center">
<Card>
@@ -19,27 +18,47 @@ export default function UserPrompt({ hostname }: UserPromptProps) {
<Card.Text className="mb-4">
Enter the username you want to use to connect to <Code>{hostname}</Code>
{". "}
WebSSH follows the Headscale ACLs, so only permitted usernames will be able to connect.
SSH via the web follows the same ACL rules as regular SSH access in Headscale, so only
permitted usernames will work.
<br />
<br />
See the{" "}
<Link external styled to="https://headplane.net/features/ssh#troubleshooting">
troubleshooting guide
</Link>{" "}
for common errors.
</Card.Text>
<Input
labelHidden
type="text"
label="Username"
placeholder="Username"
className="mb-2"
onChange={setUsername}
/>
<Button
variant="heavy"
className="w-full"
onClick={() => {
// We can't use the navigate hook here as we need to do a
// full page reload to ensure the SSH connection is established
window.location.href = `${__PREFIX__}/ssh?hostname=${hostname}&username=${username}`;
<Form
method="GET"
onSubmit={(e) => {
const formData = new FormData(e.currentTarget);
const username = formData.get("user");
if (!username) {
e.preventDefault();
return;
}
// We have to do a full navigation, since the page needs a full
// reload to initialize the SSH connection due to us disabling the
// revalidator.
const url = new URL(window.location.href);
url.searchParams.set("user", username.toString());
window.location.assign(url.toString());
}}
>
Connect
</Button>
<Input
labelHidden
type="text"
label="Username"
name="user"
placeholder="Username"
className="mb-2"
required
/>
<Button type="submit" variant="heavy" className="w-full">
Connect
</Button>
</Form>
</Card>
</div>
);
+82
View File
@@ -0,0 +1,82 @@
const WASM_MODULE_URL = `${__PREFIX__}/hp_ssh.wasm`;
const WASM_HELPER_URL = `${__PREFIX__}/wasm_exec.js`;
declare global {
type HeadplaneSSHFactory = (config: HeadplaneSSHConfig) => HeadplaneSSH;
var __hp_ssh_resolve: ((factory: HeadplaneSSHFactory) => void) | undefined;
var Go: {
new (): {
importObject: WebAssembly.Imports;
run(instance: WebAssembly.Instance): Promise<void>;
argv?: string[];
env?: Record<string, string>;
exit?: (code: number) => void;
};
};
}
interface HeadplaneSSHConfig {
controlURL: string;
preAuthKey: string;
hostname: string;
onReady: () => void;
onError?: (message: string) => void;
}
export interface HeadplaneSSH {
openTunnel(config: TunnelConfig): TunnelSession;
}
interface TunnelConfig {
ipAddress: string;
username: string;
timeout?: number;
onData: (data: string) => void;
onConnect: () => void;
onDisconnect: () => void;
}
export interface TunnelSession {
writeInput(data: string): void;
resize(cols: number, rows: number): void;
close(): void;
}
let resolvedFactory: Promise<HeadplaneSSHFactory> | null = null;
function loadGoHelper(): Promise<void> {
if (typeof globalThis.Go !== "undefined") {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = WASM_HELPER_URL;
script.crossOrigin = "anonymous";
script.onload = () => resolve();
script.onerror = () => reject(new Error("Failed to load Go WASM helper"));
document.head.appendChild(script);
});
}
/**
* One-shot function that loads the Go WASM binary and returns the SSH factory.
* Automatically loads the Go JS helper if it hasn't been loaded yet.
*/
export async function loadHeadplaneWASM(): Promise<HeadplaneSSHFactory> {
if (!resolvedFactory) {
await loadGoHelper();
const go = new Go();
const result = await WebAssembly.instantiateStreaming(fetch(WASM_MODULE_URL), go.importObject);
resolvedFactory = new Promise<HeadplaneSSHFactory>((resolve) => {
globalThis.__hp_ssh_resolve = resolve;
});
go.run(result.instance);
}
return resolvedFactory;
}
-7
View File
@@ -1,7 +0,0 @@
declare class Go {
importObject: WebAssembly.Imports;
run(instance: WebAssembly.Instance): Promise<void>;
argv?: string[];
env?: Record<string, string>;
exit?: (code: number) => void;
}
-218
View File
@@ -1,218 +0,0 @@
import { ClipboardAddon } from "@xterm/addon-clipboard";
import { FitAddon } from "@xterm/addon-fit";
import { Unicode11Addon } from "@xterm/addon-unicode11";
import { WebLinksAddon } from "@xterm/addon-web-links";
import * as xterm from "@xterm/xterm";
import { Loader2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import cn from "~/utils/cn";
import { useLiveData } from "~/utils/live-data";
import toast from "~/utils/toast";
import "@xterm/xterm/css/xterm.css";
interface XTermProps {
ipn: TsWasmNet;
username: string;
hostname: string;
}
// Go's WASM -> JS crosses realms so we might have to normalize the data under
// certain conditions. This also enforces bytes instead of strings being sent.
function normU8(data: unknown) {
if (data instanceof Uint8Array) {
return data;
}
if (data && typeof data === "object") {
const any = data as {
buffer?: ArrayBufferLike;
byteOffset?: number;
byteLength?: number;
};
if (any.buffer instanceof ArrayBuffer && typeof any.byteLength === "number") {
return new Uint8Array(
any.buffer.slice(any.byteOffset ?? 0, (any.byteOffset ?? 0) + any.byteLength),
);
}
}
throw new Error("Data is not a Uint8Array or ArrayBuffer-like object");
}
export default function XTerm({ ipn, username, hostname }: XTermProps) {
const { pause } = useLiveData();
const genRef = useRef(0);
const termRef = useRef<xterm.Terminal>(null);
const roRef = useRef<ResizeObserver>(null);
const inputRef = useRef<(input: Uint8Array) => void>(null);
const sshRef = useRef<SSHSession>(null);
const divRef = useRef<HTMLDivElement>(null);
const [isResizing, setIsResizing] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
pause();
});
useEffect(() => {
if (!divRef.current) {
return;
}
const currentGen = ++genRef.current;
const term = new xterm.Terminal({
allowProposedApi: true,
cursorBlink: true,
convertEol: true,
fontSize: 14,
});
const fit = new FitAddon();
term.loadAddon(fit);
term.loadAddon(new Unicode11Addon());
term.loadAddon(new ClipboardAddon());
term.loadAddon(
new WebLinksAddon((event, uri) => {
event.view?.open(uri, "_blank", "noopener noreferrer");
}),
);
term.unicode.activeVersion = "11";
termRef.current = term;
term.open(divRef.current!);
fit.fit();
term.focus();
const session = ipn.OpenSSH(hostname, username, {
rows: term.rows,
cols: term.cols,
onStdout: (data) => {
if (currentGen !== genRef.current || term !== termRef.current) {
console.warn("Stale terminal instance, ignoring stdout");
return;
}
const text = normU8(data);
term.write(text);
},
onStderr: (data) => {
if (currentGen !== genRef.current || term !== termRef.current) {
console.warn("Stale terminal instance, ignoring stderr");
return;
}
const text = normU8(data);
term.write(text);
const str = new TextDecoder().decode(text);
setError(str);
},
onStdin: (func) => {
inputRef.current = func;
},
onConnect: () => {
if (currentGen !== genRef.current) {
console.warn("Stale terminal instance, ignoring onConnect");
return;
}
setIsLoading(false);
},
onDisconnect: () => {
if (currentGen !== genRef.current) {
console.warn("Stale terminal instance, ignoring onDisconnect");
return;
}
roRef.current?.disconnect();
term.dispose();
termRef.current = null;
inputRef.current = null;
sshRef.current = null;
setIsLoading(false);
},
});
sshRef.current = session;
const enc = new TextEncoder();
term.onData((data) => {
if (currentGen !== genRef.current) {
console.warn("Stale terminal instance, ignoring onData");
return;
}
const bytes = enc.encode(data);
inputRef.current?.(bytes);
});
const ro = new ResizeObserver(() => {
if (currentGen !== genRef.current || term !== termRef.current) {
console.warn("Stale terminal instance, ignoring resize");
return;
}
setIsResizing(true);
fit.fit();
sshRef.current?.Resize(term.cols, term.rows);
setTimeout(() => setIsResizing(false), 100);
});
roRef.current = ro;
ro.observe(divRef.current!);
return () => {
++genRef.current;
roRef.current?.disconnect();
roRef.current = null;
sshRef.current?.Close();
sshRef.current = null;
term.dispose();
if (termRef.current === term) {
termRef.current = null;
}
inputRef.current = null;
};
}, [ipn, username, hostname]);
return (
<>
{isLoading ? (
<div className="absolute z-50 mx-auto flex h-screen w-screen items-center justify-center">
<Loader2 className="size-10 animate-spin text-mist-50" />
</div>
) : undefined}
<div className={cn("w-full h-full", isLoading ? "opacity-0" : "opacity-100")} ref={divRef} />
{termRef.current && isResizing ? (
<div
className={cn(
"absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2",
"px-4 py-2 bg-mist-800 text-white rounded-full shadow z-50",
)}
>
{termRef.current.cols}x{termRef.current.rows}
</div>
) : undefined}
{error !== null ? (
<div
className={cn(
"absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-center",
"px-4 py-2 bg-mist-800 text-white rounded-full shadow z-50",
)}
>
Failed to connect to SSH session
{error}
</div>
) : undefined}
</>
);
}
@@ -30,8 +30,8 @@ export default function HeadplaneUserRow({
const displayEmail = user.linkedHeadscaleUser?.email ?? user.email;
return (
<tr className="group hover:bg-mist-50 dark:hover:bg-mist-950" key={user.id}>
<td className="py-2 pl-0.5">
<tr className="group hover:bg-mist-100 dark:hover:bg-mist-800" key={user.id}>
<td className="py-2 pl-2">
<div className="flex items-center">
{user.profilePicUrl ? (
<img alt={displayName} className="h-10 w-10 rounded-full" src={user.profilePicUrl} />
+1 -1
View File
@@ -54,7 +54,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
let apiError: string | undefined;
try {
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
const [nodesSnap, usersSnap] = await Promise.all([
context.hsLive.get(nodesResource, api),
+1 -1
View File
@@ -24,7 +24,7 @@ export async function userAction({ request, context }: Route.ActionArgs) {
});
}
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
switch (action) {
case "create_user": {
+34
View File
@@ -0,0 +1,34 @@
import { data, redirect } from "react-router";
import { isValidColorScheme, setColorScheme } from "~/utils/color-scheme";
import type { Route } from "./+types/color-scheme";
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const colorScheme = formData.get("colorScheme");
const returnTo = safeRedirect(formData.get("returnTo"));
if (!colorScheme || !isValidColorScheme(colorScheme)) {
throw data("Bad Request", { status: 400 });
}
return redirect(returnTo, {
headers: {
"Set-Cookie": await setColorScheme(colorScheme),
},
});
}
// Stolen from react-router thanks!
function safeRedirect(to: FormDataEntryValue | null) {
if (!to || typeof to !== "string") {
return "/";
}
if (!to.startsWith("/") || to.startsWith("//")) {
return "/";
}
return to;
}
+10 -10
View File
@@ -1,14 +1,14 @@
import type { Route } from './+types/healthz';
import type { Route } from "./+types/healthz";
export async function loader({ context }: Route.LoaderArgs) {
// Use a fake API key for healthcheck
const api = context.hsApi.getRuntimeClient('fake-api-key');
const healthy = await api.isHealthy();
// 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,
headers: {
'Content-Type': 'application/json',
},
});
return new Response(JSON.stringify({ status: healthy ? "OK" : "ERROR" }), {
status: healthy ? 200 : 500,
headers: {
"Content-Type": "application/json",
},
});
}
+53 -51
View File
@@ -1,59 +1,61 @@
import { versions } from 'node:process';
import { data } from 'react-router';
import type { Route } from './+types/info';
import { versions } from "node:process";
import { data } from "react-router";
import type { Route } from "./+types/info";
export async function loader({ request, context }: Route.LoaderArgs) {
if (context.config.server.info_secret == null) {
throw data(
{
status: 'Forbidden',
},
403,
);
}
if (context.config.server.info_secret == null) {
throw data(
{
status: "Forbidden",
},
403,
);
}
const bearer = request.headers.get('Authorization') ?? '';
if (!bearer.startsWith('Bearer ')) {
throw data(
{
status: 'Unauthorized',
},
401,
);
}
const bearer = request.headers.get("Authorization") ?? "";
if (!bearer.startsWith("Bearer ")) {
throw data(
{
status: "Unauthorized",
},
401,
);
}
const token = bearer.slice('Bearer '.length).trim();
if (token !== context.config.server.info_secret) {
throw data(
{
status: 'Forbidden',
},
403,
);
}
const token = bearer.slice("Bearer ".length).trim();
if (token !== context.config.server.info_secret) {
throw data(
{
status: "Forbidden",
},
403,
);
}
// Use a fake API key for healthcheck
const api = context.hsApi.getRuntimeClient('fake-api-key');
const healthy = await api.isHealthy();
// Use a fake API key for healthcheck
const api = context.hsApi.getRuntimeClient("fake-api-key");
const healthy = await api.isHealthy();
const body = {
status: healthy ? 'healthy' : 'unhealthy',
headplane_version: __VERSION__,
headscale_canonical_version: healthy ? context.hsApi.apiVersion : 'unknown',
internal_versions: {
node: versions.node,
v8: versions.v8,
uv: versions.uv,
zlib: versions.zlib,
openssl: versions.openssl,
libc: versions.libc,
},
};
const body = {
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,
},
};
return new Response(JSON.stringify(body), {
status: 200,
headers: {
'Content-Type': 'application/json',
},
});
return new Response(JSON.stringify(body), {
status: 200,
headers: {
"Content-Type": "application/json",
},
});
}
+1 -1
View File
@@ -5,7 +5,7 @@ import type { Route } from "./+types/live";
export async function loader({ request, context }: Route.LoaderArgs) {
const principal = await context.auth.require(request);
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
// Ensure resources are loaded before streaming
+2 -2
View File
@@ -1,5 +1,5 @@
import { redirect } from 'react-router';
import { redirect } from "react-router";
export async function loader() {
return redirect('/machines');
return redirect("/machines");
}
+111
View File
@@ -0,0 +1,111 @@
# `app/server/`
Server-side application code for Headplane. Everything in this directory
runs only on the Node process — never in the browser.
## Layout
```
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 AppLoadContext
├── result.ts ← Result<T, E> helper used across the server modules
├── config/ ← YAML config loading, schema, env-overrides, integrations
├── db/ ← Drizzle SQLite client + schema
├── headscale/ ← Headscale REST API client + headscale-config loader
├── oidc/ ← OIDC provider abstraction
├── web/ ← Authentication service, identity, RBAC capabilities
└── hp-agent.ts ← Headplane agent process manager
```
## Entry points
There are two SSR entries; both are picked up by Vite via `vite.config.ts`.
### `app.ts` — the application module
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
handle the basename redirect — that's the runtime's job (see
[`runtime/`](../../runtime/)).
Consumed by:
- [`main.ts`](./main.ts) in production builds
- [`runtime/vite-plugin.ts`](../../runtime/vite-plugin.ts) in `react-router dev`
### `main.ts` — the production bootstrap
The SSR build input. Rollup bundles this file into
`build/server/index.js`. It:
1. imports the listener + config from [`app.ts`](./app.ts)
2. wraps the listener with `composeListener` from
[`runtime/http.ts`](../../runtime/http.ts) — adds `/admin → /admin/`
redirect and serves `build/client/` as static assets with immutable
caching for the `assets/` subdirectory
3. binds an http(s) server with `startHttpServer`
Run with `node /app/build/server/index.js` (this is what the Dockerfile
does). TLS is a one-line addition: pass `tls: { key, cert }` to
`startHttpServer`.
## Application context
[`context.ts`](./context.ts) exposes `createAppContext(config)`, which
constructs everything that needs to live for the lifetime of the
process:
- the SQLite client (`db`)
- the Headscale REST interface (`hsApi`)
- the optional Headplane agent manager (`agents`)
- the auth service (`auth`)
- the optional OIDC service (`oidc`)
- the live store (`hsLive`)
- the (best-effort) parsed Headscale config (`hs`)
- the integration adapter (`integration`)
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 the type, import it from `~/server/context`:
```ts
import type { AppContext } from "~/server/context";
export async function loader({ context }: LoaderFunctionArgs<AppContext>) {
// …
}
```
## Dev vs. prod
| Concern | Dev (`react-router dev`) | Prod (`node build/server/index.js`) |
| ---------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------ |
| HTTP server | Vite owns it (`vite.config.ts` `server.host/port`) | `runtime/http.ts` `startHttpServer` |
| Static assets | `./public` (served by `runtime/vite-plugin.ts`) | `build/client/` (served by `runtime/http.ts` static handler) |
| Basename redirect (`/admin``/admin/`) | `runtime/vite-plugin.ts` via `composeListener` | `main.ts` via `composeListener` |
| App load (HMR) | `ssrLoadModule(app.ts)` per request | bundled into `build/server/index.js` |
| Entry point | [`app.ts`](./app.ts) | [`main.ts`](./main.ts) |
There is **no** `if (import.meta.env.PROD)` branch in [`app.ts`](./app.ts)
or [`main.ts`](./main.ts) — the dev/prod split is expressed by which
file is loaded, not by runtime conditionals.
## Adding a new server-side module
1. Create the module under an existing subdirectory (or add a new one
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 — 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.
+50
View File
@@ -0,0 +1,50 @@
// MARK: Headplane Application
//
// Loads configuration, builds the per-process app context, and exports
// the React Router request listener as the default export.
//
// This module is consumed in two places:
// - `app/server/main.ts` — the production bootstrap; wraps the
// listener with static-asset serving and binds an http(s) server.
// - `runtime/vite-plugin.ts` — the dev-mode Vite middleware; loads
// this module through `ssrLoadModule` and dispatches each request.
import { exit, versions } from "node:process";
import { createRequestListener } from "@react-router/node";
import * as build from "virtual:react-router/server-build";
import log from "~/utils/log";
import type { HeadplaneConfig } from "./config/config-schema";
import { ConfigError } from "./config/error";
import { loadConfig } from "./config/load";
import { createAppContext } from "./context";
log.info("server", "Running Node.js %s", versions.node);
let config: HeadplaneConfig;
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 ctx = await createAppContext(config);
ctx.auth.start();
export { config };
// 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.
export default createRequestListener({
build,
mode: import.meta.env.MODE,
getLoadContext: () => ctx,
});
+27
View File
@@ -14,6 +14,23 @@ export const pathSupportedKeys = [
"oidc.headscale_api_key",
] as const;
function normalizeStringArray(values: string[]): string[] {
const seen = new Set<string>();
const normalized: string[] = [];
for (const value of values) {
const trimmed = value.trim();
if (trimmed.length === 0 || seen.has(trimmed)) {
continue;
}
seen.add(trimmed);
normalized.push(trimmed);
}
return normalized;
}
const serverConfig = type({
host: 'string.ip = "0.0.0.0"',
port: "number.integer = 3000",
@@ -98,12 +115,17 @@ const oidcConfig = type({
.optional(),
disable_api_key_login: "boolean = false",
scope: 'string = "openid email profile"',
subject_claims: type("string[]").pipe(normalizeStringArray).optional(),
allow_weak_rsa_keys: "boolean = false",
profile_picture_source: '"oidc" | "gravatar" = "oidc"',
extra_params: "Record<string, string>?",
authorization_endpoint: "string.url?",
token_endpoint: "string.url?",
userinfo_endpoint: "string.url?",
end_session_endpoint: "string.url?",
post_logout_redirect_uri: "string.url?",
use_end_session: "boolean = false",
token_endpoint_auth_method: '"client_secret_basic" | "client_secret_post" | "client_secret_jwt"?',
// Old/deprecated options
@@ -120,12 +142,17 @@ const partialOidcConfig = type({
redirect_uri: "string.url?",
disable_api_key_login: "boolean?",
scope: "string?",
subject_claims: type("string[]").pipe(normalizeStringArray).optional(),
allow_weak_rsa_keys: "boolean?",
extra_params: "Record<string, string>?",
profile_picture_source: '"oidc" | "gravatar"?',
authorization_endpoint: "string.url?",
token_endpoint: "string.url?",
userinfo_endpoint: "string.url?",
end_session_endpoint: "string.url?",
post_logout_redirect_uri: "string.url?",
use_end_session: "boolean?",
token_endpoint_auth_method: '"client_secret_basic" | "client_secret_post" | "client_secret_jwt"?',
// Old/deprecated options
+54 -56
View File
@@ -1,74 +1,72 @@
interface ErrorCodes {
CONFLICTING_SECRET_PATH_FIELD: {
fieldName: string;
};
CONFLICTING_SECRET_PATH_FIELD: {
fieldName: string;
};
INVALID_REQUIRED_FIELDS: {
messages: string[];
};
INVALID_REQUIRED_FIELDS: {
messages: string[];
};
MISSING_INTERPOLATION_VARIABLE: {
pathKey: string;
variableName: string;
};
MISSING_INTERPOLATION_VARIABLE: {
pathKey: string;
variableName: string;
};
MISSING_SECRET_FILE: {
pathKey: string;
filePath: string;
};
MISSING_SECRET_FILE: {
pathKey: string;
filePath: string;
};
}
const translationsWithVars: {
[K in keyof ErrorCodes]: (vars: ErrorCodes[K]) => string;
[K in keyof ErrorCodes]: (vars: ErrorCodes[K]) => string;
} = {
CONFLICTING_SECRET_PATH_FIELD: ({ fieldName }) =>
`Both "${fieldName}" and "${fieldName}_path" are set; please provide only one of these fields.`,
INVALID_REQUIRED_FIELDS: ({ messages }) =>
`The configuration is missing required fields or has invalid values:\n- ${messages.join('\n- ')}`,
MISSING_INTERPOLATION_VARIABLE: ({ pathKey, variableName }) =>
`Could not resolve environment variable "${variableName}" for configuration key "${pathKey}".`,
CONFLICTING_SECRET_PATH_FIELD: ({ fieldName }) =>
`Both "${fieldName}" and "${fieldName}_path" are set; please provide only one of these fields.`,
INVALID_REQUIRED_FIELDS: ({ messages }) =>
`The configuration is missing required fields or has invalid values:\n- ${messages.join("\n- ")}`,
MISSING_INTERPOLATION_VARIABLE: ({ pathKey, variableName }) =>
`Could not resolve environment variable "${variableName}" for configuration key "${pathKey}".`,
MISSING_SECRET_FILE: ({ pathKey, filePath }) =>
`The secret file specified in "${pathKey}" could not be accessed at path "${filePath}". Please ensure the file exists and is readable.`,
MISSING_SECRET_FILE: ({ pathKey, filePath }) =>
`The secret file specified in "${pathKey}" could not be accessed at path "${filePath}". Please ensure the file exists and is readable.`,
} as const;
/**
* Custom error class for configuration-related errors.
*/
export class ConfigError extends Error {
/**
* The error code representing the type of configuration error.
*/
code: keyof ErrorCodes;
/**
* The error code representing the type of configuration error.
*/
code: keyof ErrorCodes;
/**
* Creates a new ConfigError instance.
*
* @param code The error code
* @param vars The variables to interpolate into the error message
*/
constructor(code: keyof ErrorCodes, vars: unknown) {
super(
translationsWithVars[code](
vars as (typeof translationsWithVars)[typeof code] extends (
vars: infer U,
) => string
? U
: never,
),
);
this.code = code;
this.name = 'ConfigError';
}
/**
* Creates a new ConfigError instance.
*
* @param code The error code
* @param vars The variables to interpolate into the error message
*/
constructor(code: keyof ErrorCodes, vars: unknown) {
super(
translationsWithVars[code](
vars as (typeof translationsWithVars)[typeof code] extends (vars: infer U) => string
? U
: never,
),
);
this.code = code;
this.name = "ConfigError";
}
/**
* Factory method to create a ConfigError instance.
*
* @param code The error code
* @param vars The variables to interpolate into the error message
* @returns A new ConfigError instance
*/
static from<K extends keyof ErrorCodes>(code: K, vars: ErrorCodes[K]) {
return new ConfigError(code, vars);
}
/**
* Factory method to create a ConfigError instance.
*
* @param code The error code
* @param vars The variables to interpolate into the error message
* @returns A new ConfigError instance
*/
static from<K extends keyof ErrorCodes>(code: K, vars: ErrorCodes[K]) {
return new ConfigError(code, vars);
}
}
+11 -11
View File
@@ -1,16 +1,16 @@
import type { RuntimeApiClient } from '~/server/headscale/api/endpoints';
import type { RuntimeApiClient } from "~/server/headscale/api/endpoints";
export abstract class Integration<T> {
protected context: NonNullable<T>;
constructor(context: T) {
if (!context) {
throw new Error('Missing integration context');
}
protected context: NonNullable<T>;
constructor(context: T) {
if (!context) {
throw new Error("Missing integration context");
}
this.context = context;
}
this.context = context;
}
abstract isAvailable(): Promise<boolean> | boolean;
abstract onConfigChange(client: RuntimeApiClient): Promise<void> | void;
abstract get name(): string;
abstract isAvailable(): Promise<boolean> | boolean;
abstract onConfigChange(client: RuntimeApiClient): Promise<void> | void;
abstract get name(): string;
}
+47 -51
View File
@@ -1,62 +1,58 @@
import log from '~/utils/log';
import type { HeadplaneConfig } from '../config-schema';
import dockerIntegration from './docker';
import kubernetesIntegration from './kubernetes';
import procIntegration from './proc';
import log from "~/utils/log";
export async function loadIntegration(context: HeadplaneConfig['integration']) {
const integration = getIntegration(context);
if (!integration) {
return;
}
import type { HeadplaneConfig } from "../config-schema";
import dockerIntegration from "./docker";
import kubernetesIntegration from "./kubernetes";
import procIntegration from "./proc";
try {
const res = await integration.isAvailable();
if (!res) {
log.error('config', 'Integration %s is not available', integration.name);
return;
}
} catch (error) {
log.error(
'config',
'Failed to load integration %s: %s',
integration,
error,
);
log.debug('config', 'Loading error: %o', error);
return;
}
export async function loadIntegration(context: HeadplaneConfig["integration"]) {
const integration = getIntegration(context);
if (!integration) {
return;
}
return integration;
try {
const res = await integration.isAvailable();
if (!res) {
log.error("config", "Integration %s is not available", integration.name);
return;
}
} catch (error) {
log.error("config", "Failed to load integration %s: %s", integration, error);
log.debug("config", "Loading error: %o", error);
return;
}
return integration;
}
function getIntegration(integration: HeadplaneConfig['integration']) {
const docker = integration?.docker;
const k8s = integration?.kubernetes;
const proc = integration?.proc;
function getIntegration(integration: HeadplaneConfig["integration"]) {
const docker = integration?.docker;
const k8s = integration?.kubernetes;
const proc = integration?.proc;
if (!docker?.enabled && !k8s?.enabled && !proc?.enabled) {
log.debug('config', 'No integrations enabled');
return;
}
if (!docker?.enabled && !k8s?.enabled && !proc?.enabled) {
log.debug("config", "No integrations enabled");
return;
}
if (docker?.enabled && k8s?.enabled && proc?.enabled) {
log.error('config', 'Multiple integrations enabled, please pick one only');
return;
}
if (docker?.enabled && k8s?.enabled && proc?.enabled) {
log.error("config", "Multiple integrations enabled, please pick one only");
return;
}
if (docker?.enabled) {
log.info('config', 'Using Docker integration');
return new dockerIntegration(integration!.docker!);
}
if (docker?.enabled) {
log.info("config", "Using Docker integration");
return new dockerIntegration(integration!.docker!);
}
if (k8s?.enabled) {
log.info('config', 'Using Kubernetes integration');
return new kubernetesIntegration(integration!.kubernetes!);
}
if (k8s?.enabled) {
log.info("config", "Using Kubernetes integration");
return new kubernetesIntegration(integration!.kubernetes!);
}
if (proc?.enabled) {
log.info('config', 'Using Proc integration');
return new procIntegration(integration!.proc!);
}
if (proc?.enabled) {
log.info("config", "Using Proc integration");
return new procIntegration(integration!.proc!);
}
}
+3 -3
View File
@@ -1,11 +1,11 @@
import { CoreV1Api, KubeConfig } from "@kubernetes/client-node";
import { type } from "arktype";
import { readdir, readFile } from "node:fs/promises";
import { platform } from "node:os";
import { join } from "node:path";
import type { RuntimeApiClient } from "~/server/headscale/api/endpoints";
import { CoreV1Api, KubeConfig } from "@kubernetes/client-node";
import { type } from "arktype";
import type { RuntimeApiClient } from "~/server/headscale/api/endpoints";
import log from "~/utils/log";
import { Integration } from "./abstract";
@@ -4,7 +4,6 @@ import { kill } from "node:process";
import { setTimeout } from "node:timers/promises";
import type { RuntimeApiClient } from "~/server/headscale/api/endpoints";
import log from "~/utils/log";
/**
+2 -2
View File
@@ -1,8 +1,8 @@
import { type } from "arktype";
import { platform } from "node:os";
import type { RuntimeApiClient } from "~/server/headscale/api/endpoints";
import { type } from "arktype";
import type { RuntimeApiClient } from "~/server/headscale/api/endpoints";
import log from "~/utils/log";
import { Integration } from "./abstract";
+149 -154
View File
@@ -1,14 +1,17 @@
import { access, constants, readFile } from 'node:fs/promises';
import { type } from 'arktype';
import { load } from 'js-yaml';
import log from '~/utils/log';
import { access, constants, readFile } from "node:fs/promises";
import { type } from "arktype";
import { load } from "js-yaml";
import log from "~/utils/log";
import {
headplaneConfig,
PartialHeadplaneConfig,
partialHeadplaneConfig,
pathSupportedKeys,
} from './config-schema';
import { ConfigError } from './error';
headplaneConfig,
PartialHeadplaneConfig,
partialHeadplaneConfig,
pathSupportedKeys,
} from "./config-schema";
import { ConfigError } from "./error";
/**
* Main entrypoint that attempts to load and merge configuration from both
@@ -25,27 +28,27 @@ import { ConfigError } from './error';
* @throws {Error} If there are validation errors in the final configuration
*/
export async function loadConfig(configPathOverride?: string) {
const configPath =
configPathOverride != null
? configPathOverride
: process.env.HEADPLANE_CONFIG_PATH != null
? String(process.env.HEADPLANE_CONFIG_PATH)
: '/etc/headplane/config.yaml';
const configPath =
configPathOverride != null
? configPathOverride
: process.env.HEADPLANE_CONFIG_PATH != null
? String(process.env.HEADPLANE_CONFIG_PATH)
: "/etc/headplane/config.yaml";
const fileConfig = await loadConfigFile(configPath);
const envConfig = await loadConfigEnv();
const fileConfig = await loadConfigFile(configPath);
const envConfig = await loadConfigEnv();
const combinedConfig = deepMerge(fileConfig, envConfig);
await loadConfigKeyPaths(combinedConfig);
const combinedConfig = deepMerge(fileConfig, envConfig);
await loadConfigKeyPaths(combinedConfig);
const finalConfig = headplaneConfig(combinedConfig);
if (finalConfig instanceof type.errors) {
throw ConfigError.from('INVALID_REQUIRED_FIELDS', {
messages: finalConfig.map((e) => e.toString()),
});
}
const finalConfig = headplaneConfig(combinedConfig);
if (finalConfig instanceof type.errors) {
throw ConfigError.from("INVALID_REQUIRED_FIELDS", {
messages: finalConfig.map((e) => e.toString()),
});
}
return finalConfig;
return finalConfig;
}
/**
@@ -57,23 +60,23 @@ export async function loadConfig(configPathOverride?: string) {
* @throws {Error} If there are validation errors in the loaded configuration
*/
export async function loadConfigFile(path: string) {
try {
await access(path, constants.R_OK);
} catch {
log.info('config', 'Could not access config file at path: %s', path);
return;
}
try {
await access(path, constants.R_OK);
} catch {
log.info("config", "Could not access config file at path: %s", path);
return;
}
const rawBuffer = await readFile(path, 'utf8');
const rawConfig = load(rawBuffer);
const config = partialHeadplaneConfig(rawConfig);
if (config instanceof type.errors) {
throw ConfigError.from('INVALID_REQUIRED_FIELDS', {
messages: config.map((e) => e.toString()),
});
}
const rawBuffer = await readFile(path, "utf8");
const rawConfig = load(rawBuffer);
const config = partialHeadplaneConfig(rawConfig);
if (config instanceof type.errors) {
throw ConfigError.from("INVALID_REQUIRED_FIELDS", {
messages: config.map((e) => e.toString()),
});
}
return config;
return config;
}
/**
@@ -86,36 +89,36 @@ export async function loadConfigFile(path: string) {
* @throws {Error} If there are validation errors in the loaded configuration
*/
export async function loadConfigEnv() {
if (process.env.HEADPLANE_LOAD_ENV_OVERRIDES != null) {
log.warn(
'config',
'HEADPLANE_LOAD_ENV_OVERRIDES is deprecated and will be removed in future versions',
);
log.warn(
'config',
'Environment variables are always loaded and `.env` files are no longer supported',
);
}
if (process.env.HEADPLANE_LOAD_ENV_OVERRIDES != null) {
log.warn(
"config",
"HEADPLANE_LOAD_ENV_OVERRIDES is deprecated and will be removed in future versions",
);
log.warn(
"config",
"Environment variables are always loaded and `.env` files are no longer supported",
);
}
const rawConfig: Record<string, unknown> = {};
for (const [key, value] of Object.entries(process.env)) {
if (value == null || !key.startsWith('HEADPLANE_')) {
continue;
}
const rawConfig: Record<string, unknown> = {};
for (const [key, value] of Object.entries(process.env)) {
if (value == null || !key.startsWith("HEADPLANE_")) {
continue;
}
const parsedValue = parseEnvValue(value);
const configKey = key.slice('HEADPLANE_'.length).toLowerCase();
deepSet(rawConfig, configKey.split('__'), parsedValue);
}
const parsedValue = parseEnvValue(value);
const configKey = key.slice("HEADPLANE_".length).toLowerCase();
deepSet(rawConfig, configKey.split("__"), parsedValue);
}
const config = partialHeadplaneConfig(rawConfig);
if (config instanceof type.errors) {
throw ConfigError.from('INVALID_REQUIRED_FIELDS', {
messages: config.map((e) => e.toString()),
});
}
const config = partialHeadplaneConfig(rawConfig);
if (config instanceof type.errors) {
throw ConfigError.from("INVALID_REQUIRED_FIELDS", {
messages: config.map((e) => e.toString()),
});
}
return Object.keys(config).length > 0 ? config : undefined;
return Object.keys(config).length > 0 ? config : undefined;
}
/**
@@ -126,29 +129,25 @@ export async function loadConfigEnv() {
* @returns The merged object
*/
function deepMerge<T>(...objects: (T | undefined)[]): T {
const result: { [key: string]: unknown } = {};
for (const obj of objects.filter((o) => o != null)) {
for (const [key, value] of Object.entries(
obj as {
[key: string]: unknown;
},
)) {
if (value != null && typeof value === 'object' && !Array.isArray(value)) {
if (
result[key] == null ||
typeof result[key] !== 'object' ||
Array.isArray(result[key])
) {
result[key] = {};
}
result[key] = deepMerge(result[key], value);
} else {
result[key] = value;
}
}
}
const result: { [key: string]: unknown } = {};
for (const obj of objects.filter((o) => o != null)) {
for (const [key, value] of Object.entries(
obj as {
[key: string]: unknown;
},
)) {
if (value != null && typeof value === "object" && !Array.isArray(value)) {
if (result[key] == null || typeof result[key] !== "object" || Array.isArray(result[key])) {
result[key] = {};
}
result[key] = deepMerge(result[key], value);
} else {
result[key] = value;
}
}
}
return result as T;
return result as T;
}
/**
@@ -158,22 +157,18 @@ function deepMerge<T>(...objects: (T | undefined)[]): T {
* @param path An array of keys representing the path to set
* @param value The value to set at the specified path
*/
function deepSet(
obj: { [key: string]: unknown },
path: string[],
value: unknown,
): void {
let current = obj;
for (let i = 0; i < path.length - 1; i++) {
const key = path[i];
if (current[key] == null || typeof current[key] !== 'object') {
current[key] = {};
}
function deepSet(obj: { [key: string]: unknown }, path: string[], value: unknown): void {
let current = obj;
for (let i = 0; i < path.length - 1; i++) {
const key = path[i];
if (current[key] == null || typeof current[key] !== "object") {
current[key] = {};
}
current = current[key] as { [key: string]: unknown };
}
current = current[key] as { [key: string]: unknown };
}
current[path[path.length - 1]] = value;
current[path[path.length - 1]] = value;
}
/**
@@ -184,18 +179,18 @@ function deepSet(
* @returns The parsed value
*/
function parseEnvValue(value: string): unknown {
const v = value.trim().toLowerCase();
if (v === 'true') return true;
if (v === 'false') return false;
if (v === 'null') return null;
if (v === 'undefined') return undefined;
const v = value.trim().toLowerCase();
if (v === "true") return true;
if (v === "false") return false;
if (v === "null") return null;
if (v === "undefined") return undefined;
if (/^-?\d+(\.\d+)?$/.test(v)) {
const num = Number(v);
if (!Number.isNaN(num)) return num;
}
if (/^-?\d+(\.\d+)?$/.test(v)) {
const num = Number(v);
if (!Number.isNaN(num)) return num;
}
return value;
return value;
}
/**
@@ -207,43 +202,43 @@ function parseEnvValue(value: string): unknown {
* @param partial The partial configuration object to update
*/
export async function loadConfigKeyPaths(partial: PartialHeadplaneConfig) {
for (const key of pathSupportedKeys) {
const pathKey = `${key}_path`;
const pathValue = deepGet(partial, pathKey.split('.'));
const existing = deepGet(partial, key.split('.'));
for (const key of pathSupportedKeys) {
const pathKey = `${key}_path`;
const pathValue = deepGet(partial, pathKey.split("."));
const existing = deepGet(partial, key.split("."));
if (pathValue == null || typeof pathValue !== 'string') {
continue;
}
if (pathValue == null || typeof pathValue !== "string") {
continue;
}
if (existing != null) {
throw ConfigError.from('CONFLICTING_SECRET_PATH_FIELD', {
fieldName: key,
});
}
if (existing != null) {
throw ConfigError.from("CONFLICTING_SECRET_PATH_FIELD", {
fieldName: key,
});
}
const realPath = pathValue.replace(/\$\{([^}]+)\}/g, (_, variableName) => {
const value = process.env[variableName];
if (value === undefined) {
throw ConfigError.from('MISSING_INTERPOLATION_VARIABLE', {
pathKey: `${key}_path`,
variableName: variableName,
});
}
const realPath = pathValue.replace(/\$\{([^}]+)\}/g, (_, variableName) => {
const value = process.env[variableName];
if (value === undefined) {
throw ConfigError.from("MISSING_INTERPOLATION_VARIABLE", {
pathKey: `${key}_path`,
variableName: variableName,
});
}
return value;
});
return value;
});
try {
const fileContent = await readFile(realPath, 'utf8');
deepSet(partial, key.split('.'), fileContent.trim().normalize());
} catch {
throw ConfigError.from('MISSING_SECRET_FILE', {
pathKey: `${key}_path`,
filePath: realPath,
});
}
}
try {
const fileContent = await readFile(realPath, "utf8");
deepSet(partial, key.split("."), fileContent.trim().normalize());
} catch {
throw ConfigError.from("MISSING_SECRET_FILE", {
pathKey: `${key}_path`,
filePath: realPath,
});
}
}
}
/**
@@ -254,14 +249,14 @@ export async function loadConfigKeyPaths(partial: PartialHeadplaneConfig) {
* @returns The value at the specified path or undefined if not found
*/
function deepGet(obj: { [key: string]: unknown }, path: string[]): unknown {
let current = obj;
for (const segment of path) {
if (current == null || typeof current !== 'object') {
return undefined;
}
let current = obj;
for (const segment of path) {
if (current == null || typeof current !== "object") {
return undefined;
}
current = current[segment] as { [key: string]: unknown };
}
current = current[segment] as { [key: string]: unknown };
}
return current;
return current;
}
+7 -6
View File
@@ -1,9 +1,10 @@
import type { Traversal } from 'arktype';
import log from '~/utils/log';
import type { Traversal } from "arktype";
import log from "~/utils/log";
export function deprecatedField() {
return (_: unknown, ctx: Traversal) => {
log.warn('config', `${ctx.propString} is deprecated and has no effect.`);
return true;
};
return (_: unknown, ctx: Traversal) => {
log.warn("config", `${ctx.propString} is deprecated and has no effect.`);
return true;
};
}
+113
View File
@@ -0,0 +1,113 @@
import { join } from "node:path";
import log from "~/utils/log";
import type { HeadplaneConfig } from "./config/config-schema";
import { loadIntegration } from "./config/integration";
import { createDbClient } from "./db/client.server";
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 { createAgentManager } from "./hp-agent";
import { createOidcService } from "./oidc/provider";
import { createAuthService } from "./web/auth";
export type AppContext = Awaited<ReturnType<typeof createAppContext>>;
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 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;
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,
db,
cookie: {
name: "_hp_auth",
secure: config.server.cookie_secure,
maxAge: config.server.cookie_max_age,
domain: config.server.cookie_domain,
},
});
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]);
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,
hsApi,
headscaleApiKey,
agents,
auth,
oidc,
hsLive,
hs: await loadHeadscaleConfig(
config.headscale.config_path,
config.headscale.config_strict,
config.headscale.dns_records_path,
),
integration: await loadIntegration(config.integration),
};
}
-53
View File
@@ -1,53 +0,0 @@
import { eq, isNotNull } from "drizzle-orm";
import { nodesResource } from "~/server/headscale/live-store";
import log from "~/utils/log";
import type { Route } from "../../layout/+types/app";
import { ephemeralNodes } from "./schema";
export async function pruneEphemeralNodes({ context, request }: Route.LoaderArgs) {
const principal = await context.auth.require(request);
const ephemerals = await context.db
.select()
.from(ephemeralNodes)
.where(isNotNull(ephemeralNodes.node_key));
if (ephemerals.length === 0) {
log.debug("api", "No ephemeral nodes to prune");
return;
}
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const api = context.hsApi.getRuntimeClient(apiKey);
const nodes = await api.getNodes();
const toPrune = nodes.filter((node) => {
if (node.online) {
return false;
}
return ephemerals.some((ephemeral) => node.nodeKey === ephemeral.node_key);
});
if (toPrune.length === 0) {
log.debug("api", "No SSH nodes to prune");
return;
}
// Delete from the Headscale nodes list and then from the database
const promises = toPrune.map((node) => {
return async () => {
log.debug("api", `Pruning node ${node.name}`);
await api.deleteNode(node.id);
await context.db.delete(ephemeralNodes).where(eq(ephemeralNodes.node_key, node.nodeKey));
log.debug("api", `Node ${node.name} pruned successfully`);
};
});
await Promise.all(promises.map((p) => p()));
if (toPrune.length > 0) {
await context.hsLive.refresh(nodesResource, api);
}
}
+1 -8
View File
@@ -2,14 +2,6 @@ import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { HostInfo } from "~/types";
export const ephemeralNodes = sqliteTable("ephemeral_nodes", {
auth_key: text("auth_key").primaryKey(),
node_key: text("node_key"),
});
export type EphemeralNode = typeof ephemeralNodes.$inferSelect;
export type EphemeralNodeInsert = typeof ephemeralNodes.$inferInsert;
export const hostInfo = sqliteTable("host_info", {
host_id: text("host_id").primaryKey(),
payload: text("payload", { mode: "json" }).$type<HostInfo>(),
@@ -44,6 +36,7 @@ export const authSessions = sqliteTable("auth_sessions", {
user_id: text("user_id"),
api_key_hash: text("api_key_hash"),
api_key_display: text("api_key_display"),
oidc_id_token: text("oidc_id_token"),
expires_at: integer("expires_at", { mode: "timestamp" }).notNull(),
created_at: integer("created_at", { mode: "timestamp" }).$default(() => new Date()),
});
+378
View File
@@ -0,0 +1,378 @@
import {
createFateServer,
createLiveEventBus,
dataView,
FateRequestError,
list,
resolveSourceById,
type Entity,
type SourceDefinition,
type SourceRegistry,
} from "@nkzw/fate/server";
import type { Context } from "hono";
import type { Machine as HeadscaleMachine, User as HeadscaleUser } from "~/types";
import type { AppContext } from "./context";
import type { RuntimeApiClient } from "./headscale/api/endpoints";
import { isConnectionError, isDataWithApiError } from "./headscale/api/error-client";
import { nodesResource } from "./headscale/live-store";
import type { Principal } from "./web/auth";
import { Capabilities } from "./web/roles";
export interface HonoFateEnv {
Variables: {
appContext: AppContext;
};
}
interface FateContext {
api: RuntimeApiClient;
app: AppContext;
principal: Principal;
request: Request;
}
type FateAdapterContext = Context<HonoFateEnv>;
export const live = createLiveEventBus();
type UserRecord = HeadscaleUser & Record<string, unknown>;
type MachineRecord = Omit<HeadscaleMachine, "user"> & {
user?: UserRecord;
} & Record<string, unknown>;
export const UserDataView = dataView<UserRecord>("User")({
createdAt: true,
displayName: true,
email: true,
id: true,
name: true,
profilePicUrl: true,
provider: true,
providerId: true,
});
export const MachineDataView = dataView<MachineRecord>("Machine")({
approvedRoutes: true,
availableRoutes: true,
createdAt: true,
discoKey: true,
expiry: true,
givenName: true,
id: true,
ipAddresses: true,
lastSeen: true,
machineKey: true,
name: true,
nodeKey: true,
online: true,
registerMethod: true,
subnetRoutes: true,
tags: true,
user: UserDataView,
});
export type User = Entity<typeof UserDataView, "User">;
export type Machine = Entity<
typeof MachineDataView,
"Machine",
{
user?: User;
}
>;
export type FateUser = User;
export type FateMachine = Machine;
const userSource = {
id: "id",
view: UserDataView,
} satisfies SourceDefinition<UserRecord>;
const machineSource = {
id: "id",
view: MachineDataView,
} satisfies SourceDefinition<MachineRecord>;
const machineList = list(MachineDataView, { orderBy: { givenName: "asc" } });
const userList = list(UserDataView, { orderBy: { name: "asc" } });
export const Root = {
machines: machineList,
users: userList,
};
export const roots = Root;
const queries = {};
const lists = {};
function apiFailureToFateError(error: unknown, fallback: string): FateRequestError {
if (error instanceof FateRequestError) {
return error;
}
if (isDataWithApiError(error)) {
const status = error.data.statusCode;
if (status === 401) {
return new FateRequestError("UNAUTHORIZED", "Headscale rejected the current API key.");
}
if (status === 403) {
return new FateRequestError("FORBIDDEN", "Headscale refused this operation.");
}
if (status === 404) {
return new FateRequestError("NOT_FOUND", "The requested Headscale resource was not found.");
}
return new FateRequestError(
"INTERNAL_ERROR",
`Headscale API request failed with status ${status}.`,
{ status: 502 },
);
}
const data = error && typeof error === "object" && "data" in error ? error.data : undefined;
if (isConnectionError(data)) {
return new FateRequestError(
"INTERNAL_ERROR",
`Unable to reach Headscale: ${data.errorMessage}`,
{ status: 502 },
);
}
return new FateRequestError("INTERNAL_ERROR", fallback);
}
interface RenameMachineInput {
id: string;
name: string;
}
const renameMachineInput = {
parse(input: unknown): RenameMachineInput {
if (!input || typeof input !== "object") {
throw new FateRequestError("VALIDATION_ERROR", "Machine rename input is required.");
}
const id = (input as Record<string, unknown>).id;
const name = (input as Record<string, unknown>).name;
if (typeof id !== "string" || id.trim() === "") {
throw new FateRequestError("VALIDATION_ERROR", "Machine ID is required.");
}
if (typeof name !== "string" || name.trim() === "") {
throw new FateRequestError("VALIDATION_ERROR", "Machine name is required.");
}
return {
id,
name: name.trim(),
};
},
};
const mutations = {
"machine.rename": {
input: renameMachineInput,
resolve: async ({
ctx,
input,
select,
}: {
ctx: FateContext;
input: RenameMachineInput;
select: Array<string>;
}): Promise<Machine | null> => {
let node;
try {
node = await ctx.api.getNode(input.id);
} catch (error) {
throw apiFailureToFateError(error, "Unable to load this machine.");
}
if (!ctx.app.auth.canManageNode(ctx.principal, node)) {
throw new FateRequestError(
"FORBIDDEN",
"You do not have permission to rename this machine.",
);
}
try {
await ctx.api.renameNode(input.id, input.name);
} catch (error) {
throw apiFailureToFateError(error, "Unable to rename this machine.");
}
void ctx.app.hsLive.refresh(nodesResource, ctx.api).catch(() => undefined);
const eventId = `machine:${input.id}:rename:${Date.now()}`;
live.update("Machine", input.id, { changed: ["givenName", "name"], eventId });
live.connection("machines").invalidate({ eventId });
return (await resolveSourceById({
ctx,
id: input.id,
input: { select },
registry,
source: machineSource,
})) as Machine | null;
},
type: "Machine",
},
};
type SourceByIdsOptions = {
ctx: FateContext;
ids: Array<string>;
};
type SourceConnectionOptions = {
ctx: FateContext;
cursor?: string;
direction: "backward" | "forward";
take: number;
};
function requireCapability(ctx: FateContext, capability: Capabilities) {
if (!ctx.app.auth.can(ctx.principal, capability)) {
throw new FateRequestError("FORBIDDEN", "You do not have permission to view this data.");
}
}
function compareText(a: string | undefined, b: string | undefined) {
return (a ?? "").localeCompare(b ?? "", undefined, { numeric: true, sensitivity: "base" });
}
function byRequestedId<T extends { id: string }>(items: Array<T>, ids: Array<string>) {
const byId = new Map(items.map((item) => [item.id, item]));
return ids.flatMap((id) => {
const item = byId.get(id);
return item ? [item] : [];
});
}
function pageByCursor<T extends { id: string }>(
items: Array<T>,
{ cursor, direction, take }: Omit<SourceConnectionOptions, "ctx">,
) {
if (!cursor) {
return direction === "backward"
? items.slice(Math.max(0, items.length - take))
: items.slice(0, take);
}
const cursorIndex = items.findIndex((item) => item.id === cursor);
if (cursorIndex < 0) {
return direction === "backward"
? items.slice(Math.max(0, items.length - take))
: items.slice(0, take);
}
if (direction === "backward") {
return items.slice(Math.max(0, cursorIndex - take), cursorIndex);
}
return items.slice(cursorIndex + 1, cursorIndex + 1 + take);
}
async function getMachineRecords(ctx: FateContext): Promise<Array<MachineRecord>> {
requireCapability(ctx, Capabilities.read_machines);
try {
return (await ctx.api.getNodes())
.map((machine) => machine as MachineRecord)
.sort(
(a, b) =>
compareText(a.givenName || a.name, b.givenName || b.name) || compareText(a.id, b.id),
);
} catch (error) {
throw apiFailureToFateError(error, "Unable to load machines from Headscale.");
}
}
async function getUserRecords(ctx: FateContext): Promise<Array<UserRecord>> {
requireCapability(ctx, Capabilities.read_users);
try {
return (await ctx.api.getUsers())
.map((user) => user as UserRecord)
.sort((a, b) => compareText(a.name, b.name) || compareText(a.id, b.id));
} catch (error) {
throw apiFailureToFateError(error, "Unable to load users from Headscale.");
}
}
const registry = new Map() as SourceRegistry<FateContext>;
registry.set(machineSource as SourceDefinition, {
byIds: async ({ ctx, ids }: SourceByIdsOptions) =>
byRequestedId(await getMachineRecords(ctx), ids),
connection: async ({ ctx, cursor, direction, take }: SourceConnectionOptions) =>
pageByCursor(await getMachineRecords(ctx), { cursor, direction, take }),
});
registry.set(userSource as SourceDefinition, {
byIds: async ({ ctx, ids }: SourceByIdsOptions) => byRequestedId(await getUserRecords(ctx), ids),
connection: async ({ ctx, cursor, direction, take }: SourceConnectionOptions) =>
pageByCursor(await getUserRecords(ctx), { cursor, direction, take }),
});
const sourceByView = new Map<unknown, SourceDefinition>([
[MachineDataView, machineSource as SourceDefinition],
[machineList, machineSource as SourceDefinition],
[UserDataView, userSource as SourceDefinition],
[userList, userSource as SourceDefinition],
]);
function isSourceDefinition(target: unknown): target is SourceDefinition {
return target !== null && typeof target === "object" && "view" in target;
}
export const fate = createFateServer<
FateContext,
typeof roots,
typeof queries,
typeof lists,
typeof mutations,
FateAdapterContext
>({
context: async ({ adapterContext, request }) => {
if (!adapterContext) {
throw new Error("Fate request is missing Hono context");
}
const app = adapterContext.get("appContext");
let principal;
try {
principal = await app.auth.require(request);
} catch {
throw new FateRequestError("UNAUTHORIZED", "Authentication required.");
}
const api = app.hsApi.getRuntimeClient(app.auth.getHeadscaleApiKey(principal));
return {
api,
app,
principal,
request,
};
},
live,
mutations,
roots,
sources: {
registry,
getSource(target) {
if (isSourceDefinition(target)) {
return target;
}
const source = sourceByView.get(target);
if (!source) {
throw new Error("No Fate source registered for data view");
}
return source;
},
},
});
export type { FateAdapterContext, FateContext };
+13 -16
View File
@@ -1,23 +1,20 @@
import type { Key } from '~/types';
import { defineApiEndpoints } from '../factory';
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[]>;
/**
* 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,
);
getApiKeys: async () => {
const { apiKeys } = await client.apiFetch<{ apiKeys: Key[] }>("GET", "v1/apikey", apiKey);
return apiKeys;
},
return apiKeys;
},
}));
+42 -46
View File
@@ -1,56 +1,54 @@
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';
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>;
/**
* 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}`,
},
});
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;
}
},
}),
);
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,
apiKeyEndpoints,
healthcheckEndpoint,
nodeEndpoints,
policyEndpoints,
preAuthKeyEndpoints,
userEndpoints,
] as const;
/**
@@ -63,7 +61,7 @@ export const endpointSets = [
* passing in different internal implementations based on the OpenAPI spec.
*/
export type RuntimeApiClient = UnionToIntersection<
ExtractApiEndpoints<(typeof endpointSets)[number]>
ExtractApiEndpoints<(typeof endpointSets)[number]>
>;
/**
@@ -73,7 +71,5 @@ export type RuntimeApiClient = UnionToIntersection<
* @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);
export default (client: HeadscaleApiInterface["clientHelpers"], apiKey: string) =>
composeEndpoints(endpointSets, client, apiKey);
+5 -2
View File
@@ -1,7 +1,6 @@
import type { Machine } from "~/types";
import type { HeadscaleApiInterface } from "..";
import { defineApiEndpoints } from "../factory";
interface RawMachine extends Omit<Machine, "tags"> {
@@ -146,7 +145,11 @@ export default defineApiEndpoints<NodeEndpoints>((client, apiKey) => ({
},
renameNode: async (nodeId, newName) => {
await client.apiFetch<void>("POST", `v1/node/${nodeId}/rename/${newName}`, apiKey);
await client.apiFetch<void>(
"POST",
`v1/node/${nodeId}/rename/${encodeURIComponent(newName)}`,
apiKey,
);
},
setNodeTags: async (nodeId, tags) => {
+31 -31
View File
@@ -1,41 +1,41 @@
import { defineApiEndpoints } from '../factory';
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 }>;
/**
* 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 }>;
/**
* 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);
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,
};
},
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 });
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) };
},
return { policy: newPolicy, updatedAt: new Date(updatedAt) };
},
}));
@@ -34,7 +34,7 @@ export interface PreAuthKeyEndpoints {
* @param user The user associated with the pre-authentication key.
* @param key The pre-authentication key to expire.
*/
expirePreAuthKey(user: string, key: string): Promise<void>;
expirePreAuthKey(user: string, key: PreAuthKey): Promise<void>;
}
export default defineApiEndpoints<PreAuthKeyEndpoints>((client, apiKey) => ({
@@ -77,9 +77,17 @@ export default defineApiEndpoints<PreAuthKeyEndpoints>((client, apiKey) => ({
},
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.key,
});
},
}));
+71 -72
View File
@@ -1,88 +1,87 @@
import type { User } from '~/types';
import { defineApiEndpoints } from '../factory';
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[]>;
/**
* 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>;
/**
* 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>;
/**
* 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>;
/**
* 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;
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');
}
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 },
);
const { users } = await client.apiFetch<{ users: User[] }>("GET", "v1/user", apiKey, {
id,
name,
email,
});
return users;
},
return users;
},
createUser: async (username, email, displayName, pictureUrl) => {
const { user } = await client.apiFetch<{ user: User }>(
'POST',
'v1/user',
apiKey,
{ name: username, email, displayName, pictureUrl },
);
createUser: async (username, email, displayName, pictureUrl) => {
const { user } = await client.apiFetch<{ user: User }>("POST", "v1/user", apiKey, {
name: username,
email,
displayName,
pictureUrl,
});
return user;
},
return user;
},
deleteUser: async (id) => {
await client.apiFetch<void>('DELETE', `v1/user/${id}`, apiKey);
},
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/${newName}`,
apiKey,
);
},
renameUser: async (oldId, newName) => {
await client.apiFetch<void>(
"POST",
`v1/user/${oldId}/rename/${encodeURIComponent(newName)}`,
apiKey,
);
},
}));
+33 -42
View File
@@ -1,13 +1,13 @@
import type { HeadscaleConnectionError } from './error';
import type { HeadscaleConnectionError } from "./error";
/**
* Represents an error returned by the Headscale API.
*/
export interface HeadscaleAPIError {
requestUrl: `${string} ${string}`;
statusCode: number;
rawData: string;
data: Record<string, unknown> | null;
requestUrl: `${string} ${string}`;
statusCode: number;
rawData: string;
data: Record<string, unknown> | null;
}
/**
@@ -16,14 +16,14 @@ export interface HeadscaleAPIError {
* @returns True if the error is a HeadscaleAPIError, false otherwise.
*/
export function isApiError(error: unknown): error is HeadscaleAPIError {
return (
error != null &&
typeof error === 'object' &&
'requestUrl' in error &&
'statusCode' in error &&
'rawData' in error &&
'data' in error
);
return (
error != null &&
typeof error === "object" &&
"requestUrl" in error &&
"statusCode" in error &&
"rawData" in error &&
"data" in error
);
}
/**
@@ -31,17 +31,15 @@ export function isApiError(error: unknown): error is HeadscaleAPIError {
* @param error - The error to check.
* @returns True if the error is a HeadscaleConnectionError, false otherwise.
*/
export function isConnectionError(
error: unknown,
): error is HeadscaleConnectionError {
return (
error != null &&
typeof error === 'object' &&
'requestUrl' in error &&
'errorCode' in error &&
'errorMessage' in error &&
'extraData' in error
);
export function isConnectionError(error: unknown): error is HeadscaleConnectionError {
return (
error != null &&
typeof error === "object" &&
"requestUrl" in error &&
"errorCode" in error &&
"errorMessage" in error &&
"extraData" in error
);
}
/**
@@ -52,15 +50,15 @@ export function isConnectionError(
* @returns True if the error is a DataUnauthorizedError, false otherwise.
*/
export function isDataUnauthorizedError(error: unknown): boolean {
return (
error != null &&
typeof error === 'object' &&
'data' in error &&
typeof error.data === 'object' &&
error.data != null &&
'statusCode' in error.data &&
error.data.statusCode === 401
);
return (
error != null &&
typeof error === "object" &&
"data" in error &&
typeof error.data === "object" &&
error.data != null &&
"statusCode" in error.data &&
error.data.statusCode === 401
);
}
/**
@@ -72,13 +70,6 @@ export function isDataUnauthorizedError(error: unknown): boolean {
* @returns True if the error is a DataWithResponseInit containing a
* HeadscaleAPIError, false otherwise.
*/
export function isDataWithApiError(
error: unknown,
): error is { data: HeadscaleAPIError } {
return (
error != null &&
typeof error === 'object' &&
'data' in error &&
isApiError(error.data)
);
export function isDataWithApiError(error: unknown): error is { data: HeadscaleAPIError } {
return error != null && typeof error === "object" && "data" in error && isApiError(error.data);
}
+38 -43
View File
@@ -1,4 +1,4 @@
import { errors } from 'undici';
import { errors } from "undici";
/**
* Helper function that determines if an error is a Node.js exception
@@ -6,22 +6,17 @@ import { errors } from 'undici';
* @returns True if the error is a Node.js exception, false otherwise
*/
function isNodeNetworkError(error: unknown): error is NodeJS.ErrnoException {
return (
error != null &&
typeof error === 'object' &&
'code' in error &&
'errno' in error
);
return error != null && typeof error === "object" && "code" in error && "errno" in error;
}
/**
* A friendly error representation for Headscale connection issues.
*/
export interface HeadscaleConnectionError {
requestUrl: string;
errorCode: string;
errorMessage: string;
extraData: Record<string, unknown> | null;
requestUrl: string;
errorCode: string;
errorMessage: string;
extraData: Record<string, unknown> | null;
}
/**
@@ -33,40 +28,40 @@ export interface HeadscaleConnectionError {
* @returns A friendly HeadscaleAPIError.
*/
export function undiciToFriendlyError(
error: unknown,
requestUrl: string,
error: unknown,
requestUrl: string,
): HeadscaleConnectionError {
// MARK: Do we need to go deeper into causes here?
if (error instanceof AggregateError) {
error = error.errors[0];
}
// MARK: Do we need to go deeper into causes here?
if (error instanceof AggregateError) {
error = error.errors[0];
}
if (error instanceof errors.UndiciError) {
return {
requestUrl,
errorCode: error.code,
errorMessage: error.message,
extraData: null,
};
}
if (error instanceof errors.UndiciError) {
return {
requestUrl,
errorCode: error.code,
errorMessage: error.message,
extraData: null,
};
}
if (isNodeNetworkError(error)) {
return {
requestUrl,
errorCode: error.code ?? 'UNKNOWN_NODE_NETWORK_ERROR',
errorMessage: error.message,
extraData: {
syscall: error.syscall,
path: error.path,
errno: error.errno,
},
};
}
if (isNodeNetworkError(error)) {
return {
requestUrl,
errorCode: error.code ?? "UNKNOWN_NODE_NETWORK_ERROR",
errorMessage: error.message,
extraData: {
syscall: error.syscall,
path: error.path,
errno: error.errno,
},
};
}
return {
requestUrl,
errorCode: 'UNKNOWN_ERROR',
errorMessage: 'An unknown error occured',
extraData: null,
};
return {
requestUrl,
errorCode: "UNKNOWN_ERROR",
errorMessage: "An unknown error occured",
extraData: null,
};
}
+16 -23
View File
@@ -1,4 +1,4 @@
import type { HeadscaleApiInterface } from '../api';
import type { HeadscaleApiInterface } from "../api";
/**
* Creates a strongly-typed group factory for a given endpoint interface.
@@ -8,38 +8,31 @@ import type { HeadscaleApiInterface } from '../api';
*/
export interface EndpointFactory<T extends object> {
__type?: T;
(client: HeadscaleApiInterface['clientHelpers'], apiKey: string): T;
__type?: T;
(client: HeadscaleApiInterface["clientHelpers"], apiKey: string): T;
}
export function defineApiEndpoints<T extends object>(
factories: (
client: HeadscaleApiInterface['clientHelpers'],
apiKey: string,
) => T,
factories: (client: HeadscaleApiInterface["clientHelpers"], apiKey: string) => T,
): EndpointFactory<T> {
return factories;
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;
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,
factories: T,
clientHelpers: any,
apiKey: string,
): UnionToIntersection<ExtractApiEndpoints<T[number]>> {
const instances = factories.map((f) => f(clientHelpers, apiKey));
return Object.assign({}, ...instances) as any;
const instances = factories.map((f) => f(clientHelpers, apiKey));
return Object.assign({}, ...instances) as any;
}
+31 -32
View File
@@ -1,12 +1,13 @@
import { createHash } from 'node:crypto';
import { dereference } from '@readme/openapi-parser';
import { OpenAPIV2 } from 'openapi-types';
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;
[operationId: string]: string;
}
/**
@@ -17,36 +18,34 @@ export interface DocumentHash {
* @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>();
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;
}
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 { 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;
}
}
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;
return hashes;
}
+52 -69
View File
@@ -1,6 +1,6 @@
import canonicals from '~/openapi-canonical-families.json';
import hashes from '~/openapi-operation-hashes.json';
import log from '~/utils/log';
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.
@@ -15,80 +15,63 @@ const VERSIONS = Object.keys(hashes) as Version[];
* @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;
}
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;
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;
}
}
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 (!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,
);
}
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;
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,
);
if (!canonical) {
log.warn("api", "Could not canonicalize detected version %s, using as-is", bestVersion);
return bestVersion;
}
return bestVersion;
}
if (canonical !== bestVersion) {
log.info(
'api',
'Canonicalizing detected version %s → %s (same schema)',
bestVersion,
canonical,
);
}
if (canonical !== bestVersion) {
log.info(
"api",
"Canonicalizing detected version %s → %s (same schema)",
bestVersion,
canonical,
);
}
return canonical;
return canonical;
}
/**
@@ -99,5 +82,5 @@ export function detectApiVersion(
* @returns True if current is at least baseline, false otherwise.
*/
export function isAtLeast(current: Version, baseline: Version) {
return VERSIONS.indexOf(current) >= VERSIONS.indexOf(baseline);
return VERSIONS.indexOf(current) >= VERSIONS.indexOf(baseline);
}
+84 -92
View File
@@ -1,11 +1,12 @@
import { access, constants, readFile, writeFile } from 'node:fs/promises';
import { setTimeout } from 'node:timers/promises';
import log from '~/utils/log';
import { access, constants, readFile, writeFile } from "node:fs/promises";
import { setTimeout } from "node:timers/promises";
import log from "~/utils/log";
export interface DNSRecord {
type: 'A' | 'AAAA' | (string & {});
name: string;
value: string;
type: "A" | "AAAA" | (string & {});
name: string;
value: string;
}
// This class is solely for DNS records that are out of tree in the main
@@ -15,113 +16,104 @@ export interface DNSRecord {
// All DNS insertions and deletions are handled by the main config manager,
// but are passed through to here if the extra file is being used.
export class HeadscaleDNSConfig {
private records: DNSRecord[];
private access: 'rw' | 'ro' | 'no';
private path?: string;
private writeLock = false;
private records: DNSRecord[];
private access: "rw" | "ro" | "no";
private path?: string;
private writeLock = false;
constructor(
access: 'rw' | 'ro' | 'no',
records?: DNSRecord[],
path?: string,
) {
this.access = access;
this.records = records ?? [];
this.path = path;
}
constructor(access: "rw" | "ro" | "no", records?: DNSRecord[], path?: string) {
this.access = access;
this.records = records ?? [];
this.path = path;
}
readable() {
return this.access !== 'no';
}
readable() {
return this.access !== "no";
}
writable() {
return this.access === 'rw';
}
writable() {
return this.access === "rw";
}
get r() {
return this.records;
}
get r() {
return this.records;
}
async patch(records: DNSRecord[]) {
if (!this.path || !this.readable() || !this.writable()) {
return;
}
async patch(records: DNSRecord[]) {
if (!this.path || !this.readable() || !this.writable()) {
return;
}
this.records = records;
log.debug(
'config',
'Patching DNS records (%d -> %d)',
this.records.length,
records.length,
);
this.records = records;
log.debug("config", "Patching DNS records (%d -> %d)", this.records.length, records.length);
return this.write();
}
return this.write();
}
private async write() {
if (!this.path || !this.writable()) {
return;
}
private async write() {
if (!this.path || !this.writable()) {
return;
}
while (this.writeLock) {
await setTimeout(100);
}
while (this.writeLock) {
await setTimeout(100);
}
this.writeLock = true;
log.debug('config', 'Writing updated DNS configuration to %s', this.path);
const data = JSON.stringify(this.records, null, 4);
await writeFile(this.path, data);
this.writeLock = false;
}
this.writeLock = true;
log.debug("config", "Writing updated DNS configuration to %s", this.path);
const data = JSON.stringify(this.records, null, 4);
await writeFile(this.path, data);
this.writeLock = false;
}
}
export async function loadHeadscaleDNS(path?: string) {
if (!path) {
return;
}
if (!path) {
return;
}
log.debug('config', 'Loading Headscale DNS configuration file: %s', path);
const { w, r } = await validateConfigPath(path);
if (!r) {
return new HeadscaleDNSConfig('no');
}
log.debug("config", "Loading Headscale DNS configuration file: %s", path);
const { w, r } = await validateConfigPath(path);
if (!r) {
return new HeadscaleDNSConfig("no");
}
const records = await loadConfigFile(path);
if (!records) {
return new HeadscaleDNSConfig('no');
}
const records = await loadConfigFile(path);
if (!records) {
return new HeadscaleDNSConfig("no");
}
return new HeadscaleDNSConfig(w ? 'rw' : 'ro', records, path);
return new HeadscaleDNSConfig(w ? "rw" : "ro", records, path);
}
async function validateConfigPath(path: string) {
try {
await access(path, constants.F_OK | constants.R_OK);
log.info('config', 'Found a valid Headscale DNS file at %s', path);
} catch (error) {
log.error('config', 'Unable to read a Headscale DNS file at %s', path);
log.error('config', '%s', error);
return { w: false, r: false };
}
try {
await access(path, constants.F_OK | constants.R_OK);
log.info("config", "Found a valid Headscale DNS file at %s", path);
} catch (error) {
log.error("config", "Unable to read a Headscale DNS file at %s", path);
log.error("config", "%s", error);
return { w: false, r: false };
}
try {
await access(path, constants.F_OK | constants.W_OK);
return { w: true, r: true };
} catch (error) {
log.warn('config', 'Headscale DNS file at %s is not writable', path);
return { w: false, r: true };
}
try {
await access(path, constants.F_OK | constants.W_OK);
return { w: true, r: true };
} catch (error) {
log.warn("config", "Headscale DNS file at %s is not writable", path);
return { w: false, r: true };
}
}
async function loadConfigFile(path: string) {
log.debug('config', 'Reading Headscale DNS file at %s', path);
try {
const data = await readFile(path, 'utf8');
const records = JSON.parse(data) as DNSRecord[];
return records;
} catch (e) {
log.error('config', 'Error reading Headscale DNS file at %s', path);
log.error('config', '%s', e);
return false;
}
log.debug("config", "Reading Headscale DNS file at %s", path);
try {
const data = await readFile(path, "utf8");
const records = JSON.parse(data) as DNSRecord[];
return records;
} catch (e) {
log.error("config", "Error reading Headscale DNS file at %s", path);
log.error("config", "%s", e);
return false;
}
}
+295 -344
View File
@@ -1,422 +1,373 @@
import { constants, access, readFile, writeFile } from 'node:fs/promises';
import { exit } from 'node:process';
import { setTimeout } from 'node:timers/promises';
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';
import { constants, access, readFile, writeFile } from "node:fs/promises";
import { exit } from "node:process";
import { setTimeout } from "node:timers/promises";
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;
path: string;
value: unknown;
}
// 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;
private config?: typeof headscaleConfig.infer;
private document?: Document;
private access: "rw" | "ro" | "no";
private path?: string;
private writeLock = false;
private dns?: HeadscaleDNSConfig;
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;
}
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;
}
readable() {
return this.access !== 'no';
}
readable() {
return this.access !== "no";
}
writable() {
return this.access === 'rw';
}
writable() {
return this.access === "rw";
}
get c() {
return this.config;
}
get c() {
return this.config;
}
get d() {
if (this.dns) {
return this.dns.r;
}
get d() {
if (this.dns) {
return this.dns.r;
}
return this.config?.dns.extra_records ?? [];
}
return this.config?.dns.extra_records ?? [];
}
async patch(patches: PatchConfig[]) {
if (!this.path || !this.document || !this.readable() || !this.writable()) {
return;
}
async patch(patches: PatchConfig[]) {
if (!this.path || !this.document || !this.readable() || !this.writable()) {
return;
}
log.debug('config', 'Patching Headscale configuration');
for (const patch of patches) {
const { path, value } = patch;
log.debug('config', 'Patching %s with %o', path, value);
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.
// 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;
// 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;
}
for (const char of path) {
if (char === '"') {
quote = !quote;
}
if (char === '.' && !quote) {
key.push(current);
current = '';
continue;
}
if (char === "." && !quote) {
key.push(current);
current = "";
continue;
}
current += char;
}
current += char;
}
key.push(current.replaceAll('"', ''));
if (value === null) {
this.document.deleteIn(key);
continue;
}
key.push(current.replaceAll('"', ""));
if (value === null) {
this.document.deleteIn(key);
continue;
}
this.document.setIn(key, value);
}
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;
}
// 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,
);
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);
}
// 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;
}
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;
}
/**
* 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;
}
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]);
}
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;
}
// 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;
}
await this.patch([
{
path: 'dns.extra_records',
value: Array.from(new Set([...existing, record])),
},
]);
await this.patch([
{
path: "dns.extra_records",
value: Array.from(new Set([...existing, record])),
},
]);
return true;
}
return true;
}
/**
* 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.
/**
* 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.
if (this.dns) {
if (!this.dns.readable() || !this.dns.writable()) {
log.debug('config', 'DNS config is not writable');
return;
}
if (this.dns) {
if (!this.dns.readable() || !this.dns.writable()) {
log.debug("config", "DNS config is not writable");
return;
}
const records = this.dns.r.filter(
(i) => i.name !== record.name || i.type !== record.type,
);
const records = this.dns.r.filter((i) => i.name !== record.name || i.type !== record.type);
return this.dns.patch(records);
}
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 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;
}
// 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;
}
await this.patch([
{
path: 'dns.extra_records',
value: existing.filter(
(i) => i.name !== record.name || i.type !== record.type,
),
},
]);
await this.patch([
{
path: "dns.extra_records",
value: existing.filter((i) => i.name !== record.name || i.type !== record.type),
},
]);
return true;
}
return true;
}
}
export async function loadHeadscaleConfig(
path?: string,
strict = true,
dnsPath?: string,
) {
if (!path) {
log.debug('config', 'No Headscale configuration file was provided');
return new HeadscaleConfig('no');
}
export async function loadHeadscaleConfig(path?: string, strict = true, dnsPath?: string) {
if (!path) {
log.debug("config", "No Headscale configuration file was provided");
return new HeadscaleConfig("no");
}
log.debug('config', 'Loading Headscale configuration file: %s', path);
const { r, w } = await validateConfigPath(path);
if (!r) {
return new HeadscaleConfig('no');
}
log.debug("config", "Loading Headscale configuration file: %s", path);
const { r, w } = await validateConfigPath(path);
if (!r) {
return new HeadscaleConfig("no");
}
const document = await loadConfigFile(path);
if (!document) {
return new HeadscaleConfig('no');
}
const document = await loadConfigFile(path);
if (!document) {
return new HeadscaleConfig("no");
}
if (!strict) {
return new HeadscaleConfig(
w ? 'rw' : 'ro',
new HeadscaleDNSConfig('no'),
augmentUnstrictConfig(document.toJSON()),
document,
path,
);
}
if (!strict) {
return new HeadscaleConfig(
w ? "rw" : "ro",
new HeadscaleDNSConfig("no"),
augmentUnstrictConfig(document.toJSON()),
document,
path,
);
}
const config = validateConfig(document.toJSON());
if (!config) {
return new HeadscaleConfig('no');
}
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',
);
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');
}
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',
);
log.error(
'config',
'Please set `dns.extra_records_path` in the Headscale config',
);
log.error(
'config',
'Or remove `headscale.dns_records_path` from the Headplane config',
);
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",
);
log.error("config", "Please set `dns.extra_records_path` in the Headscale config");
log.error("config", "Or remove `headscale.dns_records_path` from the Headplane config");
exit(1);
}
exit(1);
}
return new HeadscaleConfig(w ? 'rw' : 'ro', dns, config, document, path);
return new HeadscaleConfig(w ? "rw" : "ro", dns, config, document, path);
}
async function validateConfigPath(path: string) {
try {
await access(path, constants.F_OK | constants.R_OK);
log.info(
'config',
'Found a valid Headscale configuration file at %s',
path,
);
} catch (error) {
log.error(
'config',
'Unable to read a Headscale configuration file at %s',
path,
);
log.error('config', '%s', error);
return { w: false, r: false };
}
try {
await access(path, constants.F_OK | constants.R_OK);
log.info("config", "Found a valid Headscale configuration file at %s", path);
} catch (error) {
log.error("config", "Unable to read a Headscale configuration file at %s", path);
log.error("config", "%s", error);
return { w: false, r: false };
}
try {
await access(path, constants.F_OK | constants.W_OK);
return { w: true, r: true };
} catch (error) {
log.warn(
'config',
'Headscale configuration file at %s is not writable',
path,
);
return { w: false, r: true };
}
try {
await access(path, constants.F_OK | constants.W_OK);
return { w: true, r: true };
} catch (error) {
log.warn("config", "Headscale configuration file at %s is not writable", path);
return { w: false, r: true };
}
}
async function loadConfigFile(path: string) {
log.debug('config', 'Reading Headscale configuration file at %s', path);
try {
const data = await readFile(path, 'utf8');
const configYaml = parseDocument(data);
if (configYaml.errors.length > 0) {
log.error(
'config',
'Cannot parse Headscale configuration file at %s',
path,
);
for (const error of configYaml.errors) {
log.error('config', ` - ${error.toString()}`);
}
log.debug("config", "Reading Headscale configuration file at %s", path);
try {
const data = await readFile(path, "utf8");
const configYaml = parseDocument(data);
if (configYaml.errors.length > 0) {
log.error("config", "Cannot parse Headscale configuration file at %s", path);
for (const error of configYaml.errors) {
log.error("config", ` - ${error.toString()}`);
}
return false;
}
return false;
}
return configYaml;
} catch (e) {
log.error(
'config',
'Error reading Headscale configuration file at %s',
path,
);
log.error('config', '%s', e);
return false;
}
return configYaml;
} catch (e) {
log.error("config", "Error reading Headscale configuration file at %s", path);
log.error("config", "%s", e);
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()}`);
}
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;
}
return result;
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.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',
},
log: loaded.log ?? {
level: "info",
format: "text",
},
logtail: loaded.logtail ?? {
enabled: false,
},
logtail: loaded.logtail ?? {
enabled: false,
},
prefixes: loaded.prefixes ?? {
allocation: 'sequential',
v4: '',
v6: '',
},
prefixes: loaded.prefixes ?? {
allocation: "sequential",
v4: "",
v6: "",
},
dns: loaded.dns ?? {
nameservers: {
global: [],
split: {},
},
search_domains: [],
extra_records: [],
magic_dns: false,
base_domain: 'headscale.net',
},
};
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',
);
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;
return config as typeof headscaleConfig.infer;
}
+127 -127
View File
@@ -1,150 +1,150 @@
import { type } from 'arktype';
import { type } from "arktype";
const goBool = type('boolean | "true" | "false"').pipe((v) => {
if (v === 'true') return true;
if (v === 'false') return false;
return v;
if (v === "true") return true;
if (v === "false") return false;
return v;
});
const goDuration = type('0 | string').pipe((v) => {
return v.toString();
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',
},
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',
},
});
.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'),
},
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,
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',
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' })),
log: type({
format: 'string = "text"',
level: 'string = "info"',
}).default(() => ({ format: "text", level: "info" })),
'policy?': {
mode: '"database" | "file" = "file"',
path: 'string?',
},
"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?',
},
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"',
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),
},
"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),
},
"logtail?": {
enabled: goBool.default(false),
},
randomize_client_port: goBool.default(false),
randomize_client_port: goBool.default(false),
});
+115
View File
@@ -0,0 +1,115 @@
import { versions } from "node:process";
import { serveStatic } from "@hono/node-server/serve-static";
import { createHonoFateHandler } from "@nkzw/fate/server";
import { Hono, type Context } from "hono";
import type { AppContext } from "./context";
import { fate, type HonoFateEnv } from "./fate";
interface HonoAppOptions {
context: AppContext;
prefix: string;
staticRoot?: string;
}
export function createHeadplaneHonoApp({ context, prefix, staticRoot }: HonoAppOptions) {
const app = new Hono<HonoFateEnv>();
const fateHandler = createHonoFateHandler(fate);
app.use("*", async (c, next) => {
c.set("appContext", context);
await next();
});
const health = async (c: Context) => {
const api = context.hsApi.getRuntimeClient("fake-api-key");
const healthy = await api.isHealthy();
return c.json({ status: healthy ? "OK" : "ERROR" }, healthy ? 200 : 500);
};
app.get("/healthz", health);
app.get(`${prefix}/healthz`, health);
app.get(`${prefix}/api/info`, async (c) => {
if (context.config.server.info_secret == null) {
return c.json({ status: "Forbidden" }, 403);
}
const bearer = c.req.header("Authorization") ?? "";
if (!bearer.startsWith("Bearer ")) {
return c.json({ status: "Unauthorized" }, 401);
}
const token = bearer.slice("Bearer ".length).trim();
if (token !== context.config.server.info_secret) {
return c.json({ status: "Forbidden" }, 403);
}
const api = context.hsApi.getRuntimeClient("fake-api-key");
const healthy = await api.isHealthy();
return c.json({
status: healthy ? "healthy" : "unhealthy",
headplane_version: __VERSION__,
headscale_canonical_version: healthy ? context.hsApi.apiVersion : "unknown",
internal_versions: {
node: versions.node,
v8: versions.v8,
uv: versions.uv,
zlib: versions.zlib,
openssl: versions.openssl,
libc: versions.libc,
},
});
});
app.get(`${prefix}/api/session`, async (c) => {
try {
const principal = await context.auth.require(c.req.raw);
if (principal.kind === "api_key") {
return c.json({
authenticated: true,
principal: {
kind: principal.kind,
sessionId: principal.sessionId,
displayName: principal.displayName,
},
});
}
return c.json({
authenticated: true,
principal: {
kind: principal.kind,
sessionId: principal.sessionId,
user: principal.user,
profile: principal.profile,
},
});
} catch {
return c.json({ authenticated: false }, 401);
}
});
app.all(`${prefix}/fate`, fateHandler);
app.all(`${prefix}/fate/*`, fateHandler);
if (staticRoot) {
const stripPrefix = (path: string) => path.slice(prefix.length) || "/";
app.get(prefix, (c) => c.redirect(`${prefix}/`));
app.use(
`${prefix}/*`,
serveStatic({
root: staticRoot,
rewriteRequestPath: stripPrefix,
}),
);
app.get(`${prefix}/*`, serveStatic({ root: staticRoot, path: "index.html" }));
}
return app;
}
+87
View File
@@ -0,0 +1,87 @@
import { createServer } from "node:http";
import { exit } from "node:process";
import { getRequestListener } from "@hono/node-server";
import { createServer as createViteServer } from "vite";
import log from "~/utils/log";
import { ConfigError } from "./config/error";
import { loadConfig } from "./config/load";
import { createAppContext } from "./context";
import { createHeadplaneHonoApp } from "./hono-app";
const PREFIX = process.env.__INTERNAL_PREFIX || "/admin";
(globalThis as Record<string, unknown>).__PREFIX__ = PREFIX;
(globalThis as Record<string, unknown>).__VERSION__ = process.env.HEADPLANE_VERSION ?? "dev";
let config;
try {
config = await loadConfig();
} catch (error) {
if (error instanceof ConfigError) {
log.error("server", "Unable to load configuration: %s", error.message);
} else {
log.error("server", "Failed to load configuration: %s", error);
}
exit(1);
}
const context = await createAppContext(config);
context.auth.start();
const app = createHeadplaneHonoApp({ context, prefix: PREFIX });
const honoListener = getRequestListener(app.fetch);
const vite = await createViteServer({
appType: "spa",
server: {
middlewareMode: true,
},
});
const server = createServer((req, res) => {
if (shouldUseHono(req.url)) {
void honoListener(req, res);
return;
}
vite.middlewares(req, res, (error?: unknown) => {
if (error) {
if (error instanceof Error) {
vite.ssrFixStacktrace(error);
}
log.error("server", "Vite middleware failed: %s", error);
res.statusCode = 500;
res.end("Internal Server Error");
return;
}
void honoListener(req, res);
});
});
server.listen(config.server.port, config.server.host, () => {
log.info("server", "Listening on http://%s:%s", config.server.host, config.server.port);
});
function shouldUseHono(rawUrl: string | undefined) {
if (!rawUrl) {
return false;
}
let pathname;
try {
pathname = new URL(rawUrl, "http://localhost").pathname;
} catch {
return false;
}
return (
pathname === "/healthz" ||
pathname === `${PREFIX}/healthz` ||
pathname === `${PREFIX}/fate` ||
pathname.startsWith(`${PREFIX}/fate/`) ||
pathname.startsWith(`${PREFIX}/api/`)
);
}
+46
View File
@@ -0,0 +1,46 @@
import { exit } from "node:process";
import { serve } from "@hono/node-server";
import log from "~/utils/log";
import { ConfigError } from "./config/error";
import { loadConfig } from "./config/load";
import { createAppContext } from "./context";
import { createHeadplaneHonoApp } from "./hono-app";
const PREFIX = process.env.__INTERNAL_PREFIX || "/admin";
(globalThis as Record<string, unknown>).__PREFIX__ = PREFIX;
(globalThis as Record<string, unknown>).__VERSION__ = process.env.HEADPLANE_VERSION ?? "dev";
let config;
try {
config = await loadConfig();
} catch (error) {
if (error instanceof ConfigError) {
log.error("server", "Unable to load configuration: %s", error.message);
} else {
log.error("server", "Failed to load configuration: %s", error);
}
exit(1);
}
const context = await createAppContext(config);
context.auth.start();
const app = createHeadplaneHonoApp({
context,
prefix: PREFIX,
staticRoot: "build/client",
});
serve(
{
fetch: app.fetch,
hostname: config.server.host,
port: config.server.port,
},
(info) => {
log.info("server", "Listening on http://%s:%s", info.address, info.port);
},
);
+115 -59
View File
@@ -1,7 +1,7 @@
import { execFile } from "node:child_process";
import { type ChildProcess, spawn } from "node:child_process";
import { access, constants, mkdir, rm, stat } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import { createInterface } from "node:readline";
import { inArray, notInArray } from "drizzle-orm";
import { NodeSQLiteDatabase } from "drizzle-orm/node-sqlite";
@@ -10,11 +10,9 @@ import { HostInfo } from "~/types";
import log from "~/utils/log";
import { HeadplaneConfig } from "./config/config-schema";
import { ephemeralNodes, hostInfo } from "./db/schema";
import { hostInfo } from "./db/schema";
import { RuntimeApiClient } from "./headscale/api/endpoints";
const execFileAsync = promisify(execFile);
export interface AgentManager {
lookup(nodeKeys: string[]): Promise<Record<string, HostInfo>>;
lastSync(): { syncedAt: Date | null; nodeCount: number; error?: string };
@@ -26,6 +24,7 @@ export interface AgentManager {
interface AgentOutput {
self: string;
hosts: Record<string, HostInfo>;
error?: string;
}
interface SyncState {
@@ -33,8 +32,6 @@ interface SyncState {
nodeCount: number;
selfKey?: string;
error?: string;
isSyncing: boolean;
pendingResync: boolean;
}
async function hasExistingState(workDir: string): Promise<boolean> {
@@ -95,10 +92,13 @@ export async function createAgentManager(
const state: SyncState = {
syncedAt: null,
nodeCount: 0,
isSyncing: false,
pendingResync: false,
};
let proc: ChildProcess | null = null;
let responseHandler: ((line: string) => void) | null = null;
let disposed = false;
let consecutiveErrors = 0;
async function generateAuthKey(): Promise<string> {
const expiration = new Date(Date.now() + 5 * 60_000);
const pak = await apiClient.createPreAuthKey(null, false, false, expiration, [
@@ -107,7 +107,7 @@ export async function createAgentManager(
return pak.key;
}
async function runAgent(authKey: string): Promise<string> {
function spawnAgent(authKey: string): ChildProcess {
const env: Record<string, string> = {
HOME: process.env.HOME ?? "",
HEADPLANE_AGENT_WORK_DIR: workDir,
@@ -120,53 +120,105 @@ export async function createAgentManager(
env.HEADPLANE_AGENT_TS_AUTHKEY = authKey;
}
const { stdout } = await execFileAsync(executablePath, [], {
timeout: 60_000,
const child = spawn(executablePath, [], {
env,
stdio: ["pipe", "pipe", "pipe"],
});
return stdout;
child.stderr?.on("data", (chunk: Buffer) => {
const text = chunk.toString().trim();
if (text) {
log.debug("agent", "%s", text);
}
});
const rl = createInterface({ input: child.stdout! });
rl.on("line", (line) => {
if (responseHandler) {
const handler = responseHandler;
responseHandler = null;
handler(line);
}
});
child.on("exit", (code, signal) => {
if (!disposed) {
log.warn("agent", "Agent process exited (code=%s, signal=%s)", code, signal);
}
proc = null;
// Reject any pending sync request
if (responseHandler) {
const handler = responseHandler;
responseHandler = null;
handler("");
}
});
proc = child;
return child;
}
async function ensureProcess(): Promise<ChildProcess> {
if (proc && proc.exitCode === null) {
return proc;
}
const stateExists = await hasExistingState(workDir);
if (stateExists) {
log.debug("agent", "Reusing existing tsnet identity");
return spawnAgent("");
}
log.info("agent", "No tsnet state found, generating pre-auth key");
return spawnAgent(await generateAuthKey());
}
function sendSync(child: ChildProcess): Promise<string> {
return new Promise((resolve) => {
responseHandler = resolve;
child.stdin?.write("sync\n");
});
}
async function requestSync(child: ChildProcess): Promise<AgentOutput> {
const line = await sendSync(child);
if (!line) {
throw new Error("Agent process closed unexpectedly");
}
return JSON.parse(line) as AgentOutput;
}
let isSyncing = false;
let pendingResync = false;
async function sync() {
if (state.isSyncing) {
state.pendingResync = true;
if (isSyncing) {
pendingResync = true;
log.debug("agent", "Sync already in progress, queued resync");
return;
}
state.isSyncing = true;
isSyncing = true;
try {
const stateExists = await hasExistingState(workDir);
const authKey = stateExists ? "" : await generateAuthKey();
const child = await ensureProcess();
const output = await requestSync(child);
if (stateExists) {
log.debug("agent", "Reusing existing tsnet identity");
}
if (output.error) {
consecutiveErrors++;
state.error = output.error;
log.error("agent", "Sync error from agent (%d/5): %s", consecutiveErrors, output.error);
let stdout: string;
try {
stdout = await runAgent(authKey);
} catch (err) {
if (!stateExists) {
throw err;
}
// Retry once with existing state (e.g. stale lock from a previous run)
log.info("agent", "Agent failed with existing state, retrying");
try {
const retryKey = await generateAuthKey();
stdout = await runAgent(retryKey);
} catch {
// Only clear identity as a last resort
log.warn("agent", "Retry failed, clearing state and starting fresh");
if (consecutiveErrors >= 5 && proc) {
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 });
const freshKey = await generateAuthKey();
stdout = await runAgent(freshKey);
}
return;
}
const output = JSON.parse(stdout) as AgentOutput;
consecutiveErrors = 0;
const keys = Object.keys(output.hosts);
for (const [nodeKey, payload] of Object.entries(output.hosts)) {
@@ -196,18 +248,28 @@ export async function createAgentManager(
log.info("agent", "Sync complete: %d nodes updated", keys.length);
} catch (error) {
consecutiveErrors++;
const message = error instanceof Error ? error.message : String(error);
state.error = message;
log.error("agent", "Sync failed: %s", message);
log.error("agent", "Sync failed (%d/5): %s", consecutiveErrors, message);
if (consecutiveErrors >= 5) {
log.warn("agent", "Too many consecutive failures, clearing state for next attempt");
await rm(join(workDir, "tailscaled.state"), { force: true });
}
} finally {
state.isSyncing = false;
if (state.pendingResync) {
state.pendingResync = false;
isSyncing = false;
if (pendingResync) {
pendingResync = false;
sync();
}
}
}
/**
* Prunes any offline nodes marked as ephemeral. This is due to a Headscale
* bug where ephemeral nodes wouldn't be automatically removed on disconnect.
*/
async function pruneStaleHostInfo() {
try {
const nodes = await apiClient.getNodes();
@@ -236,23 +298,12 @@ export async function createAgentManager(
async function pruneEphemeralNodes() {
try {
const rows = await db.select().from(ephemeralNodes);
if (rows.length === 0) {
return;
}
const nodes = await apiClient.getNodes();
const activeKeys = new Set(nodes.map((n) => n.nodeKey));
const toPrune = nodes.filter((n) => n.preAuthKey?.ephemeral && !n.online);
for (const row of rows) {
if (!row.node_key) {
continue;
}
if (!activeKeys.has(row.node_key)) {
await db.delete(ephemeralNodes).where(inArray(ephemeralNodes.auth_key, [row.auth_key]));
log.info("agent", "Pruned ephemeral SSH node %s", row.node_key);
}
for (const node of toPrune) {
await apiClient.deleteNode(node.id);
log.info("agent", "Pruned offline ephemeral node %s", node.givenName);
}
} catch (error) {
log.debug(
@@ -299,7 +350,12 @@ export async function createAgentManager(
},
dispose() {
disposed = true;
clearInterval(interval);
if (proc) {
proc.kill("SIGTERM");
proc = null;
}
},
};
}
-169
View File
@@ -1,169 +0,0 @@
import { join } from "node:path";
import { exit, versions } from "node:process";
import { createHonoServer } from "react-router-hono-server/node";
import log from "~/utils/log";
import { loadIntegration } from "./config/integration";
import { loadConfig } from "./config/load";
import { createDbClient } from "./db/client.server";
import { createHeadscaleInterface } from "./headscale/api";
import { loadHeadscaleConfig } from "./headscale/config-loader";
import { createLiveStore, nodesResource, usersResource } from "./headscale/live-store";
import { createAgentManager } from "./hp-agent";
import { createAuthService } from "./web/auth";
declare global {
const __PREFIX__: string;
const __VERSION__: string;
}
// MARK: Side-Effects
// This module contains a side-effect because everything running here
// exists for the lifetime of the process, making it appropriate.
log.info("server", "Running Node.js %s", versions.node);
let config: HeadplaneConfig;
try {
config = await loadConfig();
} catch (error) {
if (error instanceof ConfigError) {
log.error("server", "Unable to load configuration: %s", error.message);
}
exit(1);
}
const db = await createDbClient(join(config.server.data_path, "hp_persist.db"));
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 = headscaleApiKey
? await createAgentManager(
config.integration?.agent,
config.headscale.url,
hsApi.getRuntimeClient(headscaleApiKey),
hsApi.clientHelpers.isAtleast("0.28.0"),
db,
)
: (() => {
if (config.integration?.agent?.enabled) {
log.warn("agent", "Agent is enabled but no headscale.api_key is configured");
}
return undefined;
})();
// We also use this file to load anything needed by the react router code.
// These are usually per-request things that we need access to, like the
// helper that can issue and revoke cookies.
export type LoadContext = typeof appLoadContext;
import "react-router";
import { HeadplaneConfig } from "./config/config-schema";
import { ConfigError } from "./config/error";
import { createLazyOidcConnector } from "./web/oidc-connector";
declare module "react-router" {
interface AppLoadContext extends LoadContext {}
}
const hsLive = createLiveStore([nodesResource, usersResource]);
const appLoadContext = {
config,
hsLive,
hs: await loadHeadscaleConfig(
config.headscale.config_path,
config.headscale.config_strict,
config.headscale.dns_records_path,
),
auth: createAuthService({
secret: config.server.cookie_secret,
db,
cookie: {
name: "_hp_auth",
secure: config.server.cookie_secure,
maxAge: config.server.cookie_max_age,
domain: config.server.cookie_domain,
},
}),
hsApi,
agents,
integration: await loadIntegration(config.integration),
oidc:
config.oidc && config.oidc.enabled !== false && headscaleApiKey
? {
apiKey: headscaleApiKey,
connector: createLazyOidcConnector(
config.server.base_url,
config.oidc,
hsApi.getRuntimeClient(headscaleApiKey),
),
}
: undefined,
db,
};
declare module "react-router" {
interface AppLoadContext extends LoadContext {}
}
export default createHonoServer({
overrideGlobalObjects: true,
port: config.server.port,
hostname: config.server.host,
beforeAll: async (app) => {
app.use(__PREFIX__, async (c) => {
return c.redirect(`${__PREFIX__}/`);
});
},
serveStaticOptions: {
publicAssets: {
// This is part of our monkey-patch for react-router-hono-server
// To see the first part, go to the patches/ directory.
rewriteRequestPath: (path) => path.replace(`${__PREFIX__}`, ""),
},
clientAssets: {
// This is part of our monkey-patch for react-router-hono-server
// To see the first part, go to the patches/ directory.
rewriteRequestPath: (path) => path.replace(`${__PREFIX__}`, ""),
},
},
// Only log in development mode
defaultLogger: import.meta.env.DEV,
getLoadContext() {
// TODO: This is the place where we can handle reverse proxy translation
// This is better than doing it in the OIDC client, since we can do it
// for all requests, not just OIDC ones.
return appLoadContext;
},
listeningListener(info) {
log.info("server", "Running on %s:%s", info.address, info.port);
},
});
// Prune expired auth sessions every 15 minutes
setInterval(
() => {
appLoadContext.auth.pruneExpiredSessions();
},
15 * 60 * 1000,
);
process.on("SIGINT", () => {
log.info("server", "Received SIGINT, shutting down...");
process.exit(0);
});
process.on("SIGTERM", () => {
log.info("server", "Received SIGTERM, shutting down...");
process.exit(0);
});
+30
View File
@@ -0,0 +1,30 @@
// MARK: Production Bootstrap
//
// The production SSR build entry. Imports the React Router request
// listener from `./app`, wraps it with static-asset serving (out of
// `build/client`) and basename redirect, then binds an http(s) server.
//
// This file is NOT loaded in dev — `react-router dev` boots through
// Vite, and the dev-only `runtime/vite-plugin.ts` dispatches requests
// straight to `./app`'s default export.
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
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");
startHttpServer({
host: config.server.host,
port: config.server.port,
listener: composeListener({
basename: __PREFIX__,
staticRoot: clientDir,
immutableAssets: true,
requestListener,
}),
});
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
export type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
export function ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}
export function err<E>(error: E): Result<never, E> {
return { ok: false, error };
}
+207 -249
View File
@@ -10,10 +10,6 @@ import type { Machine } from "~/types";
import { type HeadplaneUser, authSessions, users } from "../db/schema";
import { Capabilities, type Role, Roles, capsForRole } from "./roles";
// ── Principal ────────────────────────────────────────────────────────
// The per-request identity object. Discriminated on `kind` so routes
// can branch structurally instead of checking magic strings.
export type Principal =
| {
kind: "api_key";
@@ -24,6 +20,7 @@ export type Principal =
| {
kind: "oidc";
sessionId: string;
idToken?: string;
user: {
id: string;
subject: string;
@@ -38,14 +35,8 @@ export type Principal =
};
};
// ── Cookie payload ───────────────────────────────────────────────────
// The cookie contains only a session ID + minimal profile data for
// SSR rendering. Credentials never leave the server.
interface CookiePayload {
sid: string;
// API key is stored in the cookie ONLY for api_key sessions.
// OIDC sessions use the server-side oidc.headscale_api_key.
api_key?: string;
profile?: {
name: string;
@@ -54,10 +45,9 @@ interface CookiePayload {
};
}
// ── AuthService ──────────────────────────────────────────────────────
export interface AuthServiceOptions {
secret: string;
headscaleApiKey?: string;
db: NodeSQLiteDatabase;
cookie: {
name: string;
@@ -67,36 +57,94 @@ export interface AuthServiceOptions {
};
}
export class AuthService {
private opts: AuthServiceOptions;
private requestCache = new WeakMap<Request, Promise<Principal>>();
export interface AuthService {
require(request: Request): Promise<Principal>;
can(principal: Principal, capabilities: Capabilities): boolean;
canManageNode(principal: Principal, node: Machine): boolean;
getHeadscaleApiKey(principal: Principal): string;
createOidcSession(
userId: string,
profile: NonNullable<CookiePayload["profile"]>,
options?: { idToken?: string; maxAge?: number },
): Promise<string>;
constructor(opts: AuthServiceOptions) {
this.opts = opts;
createApiKeySession(apiKey: string, displayName: string, maxAge: number): Promise<string>;
destroySession(request?: Request): Promise<string>;
findOrCreateUser(
subject: string,
profile?: { name?: string; email?: string; picture?: 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(currentOwnerSubject: string, newOwnerSubject: string): Promise<boolean>;
reassignSubject(subject: string, role: Role): Promise<boolean>;
pruneExpiredSessions(): Promise<void>;
start(): void;
stop(): void;
}
export function createAuthService(opts: AuthServiceOptions): AuthService {
const requestCache = new WeakMap<Request, Promise<Principal>>();
let pruneTimer: ReturnType<typeof setInterval> | undefined;
async function encodeCookie(payload: CookiePayload, maxAge: number): Promise<string> {
const cookie = createCookie(opts.cookie.name, {
...opts.cookie,
path: __PREFIX__,
maxAge,
});
const signed = Buffer.from(JSON.stringify(payload)).toString("base64url");
const hmac = createHmac("sha256", opts.secret).update(signed).digest("base64url");
return cookie.serialize(`${signed}.${hmac}`);
}
// ── Authentication ─────────────────────────────────────────────
/**
* Resolve the principal for a request. Throws if no valid session.
* Results are cached per-request so multiple calls in the same
* loader don't hit the DB repeatedly.
*/
require(request: Request): Promise<Principal> {
const cached = this.requestCache.get(request);
if (cached) {
return cached;
async function decodeCookie(request: Request): Promise<CookiePayload> {
const cookieHeader = request.headers.get("cookie");
if (!cookieHeader) {
throw new Error("No session cookie found");
}
const promise = this.resolve(request);
this.requestCache.set(request, promise);
return promise;
const cookie = createCookie(opts.cookie.name, {
...opts.cookie,
path: __PREFIX__,
});
const raw = (await cookie.parse(cookieHeader)) as string | null;
if (!raw) {
throw new Error("Session cookie is empty");
}
const dotIndex = raw.lastIndexOf(".");
if (dotIndex === -1) {
throw new Error("Malformed session cookie");
}
const signed = raw.slice(0, dotIndex);
const hmac = raw.slice(dotIndex + 1);
const expected = createHmac("sha256", opts.secret).update(signed).digest("base64url");
if (hmac !== expected) {
throw new Error("Invalid session cookie signature");
}
return JSON.parse(Buffer.from(signed, "base64url").toString("utf-8")) as CookiePayload;
}
private async resolve(request: Request): Promise<Principal> {
const payload = await this.decodeCookie(request);
function hashApiKey(key: string): string {
return createHash("sha256").update(key).digest("hex");
}
const [session] = await this.opts.db
async function resolve(request: Request): Promise<Principal> {
const payload = await decodeCookie(request);
const [session] = await opts.db
.select()
.from(authSessions)
.where(eq(authSessions.id, payload.sid))
@@ -107,7 +155,7 @@ export class AuthService {
}
if (session.expires_at < new Date()) {
await this.opts.db.delete(authSessions).where(eq(authSessions.id, session.id));
await opts.db.delete(authSessions).where(eq(authSessions.id, session.id));
throw new Error("Session expired");
}
@@ -128,11 +176,7 @@ export class AuthService {
throw new Error("OIDC session missing user_id");
}
const [user] = await this.opts.db
.select()
.from(users)
.where(eq(users.id, session.user_id))
.limit(1);
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");
@@ -142,6 +186,7 @@ export class AuthService {
return {
kind: "oidc",
sessionId: session.id,
idToken: session.oidc_id_token ?? undefined,
user: {
id: user.id,
subject: user.sub,
@@ -157,13 +202,18 @@ export class AuthService {
};
}
// ── Authorization ──────────────────────────────────────────────
function require(request: Request): Promise<Principal> {
const cached = requestCache.get(request);
if (cached) {
return cached;
}
/**
* Check if a principal has a given set of capabilities.
* API key principals always have full access.
*/
can(principal: Principal, capabilities: Capabilities): boolean {
const promise = resolve(request);
requestCache.set(request, promise);
return promise;
}
function can(principal: Principal, capabilities: Capabilities): boolean {
if (principal.kind === "api_key") {
return true;
}
@@ -172,11 +222,7 @@ export class AuthService {
return (capabilities & roleCaps) === capabilities;
}
/**
* Check if a principal can act on a machine. Owners of the machine
* can act on it even without write_machines capability.
*/
canManageNode(principal: Principal, node: Machine): boolean {
function canManageNode(principal: Principal, node: Machine): boolean {
if (principal.kind === "api_key") {
return true;
}
@@ -190,105 +236,79 @@ export class AuthService {
return hsUserId !== undefined && node.user?.id === hsUserId;
}
// ── Session management ─────────────────────────────────────────
/**
* Create a new OIDC session. Returns the Set-Cookie header value.
*/
async createOidcSession(
userId: string,
profile: NonNullable<CookiePayload["profile"]>,
maxAge = this.opts.cookie.maxAge,
): Promise<string> {
const sid = ulid();
await this.opts.db.insert(authSessions).values({
id: sid,
kind: "oidc",
user_id: userId,
expires_at: new Date(Date.now() + maxAge * 1000),
});
return this.encodeCookie({ sid, profile }, maxAge);
}
/**
* Create a new API key session. A SHA-256 hash of the key is stored
* server-side for auditing. The plaintext key is carried in the
* HMAC-signed cookie so it can be used for Headscale API calls.
* Returns the Set-Cookie header value.
*/
async createApiKeySession(apiKey: string, displayName: string, maxAge: number): Promise<string> {
const sid = ulid();
await this.opts.db.insert(authSessions).values({
id: sid,
kind: "api_key",
api_key_hash: this.hashApiKey(apiKey),
api_key_display: displayName,
expires_at: new Date(Date.now() + maxAge),
});
return this.encodeCookie({ sid, api_key: apiKey }, Math.floor(maxAge / 1000));
}
/**
* Get the Headscale API key for making API calls.
* OIDC sessions use the configured oidc.headscale_api_key.
* API key sessions use the user-provided key stored in the cookie.
*/
getHeadscaleApiKey(principal: Principal, oidcApiKey?: string): string {
function getHeadscaleApiKey(principal: Principal): string {
if (principal.kind === "api_key") {
return principal.apiKey;
}
if (!oidcApiKey) {
throw new Error("OIDC sessions require oidc.headscale_api_key");
if (!opts.headscaleApiKey) {
throw new Error("OIDC sessions require headscale.api_key to be configured");
}
return oidcApiKey;
return opts.headscaleApiKey;
}
/**
* Destroy the current session. Returns the Set-Cookie header that
* clears the cookie.
*/
async destroySession(request?: Request): Promise<string> {
async function createOidcSession(
userId: string,
profile: NonNullable<CookiePayload["profile"]>,
options?: { idToken?: string; maxAge?: number },
): Promise<string> {
const maxAge = options?.maxAge ?? opts.cookie.maxAge;
const sid = ulid();
await opts.db.insert(authSessions).values({
id: sid,
kind: "oidc",
user_id: userId,
oidc_id_token: options?.idToken,
expires_at: new Date(Date.now() + maxAge * 1000),
});
return encodeCookie({ sid, profile }, maxAge);
}
async function createApiKeySession(
apiKey: string,
displayName: string,
maxAge: number,
): Promise<string> {
const sid = ulid();
await opts.db.insert(authSessions).values({
id: sid,
kind: "api_key",
api_key_hash: hashApiKey(apiKey),
api_key_display: displayName,
expires_at: new Date(Date.now() + maxAge),
});
return encodeCookie({ sid, api_key: apiKey }, Math.floor(maxAge / 1000));
}
async function destroySession(request?: Request): Promise<string> {
if (request) {
try {
const payload = await this.decodeCookie(request);
await this.opts.db.delete(authSessions).where(eq(authSessions.id, payload.sid));
const payload = await decodeCookie(request);
await opts.db.delete(authSessions).where(eq(authSessions.id, payload.sid));
} catch {
// Cookie already invalid, just clear it
}
}
const cookie = createCookie(this.opts.cookie.name, {
...this.opts.cookie,
const cookie = createCookie(opts.cookie.name, {
...opts.cookie,
path: __PREFIX__,
});
return cookie.serialize("", { expires: new Date(0) });
}
// ── User management ────────────────────────────────────────────
/**
* Find or create a Headplane user by OIDC subject. Returns the
* user ID. The first user ever created is automatically granted
* the owner role (bootstrap). Profile data (name, email) is
* refreshed on every login.
*/
async findOrCreateUser(
async function findOrCreateUser(
subject: string,
profile?: { name?: string; email?: string; picture?: string },
): Promise<string> {
const [existing] = await this.opts.db
.select()
.from(users)
.where(eq(users.sub, subject))
.limit(1);
const [existing] = await opts.db.select().from(users).where(eq(users.sub, subject)).limit(1);
if (existing) {
await this.opts.db
await opts.db
.update(users)
.set({
name: profile?.name,
@@ -302,7 +322,7 @@ export class AuthService {
}
const id = ulid();
await this.opts.db.insert(users).values({
await opts.db.insert(users).values({
id,
sub: subject,
name: profile?.name,
@@ -312,14 +332,10 @@ export class AuthService {
caps: capsForRole("member"),
});
// If this is the only user in the table, promote to owner.
// The unique constraint on `sub` prevents two concurrent inserts
// for the same subject; for different subjects, COUNT atomically
// reflects all committed rows so at most one will see count === 1.
const [{ count }] = await this.opts.db.select({ count: sql<number>`count(*)` }).from(users);
const [{ count }] = await opts.db.select({ count: sql<number>`count(*)` }).from(users);
if (count === 1) {
await this.opts.db
await opts.db
.update(users)
.set({ role: "owner", caps: capsForRole("owner") })
.where(eq(users.id, id));
@@ -328,12 +344,8 @@ export class AuthService {
return id;
}
/**
* Link a Headplane user to a Headscale user. Returns false if the
* Headscale user is already claimed by another Headplane user.
*/
async linkHeadscaleUser(userId: string, headscaleUserId: string): Promise<boolean> {
const [existing] = await this.opts.db
async function linkHeadscaleUser(userId: string, headscaleUserId: string): Promise<boolean> {
const [existing] = await opts.db
.select({ id: users.id })
.from(users)
.where(eq(users.headscale_user_id, headscaleUserId))
@@ -343,7 +355,7 @@ export class AuthService {
return false;
}
await this.opts.db
await opts.db
.update(users)
.set({ headscale_user_id: headscaleUserId, updated_at: new Date() })
.where(eq(users.id, userId));
@@ -351,24 +363,18 @@ export class AuthService {
return true;
}
/**
* Clear the Headscale user link for a Headplane user. Used when the
* linked Headscale user no longer exists.
*/
async unlinkHeadscaleUser(userId: string): Promise<void> {
await this.opts.db
async function unlinkHeadscaleUser(userId: string): Promise<void> {
await opts.db
.update(users)
.set({ headscale_user_id: null, updated_at: new Date() })
.where(eq(users.id, userId));
}
/**
* Link a Headplane user (identified by OIDC subject) to a Headscale
* user. Used by admin UI when subjects are more accessible than
* internal Headplane IDs. Returns false if already claimed.
*/
async linkHeadscaleUserBySubject(subject: string, headscaleUserId: string): Promise<boolean> {
const [user] = await this.opts.db
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))
@@ -378,23 +384,15 @@ export class AuthService {
return false;
}
return this.linkHeadscaleUser(user.id, headscaleUserId);
return linkHeadscaleUser(user.id, headscaleUserId);
}
/**
* List all Headplane user records. Used by the users overview page
* to display the primary user list independently of the Headscale API.
*/
async listUsers(): Promise<HeadplaneUser[]> {
return this.opts.db.select().from(users);
async function listUsers(): Promise<HeadplaneUser[]> {
return opts.db.select().from(users);
}
/**
* Returns the set of Headscale user IDs that are already claimed
* by a Headplane user. Used to filter the link picker.
*/
async claimedHeadscaleUserIds(): Promise<Set<string>> {
const rows = await this.opts.db.select({ hsId: users.headscale_user_id }).from(users);
async function claimedHeadscaleUserIds(): Promise<Set<string>> {
const rows = await opts.db.select({ hsId: users.headscale_user_id }).from(users);
const ids = new Set<string>();
for (const row of rows) {
@@ -405,12 +403,8 @@ export class AuthService {
return ids;
}
/**
* Get the role for a given OIDC subject. Used by the users overview
* to display roles for Headscale users.
*/
async roleForSubject(subject: string): Promise<Role | undefined> {
const [user] = await this.opts.db.select().from(users).where(eq(users.sub, subject)).limit(1);
async function roleForSubject(subject: string): Promise<Role | undefined> {
const [user] = await opts.db.select().from(users).where(eq(users.sub, subject)).limit(1);
if (!user) {
return;
@@ -419,12 +413,8 @@ export class AuthService {
return (user.role in Roles ? user.role : "member") as Role;
}
/**
* Get the role for a Headplane user linked to a given Headscale user ID.
* Returns undefined if no Headplane user is linked to this Headscale user.
*/
async roleForHeadscaleUser(headscaleUserId: string): Promise<Role | undefined> {
const [user] = await this.opts.db
async function roleForHeadscaleUser(headscaleUserId: string): Promise<Role | undefined> {
const [user] = await opts.db
.select()
.from(users)
.where(eq(users.headscale_user_id, headscaleUserId))
@@ -437,14 +427,11 @@ export class AuthService {
return (user.role in Roles ? user.role : "member") as Role;
}
/**
* Transfer ownership from the current owner to another user.
* The current owner is demoted to admin and the target is promoted
* to owner. Both users must exist. Returns false if the caller is
* not actually the owner or the target doesn't exist.
*/
async transferOwnership(currentOwnerSubject: string, newOwnerSubject: string): Promise<boolean> {
const [current] = await this.opts.db
async function transferOwnership(
currentOwnerSubject: string,
newOwnerSubject: string,
): Promise<boolean> {
const [current] = await opts.db
.select()
.from(users)
.where(eq(users.sub, currentOwnerSubject))
@@ -454,7 +441,7 @@ export class AuthService {
return false;
}
const [target] = await this.opts.db
const [target] = await opts.db
.select()
.from(users)
.where(eq(users.sub, newOwnerSubject))
@@ -464,12 +451,12 @@ export class AuthService {
return false;
}
await this.opts.db
await opts.db
.update(users)
.set({ role: "admin", caps: capsForRole("admin"), updated_at: new Date() })
.where(eq(users.id, current.id));
await this.opts.db
await opts.db
.update(users)
.set({ role: "owner", caps: capsForRole("owner"), updated_at: new Date() })
.where(eq(users.id, target.id));
@@ -477,17 +464,13 @@ export class AuthService {
return true;
}
/**
* Reassign the role of a user identified by their OIDC subject.
* Cannot reassign the owner role.
*/
async reassignSubject(subject: string, role: Role): Promise<boolean> {
const currentRole = await this.roleForSubject(subject);
async function reassignSubject(subject: string, role: Role): Promise<boolean> {
const currentRole = await roleForSubject(subject);
if (currentRole === "owner") {
return false;
}
await this.opts.db
await opts.db
.insert(users)
.values({
id: ulid(),
@@ -503,66 +486,41 @@ export class AuthService {
return true;
}
/**
* Clean up expired sessions. Should be called periodically.
*/
async pruneExpiredSessions(): Promise<void> {
await this.opts.db.delete(authSessions).where(lt(authSessions.expires_at, new Date()));
async function pruneExpiredSessions(): Promise<void> {
await opts.db.delete(authSessions).where(lt(authSessions.expires_at, new Date()));
}
// ── Private helpers ────────────────────────────────────────────
private async encodeCookie(payload: CookiePayload, maxAge: number): Promise<string> {
const cookie = createCookie(this.opts.cookie.name, {
...this.opts.cookie,
path: __PREFIX__,
maxAge,
});
const signed = Buffer.from(JSON.stringify(payload)).toString("base64url");
const hmac = createHmac("sha256", this.opts.secret).update(signed).digest("base64url");
return cookie.serialize(`${signed}.${hmac}`);
function start(): void {
pruneTimer = setInterval(() => void pruneExpiredSessions(), 15 * 60 * 1000);
}
private async decodeCookie(request: Request): Promise<CookiePayload> {
const cookieHeader = request.headers.get("cookie");
if (!cookieHeader) {
throw new Error("No session cookie found");
function stop(): void {
if (pruneTimer) {
clearInterval(pruneTimer);
pruneTimer = undefined;
}
const cookie = createCookie(this.opts.cookie.name, {
...this.opts.cookie,
path: __PREFIX__,
});
const raw = (await cookie.parse(cookieHeader)) as string | null;
if (!raw) {
throw new Error("Session cookie is empty");
}
const dotIndex = raw.lastIndexOf(".");
if (dotIndex === -1) {
throw new Error("Malformed session cookie");
}
const signed = raw.slice(0, dotIndex);
const hmac = raw.slice(dotIndex + 1);
const expected = createHmac("sha256", this.opts.secret).update(signed).digest("base64url");
if (hmac !== expected) {
throw new Error("Invalid session cookie signature");
}
return JSON.parse(Buffer.from(signed, "base64url").toString("utf-8")) as CookiePayload;
}
private hashApiKey(key: string): string {
return createHash("sha256").update(key).digest("hex");
}
}
export function createAuthService(opts: AuthServiceOptions): AuthService {
return new AuthService(opts);
return {
require: require,
can,
canManageNode,
getHeadscaleApiKey,
createOidcSession,
createApiKeySession,
destroySession,
findOrCreateUser,
linkHeadscaleUser,
unlinkHeadscaleUser,
linkHeadscaleUserBySubject,
listUsers,
claimedHeadscaleUserIds,
roleForSubject,
roleForHeadscaleUser,
transferOwnership,
reassignSubject,
pruneExpiredSessions,
start,
stop,
};
}
+2 -1
View File
@@ -12,7 +12,8 @@ export function getOidcSubject(user: User): string | undefined {
return;
}
return user.providerId.split("/").pop();
const segment = user.providerId.split("/").pop();
return segment ? decodeURIComponent(segment) : segment;
}
/**
-321
View File
@@ -1,321 +0,0 @@
import * as oidc from "openid-client";
import log from "~/utils/log";
import type { HeadplaneConfig } from "../config/config-schema";
import type { RuntimeApiClient } from "../headscale/api/endpoints";
import { isDataUnauthorizedError } from "../headscale/api/error-client";
export type OidcConfig = NonNullable<HeadplaneConfig["oidc"]>;
/**
* Errors that can occur during OIDC connector setup and validation.
*/
export type OidcConnectorError =
| "INVALID_API_KEY"
| "MISSING_AUTHORIZATION_ENDPOINT"
| "MISSING_TOKEN_ENDPOINT"
| "MISSING_USERINFO_ENDPOINT"
| "MISSING_REQUIRED_CLAIMS"
| "DISCOVERY_FAILED"
| "UNKNOWN_ERROR";
/**
* Represents a "configured" OIDC setup for Headplane.
* This may include mis-configured versions too and will surface error messages.
*/
export type OidcConnector =
| {
isValid: true;
isExclusive: boolean;
usePKCE: boolean;
client: oidc.Configuration;
apiKey: string;
scope: string;
extraParams?: Record<string, string>;
}
| {
isValid: false;
isExclusive: false;
errors: OidcConnectorError[];
};
/**
* A lazy OIDC connector that retries initialization on failure.
* This allows OIDC to recover from transient startup failures (e.g., network issues,
* OIDC provider temporarily unavailable) without requiring a server restart.
*/
export interface LazyOidcConnector {
/**
* Get the current OIDC connector state.
* If a previous attempt failed, this will retry initialization.
* Successful results are cached until invalidated.
*/
get(): Promise<OidcConnector>;
/**
* Force a re-initialization of the OIDC connector on the next get() call.
* Useful for manually triggering a retry after configuration changes.
*/
invalidate(): void;
}
/**
* Creates a lazy OIDC connector that retries on failure.
* Successful initialization is cached; failed attempts are retried on each get() call.
*
* @param baseUrl The base URL of the Headplane server.
* @param config The OIDC configuration.
* @param client The Headscale runtime API client.
* @returns A lazy OIDC connector that retries on failure.
*/
export function createLazyOidcConnector(
baseUrl: string | undefined,
config: OidcConfig,
client: RuntimeApiClient,
): LazyOidcConnector {
let cachedConnector: OidcConnector | undefined;
let initPromise: Promise<OidcConnector> | undefined;
return {
async get(): Promise<OidcConnector> {
if (cachedConnector?.isValid) {
return cachedConnector;
}
if (initPromise) {
return initPromise;
}
initPromise = createOidcConnector(baseUrl, config, client);
try {
const connector = await initPromise;
if (connector.isValid) {
cachedConnector = connector;
log.info("auth", "OIDC connector initialized successfully");
} else {
log.warn("auth", "OIDC connector initialization failed, will retry on next request");
}
return connector;
} finally {
// Clear the promise so we can retry on next call if it failed
initPromise = undefined;
}
},
invalidate(): void {
cachedConnector = undefined;
initPromise = undefined;
log.info("auth", "OIDC connector cache invalidated");
},
};
}
/**
* Creates an OIDC connector based on the configuration and Headscale API.
* This will attempt to validate the configuration and return any errors.
*
* @param baseUrl The base URL of the Headplane server.
* @param config The OIDC configuration.
* @param client The Headscale runtime API client.
* @returns An OIDC connector with validation status.
*/
async function createOidcConnector(
baseUrl: string | undefined,
config: OidcConfig,
client: RuntimeApiClient,
): Promise<OidcConnector> {
if (baseUrl == null && config.redirect_uri == null) {
log.warn(
"config",
"OIDC is enabled but `server.base_url` is not set in the config. Starting in Headplane 0.7.0 this will be required for OIDC to function properly and will throw errors if not set, see https://headplane.net/features/sso#configuring-oidc for more information.",
);
}
const errors: OidcConnectorError[] = [];
if (!config.headscale_api_key) {
errors.push("INVALID_API_KEY");
return {
isValid: false,
isExclusive: false,
errors,
};
}
try {
await client.getApiKeys();
} catch (error) {
if (isDataUnauthorizedError(error)) {
errors.push("INVALID_API_KEY");
return {
isValid: false,
isExclusive: false,
errors,
};
}
// MARK: Otherwise assume the API key is valid since the API request
// failed for another reason that isn't 401 and we are optimistic
}
const oidcClientOrErrors = await discoveryCoalesce(config);
if (Array.isArray(oidcClientOrErrors)) {
errors.push(...oidcClientOrErrors);
return {
isValid: false,
isExclusive: false,
errors,
};
}
return {
isValid: true,
isExclusive: config.disable_api_key_login,
usePKCE: config.use_pkce,
client: oidcClientOrErrors,
apiKey: config.headscale_api_key,
scope: config.scope,
extraParams: config.extra_params,
};
}
/**
* Runs OIDC discovery and coalesces the results with the provided config.
* We treat the manually supplied values as overrides to discovery.
*
* @param config The OIDC configuration.
* @returns The coalesced OIDC configuration or an array of errors.
*/
async function discoveryCoalesce(
config: OidcConfig,
): Promise<oidc.Configuration | OidcConnectorError[]> {
let metadata: oidc.ServerMetadata;
let discoveryFailed = false;
try {
const client = await oidc.discovery(new URL(config.issuer), config.client_id);
metadata = client.serverMetadata();
if (config.use_pkce === true && !client.serverMetadata().supportsPKCE()) {
log.warn("config", "OIDC provider does not support PKCE, but it is enabled in the config");
}
if (metadata.claims_supported != null) {
if (!metadata.claims_supported.includes("sub")) {
log.error("config", "OIDC provider does not support `sub` claim");
return ["MISSING_REQUIRED_CLAIMS"];
}
if (!metadata.claims_supported.includes("name")) {
if (
!(
metadata.claims_supported.includes("given_name") &&
metadata.claims_supported.includes("family_name")
)
) {
log.warn(
"config",
"OIDC provider does not support `name`, `given_name`, or `family_name` claims",
);
}
}
if (
!metadata.claims_supported.includes("preferred_username") &&
!metadata.claims_supported.includes("email")
) {
log.warn("config", "OIDC provider does not support `preferred_username` or `email` claims");
}
}
} catch {
log.warn("auth", "Failed to reach OIDC provider for discovery, will retry on next request");
discoveryFailed = true;
metadata = {
issuer: config.issuer,
};
}
const authorization_endpoint = config.authorization_endpoint ?? metadata.authorization_endpoint;
const token_endpoint = config.token_endpoint ?? metadata.token_endpoint;
const userinfo_endpoint = config.userinfo_endpoint ?? metadata.userinfo_endpoint;
const hasMissingEndpoints = !authorization_endpoint || !token_endpoint || !userinfo_endpoint;
if (discoveryFailed && hasMissingEndpoints) {
return ["DISCOVERY_FAILED"];
}
const errors: OidcConnectorError[] = [];
if (!authorization_endpoint) {
errors.push("MISSING_AUTHORIZATION_ENDPOINT");
}
if (!token_endpoint) {
errors.push("MISSING_TOKEN_ENDPOINT");
}
if (!userinfo_endpoint) {
errors.push("MISSING_USERINFO_ENDPOINT");
}
if (errors.length > 0) {
return errors;
}
const oidcClient = new oidc.Configuration(
{
...metadata,
issuer: config.issuer,
authorization_endpoint,
token_endpoint,
userinfo_endpoint,
},
config.client_id,
config.client_secret,
negotiateTokenEndpointAuthMethod(config, metadata),
);
return oidcClient;
}
/**
* Determines the token endpoint authentication method based on config and metadata.
*
* @param config The OIDC configuration.
* @param metadata The OIDC server metadata.
* @returns The client authentication method for the token endpoint.
*/
function negotiateTokenEndpointAuthMethod(
config: OidcConfig,
metadata: oidc.ServerMetadata,
): oidc.ClientAuth {
if (config.token_endpoint_auth_method != null) {
switch (config.token_endpoint_auth_method) {
case "client_secret_basic":
return oidc.ClientSecretBasic(config.client_secret);
case "client_secret_post":
return oidc.ClientSecretPost(config.client_secret);
case "client_secret_jwt":
return oidc.ClientSecretJwt(config.client_secret);
}
}
const supported = metadata.token_endpoint_auth_methods_supported;
if (supported != null && supported.length > 0) {
// Prefer client_secret_basic (spec default), otherwise use first available
if (supported.includes("client_secret_basic")) {
return oidc.ClientSecretBasic(config.client_secret);
}
if (supported.includes("client_secret_post")) {
return oidc.ClientSecretPost(config.client_secret);
}
if (supported.includes("client_secret_jwt")) {
return oidc.ClientSecretJwt(config.client_secret);
}
}
log.warn("config", "Falling back to client_secret_post for token endpoint authentication");
return oidc.ClientSecretPost(config.client_secret);
}
+23
View File
@@ -0,0 +1,23 @@
import { RouterProvider } from "@tanstack/react-router";
import { Suspense } from "react";
import { FateClient } from "react-fate";
import { createFateClient } from "react-fate/client";
import { router } from "./router";
const fate = createFateClient({
fetch: (input, init) => fetch(input, { ...init, credentials: "include" }),
url: `${__PREFIX__}/fate`,
});
export function App() {
return (
<FateClient client={fate}>
<Suspense
fallback={<div className="min-h-screen bg-neutral-950 p-6 text-neutral-400">Loading</div>}
>
<RouterProvider router={router} />
</Suspense>
</FateClient>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "~/tailwind.css";
import { App } from "./app";
const root = document.getElementById("root");
if (!root) {
throw new Error("Unable to find root element");
}
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
);
+326
View File
@@ -0,0 +1,326 @@
import { Link, Outlet, createRootRoute, createRoute, createRouter } from "@tanstack/react-router";
import { useState, useTransition } from "react";
import {
useFateClient,
useLiveListView,
useLiveView,
useRequest,
view,
type ViewRef,
} from "react-fate";
import type { FateMachine, FateUser } from "../server/fate";
const UserView = view<FateUser>()({
displayName: true,
email: true,
id: true,
name: true,
});
const MachineView = view<FateMachine>()({
expiry: true,
givenName: true,
id: true,
ipAddresses: true,
lastSeen: true,
name: true,
online: true,
tags: true,
user: {
displayName: true,
id: true,
name: true,
},
});
const MachineConnectionView = {
args: { first: 100 },
items: {
cursor: true,
node: MachineView,
},
pagination: {
hasNext: true,
hasPrevious: true,
nextCursor: true,
previousCursor: true,
},
};
const UserConnectionView = {
args: { first: 100 },
items: {
cursor: true,
node: UserView,
},
pagination: {
hasNext: true,
hasPrevious: true,
nextCursor: true,
previousCursor: true,
},
};
const rootRoute = createRootRoute({
component: RootLayout,
});
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/",
component: HomePage,
});
const machinesRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/machines",
component: MachinesPage,
});
const usersRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/users",
component: UsersPage,
});
const routeTree = rootRoute.addChildren([indexRoute, machinesRoute, usersRoute]);
export const router = createRouter({
basepath: __PREFIX__,
routeTree,
});
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
function RootLayout() {
return (
<div className="min-h-screen bg-neutral-950 text-white">
<header className="border-b border-white/10 px-6 py-4">
<nav className="flex gap-4 text-sm">
<Link to="/">Home</Link>
<Link to="/machines">Machines</Link>
<Link to="/users">Users</Link>
</nav>
</header>
<main className="p-6">
<Outlet />
</main>
</div>
);
}
function HomePage() {
return (
<section className="space-y-2">
<h1 className="text-2xl font-semibold">Headplane SPA</h1>
<p className="text-neutral-400">
This is the one-way SPA shell. Data should move to raw Fate views and actions.
</p>
</section>
);
}
function MachinesPage() {
const { machines } = useRequest({ machines: { list: MachineConnectionView } });
const [machineItems, loadNext] = useLiveListView(MachineConnectionView, machines);
return (
<section className="space-y-4">
<h1 className="text-2xl font-semibold">Machines</h1>
{machineItems.length === 0 ? (
<p className="rounded-lg border border-white/10 bg-white/5 p-4 text-neutral-400">
No machines returned from Headscale.
</p>
) : (
<div className="overflow-hidden rounded-xl border border-white/10">
<table className="min-w-full divide-y divide-white/10 text-left text-sm">
<thead className="bg-white/5 text-xs tracking-wide text-neutral-400 uppercase">
<tr>
<th className="px-4 py-3 font-medium">Machine</th>
<th className="px-4 py-3 font-medium">User</th>
<th className="px-4 py-3 font-medium">Addresses</th>
<th className="px-4 py-3 font-medium">Status</th>
<th className="px-4 py-3 font-medium">Last seen</th>
</tr>
</thead>
<tbody className="divide-y divide-white/10">
{machineItems.map(({ node: machine }) => (
<MachineRow key={machine.id} machine={machine} />
))}
</tbody>
</table>
</div>
)}
{loadNext ? (
<button
className="rounded-lg border border-white/10 px-3 py-2 text-sm text-neutral-200 hover:bg-white/10"
onClick={() => void loadNext()}
type="button"
>
Load more machines
</button>
) : null}
</section>
);
}
function MachineRow({ machine: machineRef }: { machine: ViewRef<"Machine"> }) {
const fate = useFateClient();
const machine = useLiveView(MachineView, machineRef);
const currentName = machine.givenName || machine.name;
const [name, setName] = useState(currentName);
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const rename = () => {
const nextName = name.trim();
if (!nextName || nextName === currentName) {
return;
}
startTransition(() => {
void (async () => {
setError(null);
const result = await fate.mutations.machine.rename({
input: { id: machine.id, name: nextName },
view: MachineView,
});
if (result.error) {
setError(result.error.message);
return;
}
setName(nextName);
})();
});
};
return (
<tr className="bg-neutral-950/80">
<td className="px-4 py-3 align-top">
<div className="font-medium text-white">{currentName}</div>
<div className="text-xs text-neutral-500">{machine.id}</div>
<form
className="mt-2 flex max-w-sm gap-2"
onSubmit={(event) => {
event.preventDefault();
rename();
}}
>
<input
className="min-w-0 flex-1 rounded-md border border-white/10 bg-neutral-900 px-2 py-1 text-xs text-white outline-none focus:border-cyan-400"
disabled={isPending}
onChange={(event) => setName(event.currentTarget.value)}
value={name}
/>
<button
className="rounded-md border border-white/10 px-2 py-1 text-xs text-neutral-200 hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-50"
disabled={isPending || !name.trim() || name.trim() === currentName}
type="submit"
>
{isPending ? "Saving" : "Rename"}
</button>
</form>
{error ? <div className="mt-1 text-xs text-red-300">{error}</div> : null}
{machine.tags.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1">
{machine.tags.map((tag) => (
<span
className="rounded-full bg-cyan-400/10 px-2 py-0.5 text-xs text-cyan-200"
key={tag}
>
{tag}
</span>
))}
</div>
) : null}
</td>
<td className="px-4 py-3 align-top text-neutral-300">
{machine.user ? machine.user.displayName || machine.user.name : "Tag-owned"}
</td>
<td className="px-4 py-3 align-top text-neutral-300">
<div className="flex flex-col gap-1 font-mono text-xs">
{machine.ipAddresses.map((ip) => (
<span key={ip}>{ip}</span>
))}
</div>
</td>
<td className="px-4 py-3 align-top">
<span
className={
machine.online
? "rounded-full bg-green-400/10 px-2 py-1 text-xs text-green-200"
: "rounded-full bg-neutral-700 px-2 py-1 text-xs text-neutral-300"
}
>
{machine.online ? "Online" : "Offline"}
</span>
</td>
<td className="px-4 py-3 align-top text-neutral-300">{formatDate(machine.lastSeen)}</td>
</tr>
);
}
function UsersPage() {
const { users } = useRequest({ users: { list: UserConnectionView } });
const [userItems, loadNext] = useLiveListView(UserConnectionView, users);
return (
<section className="space-y-4">
<h1 className="text-2xl font-semibold">Users</h1>
{userItems.length === 0 ? (
<p className="rounded-lg border border-white/10 bg-white/5 p-4 text-neutral-400">
No users returned from Headscale.
</p>
) : (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{userItems.map(({ node: user }) => (
<UserCard key={user.id} user={user} />
))}
</div>
)}
{loadNext ? (
<button
className="rounded-lg border border-white/10 px-3 py-2 text-sm text-neutral-200 hover:bg-white/10"
onClick={() => void loadNext()}
type="button"
>
Load more users
</button>
) : null}
</section>
);
}
function UserCard({ user: userRef }: { user: ViewRef<"User"> }) {
const user = useLiveView(UserView, userRef);
return (
<article className="rounded-xl border border-white/10 bg-white/5 p-4">
<div className="font-medium text-white">{user.displayName || user.name}</div>
<div className="text-sm text-neutral-400">{user.name}</div>
{user.email ? <div className="mt-2 text-sm text-neutral-300">{user.email}</div> : null}
<div className="mt-3 font-mono text-xs text-neutral-500">{user.id}</div>
</article>
);
}
function formatDate(value: string | null | undefined) {
if (!value) return "Never";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString();
}
+125 -14
View File
@@ -2,12 +2,26 @@
@plugin "tailwindcss-animate";
/* Dark mode: respect an explicit `.dark` / `.light` class on <html>, and fall
* back to the user's OS preference when neither is set (i.e. "system"). */
@custom-variant dark {
&:where(.dark, .dark *) {
@slot;
}
@media (prefers-color-scheme: dark) {
&:where(html:not(.light):not(.dark)),
&:where(html:not(.light):not(.dark) *) {
@slot;
}
}
}
@theme {
--blur-xs: 2px;
--height-editor: calc(100vh - 20rem);
--font-sans: Inter, -apple-system, BlinkMacSystemFont, Helvetica, Arial, sans-serif;
--font-sans: "Inter Variable", -apple-system, BlinkMacSystemFont, sans-serif;
--transition-duration-25: 25ms;
--transition-duration-50: 50ms;
@@ -55,17 +69,114 @@
}
}
@supports (scrollbar-gutter: stable) {
html {
scrollbar-gutter: stable;
@layer base {
::-webkit-scrollbar {
width: 5px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: var(--border);
border-radius: 5px;
}
* {
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
}
.cm-merge-theme {
height: 100% !important;
body {
font-optical-sizing: auto;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.cm-mergeView {
:root {
--cm-bg: var(--color-white);
--cm-fg: var(--color-mist-900);
--cm-caret: var(--color-mist-900);
--cm-selection: var(--color-indigo-100);
--cm-selection-match: var(--color-indigo-50);
--cm-line-highlight: var(--color-mist-100);
--cm-gutter-bg: var(--color-mist-50);
--cm-gutter-fg: var(--color-mist-400);
--cm-gutter-fg-active: var(--color-mist-700);
--cm-gutter-border: var(--color-mist-200);
--cm-comment: var(--color-mist-500);
--cm-keyword: var(--color-purple-600);
--cm-string: var(--color-emerald-700);
--cm-type: var(--color-indigo-600);
--cm-definition: var(--color-blue-600);
--cm-name: var(--color-mist-800);
--cm-variable: var(--color-cyan-700);
--cm-property: var(--color-violet-600);
--cm-atom: var(--color-orange-600);
--cm-number: var(--color-orange-600);
--cm-link: var(--color-blue-600);
--cm-bracket: var(--color-mist-500);
}
/* CodeMirror dark token vars applied for explicit .dark or system preference
* when no explicit class is set. Mirrors the dark variant above. */
.dark {
--cm-bg: var(--color-mist-950);
--cm-fg: var(--color-mist-100);
--cm-caret: var(--color-mist-100);
--cm-selection: var(--color-indigo-900);
--cm-selection-match: var(--color-indigo-950);
--cm-line-highlight: oklch(18% 0.006 224 / 0.5);
--cm-gutter-bg: var(--color-mist-950);
--cm-gutter-fg: var(--color-mist-500);
--cm-gutter-fg-active: var(--color-mist-300);
--cm-gutter-border: var(--color-mist-800);
--cm-comment: var(--color-mist-400);
--cm-keyword: var(--color-purple-400);
--cm-string: var(--color-emerald-400);
--cm-type: var(--color-indigo-400);
--cm-definition: var(--color-blue-400);
--cm-name: var(--color-mist-200);
--cm-variable: var(--color-cyan-400);
--cm-property: var(--color-violet-400);
--cm-atom: var(--color-orange-400);
--cm-number: var(--color-orange-400);
--cm-link: var(--color-blue-400);
--cm-bracket: var(--color-mist-400);
}
@media (prefers-color-scheme: dark) {
html:not(.light):not(.dark) {
--cm-bg: var(--color-mist-950);
--cm-fg: var(--color-mist-100);
--cm-caret: var(--color-mist-100);
--cm-selection: var(--color-indigo-900);
--cm-selection-match: var(--color-indigo-950);
--cm-line-highlight: oklch(18% 0.006 224 / 0.5);
--cm-gutter-bg: var(--color-mist-950);
--cm-gutter-fg: var(--color-mist-500);
--cm-gutter-fg-active: var(--color-mist-300);
--cm-gutter-border: var(--color-mist-800);
--cm-comment: var(--color-mist-400);
--cm-keyword: var(--color-purple-400);
--cm-string: var(--color-emerald-400);
--cm-type: var(--color-indigo-400);
--cm-definition: var(--color-blue-400);
--cm-name: var(--color-mist-200);
--cm-variable: var(--color-cyan-400);
--cm-property: var(--color-violet-400);
--cm-atom: var(--color-orange-400);
--cm-number: var(--color-orange-400);
--cm-link: var(--color-blue-400);
--cm-bracket: var(--color-mist-400);
}
}
.cm-merge-theme,
.cm-mergeView,
.cm-mergeViewEditor {
height: 100% !important;
}
@@ -73,10 +184,6 @@
height: 100%;
}
.cm-mergeViewEditor {
height: 100% !important;
}
.toast-viewport {
position: fixed;
z-index: 50;
@@ -177,8 +284,12 @@
}
}
/* Weirdest class name characters but ok */
.cm-mergeView .ͼ1 .cm-scroller,
.cm-mergeView .ͼ1 {
.cm-mergeView .cm-editor,
.cm-mergeView .cm-editor .cm-scroller {
height: 100% !important;
}
.cm-mergeViewEditors {
scrollbar-color: var(--cm-gutter-border) transparent;
scrollbar-width: auto;
}

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