Compare commits

...

32 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
118 changed files with 7794 additions and 3845 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
+2
View File
@@ -1,6 +1,8 @@
node_modules
/.fate
/.react-router
/.cache
/.data
/build
/test
.env
+1
View File
@@ -1 +1,2 @@
side-effects-cache = false
public-hoist-pattern[]=vue
+31 -43
View File
@@ -1,58 +1,46 @@
# 0.7.0-beta.3 (May 14, 2026)
> This is a beta release. Please report any issues you encounter.
- 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 Browser SSH feature**
- Should now work with custom DERP ports and properly handle sessions.
- Switched to using `libghostty` for a proper, modern terminal experience (closes [#515](https://github.com/tale/headplane/issues/515)).
- Added more resilient error handling and state handling when initiating connections.
- **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.
- **Replaced `openid-client` with a clean-room OIDC implementation.**
- Removed the `openid-client` dependency entirely.
- Fixed `client_secret_basic` auth method not working with Google SSO and other providers (closes [#493](https://github.com/tale/headplane/issues/493)).
- Fixed OIDC connector initialization failures on beta.1 (closes [#516](https://github.com/tale/headplane/issues/516)).
- **Rearchitected the Headplane Agent** with a new 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 and fetches all peer hostinfo as JSON.
- 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)).
- **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)).
- Store OIDC profile pictures in the database to prevent cookie header overload (closes [#326](https://github.com/tale/headplane/issues/326), via [#510](https://github.com/tale/headplane/pull/510)).
- Fixed pre-auth key expiration on Headscale 0.28+ (closes [#519](https://github.com/tale/headplane/issues/519)).
- Fixed OIDC subject matching for providers that use special characters in user IDs (e.g. Auth0 `github|12345`) (closes [#428](https://github.com/tale/headplane/issues/428)).
- Fixed `headscale.api_key` not being used consistently across all code paths.
- Fixed intermittent SSR crash on the Access Control page caused by client-only CodeMirror imports.
- 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 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 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.
- **Rewrote the WebSSH WASM module** to match Tailscale's proven `tsconnect` init sequence.
- Switched the terminal renderer from xterm.js to [restty](https://restty.dev) (Ghostty WASM).
- Bundled self-hosted JetBrains Mono Nerd Font with Nerd Fonts symbol fallback — no CDN dependency.
- Fixed SSH sessions failing with EOF: the SSH channel multiplexer was not receiving server traffic.
- Fixed terminal resize sending swapped rows/cols, causing garbled output on window resize.
- Fixed `log.Fatal()` calls in the WASM bridge killing the entire runtime on recoverable errors.
- Fixed `Close()` returning `true` on error and `false` on success.
- Fixed stale closure bug in the NodeKey tracking callback.
- Removed unnecessary `LoginDefault` and `LocalBackendStartKeyOSNeutral` control flags.
- Added cancellation support for in-flight SSH connections on close.
- Fixed WebSSH dropping DERP port information on non-standard ports (e.g. `:8443`), which caused connections to fail (closes [#515](https://github.com/tale/headplane/issues/515)).
- Fixed WebSSH WASM prefix paths for correct asset loading (closes [#386](https://github.com/tale/headplane/issues/386)).
- Fixed Nix WASM build applying DERP patch to wrong vendor directory.
- 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)).
- 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)).
- Detect unsupported Docker API versions early with a clear error message (via [#497](https://github.com/tale/headplane/pull/497)).
- 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.
+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;
+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>
+4 -1
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
+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)];
+2 -2
View File
@@ -108,12 +108,12 @@ export default function Page({ loaderData: { access, writable, policy } }: Route
</TabsTab>
</TabsList>
<TabsPanel value="edit">
<Suspense fallback={<Fallback acl={codePolicy} />}>
<Suspense fallback={<Fallback />}>
<LazyEditor isDisabled={disabled} onChange={setCodePolicy} value={codePolicy} />
</Suspense>
</TabsPanel>
<TabsPanel value="diff">
<Suspense fallback={<Fallback acl={codePolicy} />}>
<Suspense fallback={<Fallback />}>
<LazyDiffer left={policy} right={codePolicy} />
</Suspense>
</TabsPanel>
+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: {
+13 -5
View File
@@ -66,13 +66,21 @@ export async function loader({ request, context }: Route.LoaderArgs) {
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,
}),
"Set-Cookie": await context.auth.createOidcSession(
userId,
{
name: identity.name,
email: identity.email,
username: identity.username,
},
{ idToken },
),
},
});
}
+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");
}
@@ -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";
@@ -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");
}
+11 -4
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";
@@ -281,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"
@@ -315,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."
-11
View File
@@ -1,7 +1,6 @@
import { Loader2, WifiOff } from "lucide-react";
import { useEffect, useState } from "react";
import { data, isRouteErrorResponse, type ShouldRevalidateFunction } from "react-router";
import { ExternalScriptsHandle } from "remix-utils/external-scripts";
import Button from "~/components/button";
import Card from "~/components/card";
@@ -112,16 +111,6 @@ export const links: Route.LinksFunction = () => [
},
];
export const handle: ExternalScriptsHandle = {
scripts: [
{
src: WASM_HELPER_URL,
crossOrigin: "anonymous",
preload: true,
},
],
};
export default function Page({ loaderData }: Route.ComponentProps) {
const { hostname, username, offline, node } = loaderData;
+19 -1
View File
@@ -1,4 +1,5 @@
const WASM_MODULE_URL = `${__PREFIX__}/hp_ssh.wasm`;
const WASM_HELPER_URL = `${__PREFIX__}/wasm_exec.js`;
declare global {
type HeadplaneSSHFactory = (config: HeadplaneSSHConfig) => HeadplaneSSH;
@@ -44,12 +45,29 @@ export interface TunnelSession {
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.
* It expects the Go WASM helper to be loaded, and will error if called before.
* 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);
+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",
},
});
}
+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),
};
}
+1
View File
@@ -36,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) };
},
}));
+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);
},
);
-177
View File
@@ -1,177 +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 { createOidcService } from "./oidc/provider";
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,
headscaleApiKey,
db,
cookie: {
name: "_hp_auth",
secure: config.server.cookie_secure,
maxAge: config.server.cookie_max_age,
domain: config.server.cookie_domain,
},
}),
headscaleApiKey,
hsApi,
agents,
integration: await loadIntegration(config.integration),
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,
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,
extraParams: config.oidc.extra_params,
profilePictureSource: config.oidc.profile_picture_source,
}),
disableApiKeyLogin: config.oidc.disable_api_key_login,
}
: 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);
},
});
appLoadContext.auth.start();
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,
}),
});
+407 -16
View File
@@ -1,4 +1,4 @@
import { createHash, randomBytes } from "node:crypto";
import { createHash, createPublicKey, randomBytes, verify } from "node:crypto";
import { createRemoteJWKSet, errors as joseErrors, jwtVerify } from "jose";
import type { JWSHeaderParameters, JWTPayload, FlattenedJWSInput } from "jose";
@@ -15,14 +15,18 @@ export interface OidcConfig {
authorizationEndpoint?: string;
tokenEndpoint?: string;
userinfoEndpoint?: string;
endSessionEndpoint?: string;
jwksUri?: string;
tokenEndpointAuthMethod?: "client_secret_basic" | "client_secret_post";
usePkce?: boolean;
scope?: string;
subjectClaims?: string[];
allowWeakRsaKeys?: boolean;
extraParams?: Record<string, string>;
profilePictureSource?: "oidc" | "gravatar";
postLogoutRedirectUri?: string;
}
export interface ResolvedEndpoints {
@@ -47,6 +51,7 @@ export interface OidcIdentity {
username: string;
email?: string;
picture?: string;
idToken?: string;
}
export type OidcErrorCode =
@@ -87,6 +92,8 @@ export interface OidcService {
flowState: OidcFlowState,
): Promise<Result<OidcIdentity, OidcError>>;
buildEndSessionUrl(idToken?: string): string | undefined;
invalidate(): void;
reload(config: OidcConfig): void;
}
@@ -114,6 +121,23 @@ interface TokenErrorResponse {
error_description?: string;
}
interface JwksResponse {
keys?: Array<JsonWebKey & { kid?: string }>;
}
interface DecodedJwtParts {
header: Record<string, unknown>;
payload: OidcClaims;
signature: Buffer;
signingInput: string;
}
interface WeakRsaContext {
alg: "RS256" | "RS384" | "RS512";
decoded: DecodedJwtParts;
candidateKeys: Array<JsonWebKey & { kid?: string }>;
}
export function createOidcService(initialConfig: OidcConfig): OidcService {
let config = Object.freeze({ ...initialConfig });
@@ -122,6 +146,13 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
let jwks: JwksResolver | undefined;
let resolvedAuthMethod: "client_secret_basic" | "client_secret_post" | undefined =
initialConfig.tokenEndpointAuthMethod;
const weakJwksCache = new Map<
string,
{ expiresAt: number; keys: Array<JsonWebKey & { kid?: string }> }
>();
let hasWarnedWeakKeyMode = false;
maybeWarnWeakRsaMode(config);
function status(): ReturnType<OidcService["status"]> {
if (lastError) {
@@ -147,6 +178,7 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
tokenEndpoint: config.tokenEndpoint!,
jwksUri: config.jwksUri!,
userinfoEndpoint: config.userinfoEndpoint,
endSessionEndpoint: config.endSessionEndpoint,
};
lastError = undefined;
@@ -221,7 +253,8 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
const jwksUri = config.jwksUri ?? (metadata.jwks_uri as string | undefined);
const userinfoEndpoint =
config.userinfoEndpoint ?? (metadata.userinfo_endpoint as string | undefined);
const endSessionEndpoint = metadata.end_session_endpoint as string | undefined;
const endSessionEndpoint =
config.endSessionEndpoint ?? (metadata.end_session_endpoint as string | undefined);
if (!authorizationEndpoint || !tokenEndpoint || !jwksUri) {
const missing: string[] = [];
@@ -355,7 +388,16 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
const claims = verifyResult.value;
const enriched = await enrichWithUserInfo(resolved.value, tokens.access_token, claims);
return ok(buildIdentity(enriched));
if (!resolveSubject(enriched)) {
return err({
code: "missing_sub",
message: "ID token and userinfo response are missing all configured subject claims",
hint: `Your identity provider did not return a stable user identifier. Configure oidc.subject_claims or ensure one of these claims is present: ${getSubjectClaimOrder().join(", ")}.`,
});
}
return ok(buildIdentity(enriched, tokens.id_token));
}
async function exchangeCode(
@@ -523,14 +565,6 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
clockTolerance: 60,
});
if (!payload.sub) {
return err({
code: "missing_sub",
message: "ID token is missing the 'sub' claim",
hint: "Your identity provider did not return a user identifier. Check that your OIDC client is configured to include the 'sub' claim.",
});
}
if (payload.nonce !== expectedNonce) {
return err({
code: "nonce_mismatch",
@@ -555,6 +589,28 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
});
}
let weakRsaContext: WeakRsaContext | undefined;
try {
weakRsaContext = await getWeakRsaContext(idToken);
} catch (weakRsaCause) {
return err({
code: "invalid_id_token",
message: `ID token verification failed: ${weakRsaCause instanceof Error ? weakRsaCause.message : String(weakRsaCause)}`,
});
}
if (weakRsaContext) {
if (!config.allowWeakRsaKeys) {
return err({
code: "invalid_id_token",
message: "ID token was signed with a weak RSA key that Headplane rejects by default",
hint: "If your provider cannot rotate to a 2048-bit-or-larger RSA signing key, set oidc.allow_weak_rsa_keys to true as a temporary compatibility fallback.",
});
}
return verifyIdTokenWithWeakRsa(weakRsaContext, expectedNonce);
}
if (cause instanceof joseErrors.JWSSignatureVerificationFailed) {
return err({
code: "invalid_id_token",
@@ -570,13 +626,76 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
}
}
async function verifyIdTokenWithWeakRsa(
weakRsaContext: WeakRsaContext,
expectedNonce: string,
): Promise<Result<OidcClaims, OidcError>> {
for (const jwk of weakRsaContext.candidateKeys) {
try {
const key = createPublicKey({ key: jwk, format: "jwk" });
const isValid = verify(
getNodeVerifyAlgorithm(weakRsaContext.alg),
Buffer.from(weakRsaContext.decoded.signingInput),
key,
weakRsaContext.decoded.signature,
);
if (!isValid) {
continue;
}
} catch (cause) {
return err({
code: "invalid_id_token",
message: `ID token verification failed: ${cause instanceof Error ? cause.message : String(cause)}`,
});
}
if (!hasWarnedWeakKeyMode) {
hasWarnedWeakKeyMode = true;
log.warn(
"auth",
"OIDC issuer %s is using a weak RSA signing key. Accepting it only because oidc.allow_weak_rsa_keys=true.",
config.issuer,
);
}
const claimError = validateOidcClaims(
weakRsaContext.decoded.payload,
config.issuer,
config.clientId,
60,
);
if (claimError) {
return err(claimError);
}
if (weakRsaContext.decoded.payload.nonce !== expectedNonce) {
return err({
code: "nonce_mismatch",
message: `Nonce mismatch: expected ${expectedNonce}, got ${weakRsaContext.decoded.payload.nonce}`,
hint: "Please try signing in again. This can happen with stale browser sessions.",
});
}
return ok(weakRsaContext.decoded.payload);
}
return err({
code: "invalid_id_token",
message: "ID token signature verification failed",
hint: "The identity provider's signing keys may have changed. Try restarting Headplane to refresh the key cache.",
});
}
async function enrichWithUserInfo(
ep: ResolvedEndpoints,
accessToken: string,
claims: OidcClaims,
): Promise<OidcClaims> {
const needsEnrichment = !claims.name && !claims.email && !claims.picture;
if (!needsEnrichment || !ep.userinfoEndpoint) {
const needsEnrichment =
!claims.name && !claims.email && !claims.picture && !!resolveSubject(claims);
const needsSubjectEnrichment = !resolveSubject(claims);
if ((!needsEnrichment && !needsSubjectEnrichment) || !ep.userinfoEndpoint) {
return claims;
}
@@ -595,8 +714,17 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
}
const userInfo = (await response.json()) as Record<string, unknown>;
const subjectClaimValues = Object.fromEntries(
getSubjectClaimOrder()
.filter((claim) => claim !== "sub")
.map((claim) => [
claim,
readClaimAsString(claims, claim) ?? readClaimAsString(userInfo, claim),
]),
);
return {
...claims,
...subjectClaimValues,
name: claims.name ?? (userInfo.name as string | undefined),
given_name: claims.given_name ?? (userInfo.given_name as string | undefined),
family_name: claims.family_name ?? (userInfo.family_name as string | undefined),
@@ -604,6 +732,7 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
claims.preferred_username ?? (userInfo.preferred_username as string | undefined),
email: claims.email ?? (userInfo.email as string | undefined),
picture: claims.picture ?? (userInfo.picture as string | undefined),
sub: claims.sub ?? readClaimAsString(userInfo, "sub"),
};
} catch (cause) {
log.debug(
@@ -616,7 +745,12 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
}
}
function buildIdentity(claims: OidcClaims): OidcIdentity {
function buildIdentity(claims: OidcClaims, idToken?: string): OidcIdentity {
const subject = resolveSubject(claims);
if (!subject) {
throw new Error("OIDC subject was not resolved before identity construction");
}
const name =
claims.name ??
(claims.given_name && claims.family_name
@@ -637,14 +771,34 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
return {
issuer: config.issuer,
subject: claims.sub!,
subject,
name,
username,
email: claims.email,
picture,
idToken,
};
}
function buildEndSessionUrl(idToken?: string): string | undefined {
if (!endpoints?.endSessionEndpoint) {
return undefined;
}
const params = new URLSearchParams();
if (idToken) {
params.set("id_token_hint", idToken);
}
params.set("client_id", config.clientId);
const postLogoutRedirectUri =
config.postLogoutRedirectUri ?? new URL(`${__PREFIX__}/login?s=logout`, config.baseUrl).href;
params.set("post_logout_redirect_uri", postLogoutRedirectUri);
return `${endpoints.endSessionEndpoint}?${params.toString()}`;
}
function invalidate(): void {
endpoints = undefined;
lastError = undefined;
@@ -654,10 +808,112 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
function reload(newConfig: OidcConfig): void {
config = Object.freeze({ ...newConfig });
maybeWarnWeakRsaMode(config);
invalidate();
}
return { status, discover, startFlow, handleCallback, invalidate, reload };
function getSubjectClaimOrder(): string[] {
return [
"sub",
...normalizeSubjectClaims(config.subjectClaims).filter((claim) => claim !== "sub"),
];
}
function resolveSubject(claims: OidcClaims): string | undefined {
for (const claim of getSubjectClaimOrder()) {
const value = readClaimAsString(claims, claim);
if (value) {
return value;
}
}
return undefined;
}
return {
status,
discover,
startFlow,
handleCallback,
buildEndSessionUrl,
invalidate,
reload,
};
function maybeWarnWeakRsaMode(currentConfig: OidcConfig): void {
if (!currentConfig.allowWeakRsaKeys) {
return;
}
log.warn(
"auth",
"OIDC weak RSA compatibility mode is enabled for issuer %s. This lowers token verification security and should only be used as a temporary workaround.",
currentConfig.issuer,
);
}
async function getWeakRsaContext(idToken: string): Promise<WeakRsaContext | undefined> {
if (!endpoints?.jwksUri) {
return undefined;
}
const decoded = decodeJwtParts(idToken);
const alg = decoded.header.alg;
if (alg !== "RS256" && alg !== "RS384" && alg !== "RS512") {
return undefined;
}
const keys = await fetchSigningJwks(endpoints.jwksUri);
const candidateKeys = selectCandidateSigningKeys(keys, decoded.header.kid).filter((jwk) =>
isWeakRsaKey(jwk),
);
if (candidateKeys.length === 0) {
return undefined;
}
return { alg, decoded, candidateKeys };
}
async function fetchSigningJwks(jwksUri: string): Promise<Array<JsonWebKey & { kid?: string }>> {
const cached = weakJwksCache.get(jwksUri);
const now = Date.now();
if (cached && cached.expiresAt > now) {
return cached.keys;
}
const response = await fetch(jwksUri, {
headers: { Accept: "application/json" },
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) {
throw new Error(`JWKS endpoint returned ${response.status}: ${jwksUri}`);
}
const json = (await response.json()) as JwksResponse;
const keys = Array.isArray(json.keys) ? json.keys : [];
if (keys.length === 0) {
throw new Error("JWKS response did not contain any keys");
}
weakJwksCache.set(jwksUri, {
expiresAt: now + 60_000,
keys,
});
return keys;
}
}
function readClaimAsString(claims: Record<string, unknown>, claimName: string): string | undefined {
const value = claims[claimName];
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function generateRandom(bytes = 32): string {
@@ -667,3 +923,138 @@ function generateRandom(bytes = 32): string {
function computeS256Challenge(verifier: string): string {
return createHash("sha256").update(verifier).digest("base64url");
}
function decodeJwtParts(token: string): DecodedJwtParts {
const parts = token.split(".");
if (parts.length !== 3) {
throw new Error("JWT must have exactly 3 parts");
}
const [encodedHeader, encodedPayload, encodedSignature] = parts;
const header = JSON.parse(Buffer.from(encodedHeader, "base64url").toString("utf8")) as Record<
string,
unknown
>;
const payload = JSON.parse(
Buffer.from(encodedPayload, "base64url").toString("utf8"),
) as OidcClaims;
const signature = Buffer.from(encodedSignature, "base64url");
return {
header,
payload,
signature,
signingInput: `${encodedHeader}.${encodedPayload}`,
};
}
function selectCandidateSigningKeys(
keys: Array<JsonWebKey & { kid?: string }>,
expectedKid: unknown,
): Array<JsonWebKey & { kid?: string }> {
const kid = typeof expectedKid === "string" ? expectedKid : undefined;
const rsaKeys = keys.filter((key) => key.kty === "RSA");
if (kid) {
const matchingKey = rsaKeys.find((key) => key.kid === kid);
if (matchingKey) {
return [matchingKey];
}
return [];
}
return rsaKeys;
}
function getNodeVerifyAlgorithm(alg: string): "RSA-SHA256" | "RSA-SHA384" | "RSA-SHA512" {
switch (alg) {
case "RS256":
return "RSA-SHA256";
case "RS384":
return "RSA-SHA384";
case "RS512":
return "RSA-SHA512";
default:
throw new Error(`Unsupported RSA verification algorithm: ${alg}`);
}
}
function isWeakRsaKey(jwk: JsonWebKey): boolean {
if (jwk.kty !== "RSA" || typeof jwk.n !== "string") {
return false;
}
return getRsaModulusBitLength(jwk.n) < 2048;
}
function getRsaModulusBitLength(base64UrlModulus: string): number {
const modulus = Buffer.from(base64UrlModulus, "base64url");
if (modulus.length === 0) {
return 0;
}
let leadingZeroBits = 0;
let currentByte = modulus[0];
while ((currentByte & 0x80) === 0 && leadingZeroBits < 8) {
leadingZeroBits++;
currentByte <<= 1;
}
return modulus.length * 8 - leadingZeroBits;
}
function normalizeSubjectClaims(subjectClaims?: string[]): string[] {
const seen = new Set<string>();
const normalized: string[] = [];
for (const claim of subjectClaims ?? []) {
const trimmed = claim.trim();
if (trimmed.length === 0 || seen.has(trimmed)) {
continue;
}
seen.add(trimmed);
normalized.push(trimmed);
}
return normalized;
}
function validateOidcClaims(
payload: OidcClaims,
expectedIssuer: string,
expectedAudience: string,
clockToleranceSeconds: number,
): OidcError | undefined {
if (payload.iss !== expectedIssuer) {
return {
code: "invalid_id_token",
message: 'JWT claim validation failed: iss — unexpected "iss" claim value',
};
}
const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
if (!audiences.includes(expectedAudience)) {
return {
code: "invalid_id_token",
message: 'JWT claim validation failed: aud — unexpected "aud" claim value',
};
}
const now = Math.floor(Date.now() / 1000);
if (typeof payload.exp === "number" && now - clockToleranceSeconds >= payload.exp) {
return {
code: "invalid_id_token",
message: "ID token is expired",
};
}
if (typeof payload.nbf === "number" && now + clockToleranceSeconds < payload.nbf) {
return {
code: "invalid_id_token",
message: "JWT claim validation failed: nbf — token is not active yet",
};
}
return undefined;
}
+6 -2
View File
@@ -20,6 +20,7 @@ export type Principal =
| {
kind: "oidc";
sessionId: string;
idToken?: string;
user: {
id: string;
subject: string;
@@ -64,7 +65,7 @@ export interface AuthService {
createOidcSession(
userId: string,
profile: NonNullable<CookiePayload["profile"]>,
maxAge?: number,
options?: { idToken?: string; maxAge?: number },
): Promise<string>;
createApiKeySession(apiKey: string, displayName: string, maxAge: number): Promise<string>;
@@ -185,6 +186,7 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
return {
kind: "oidc",
sessionId: session.id,
idToken: session.oidc_id_token ?? undefined,
user: {
id: user.id,
subject: user.sub,
@@ -249,13 +251,15 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
async function createOidcSession(
userId: string,
profile: NonNullable<CookiePayload["profile"]>,
maxAge = opts.cookie.maxAge,
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),
});
+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;
}
+141 -135
View File
@@ -3,209 +3,215 @@
// https://github.com/tailscale/tailscale/blob/main/tailcfg/tailcfg.go#L816
export interface HostInfo {
/**
* Custom identifier we use to determine if its an agent or not
*/
HeadplaneAgent?: boolean;
/**
* Custom identifier we use to determine if its an agent or not
*/
HeadplaneAgent?: boolean;
/** Version of this code (in version.Long format) */
IPNVersion?: string;
/** Version of this code (in version.Long format) */
IPNVersion?: string;
/** Logtail ID of frontend instance */
FrontendLogID?: string;
/** Logtail ID of frontend instance */
FrontendLogID?: string;
/** Logtail ID of backend instance */
BackendLogID?: string;
/** Logtail ID of backend instance */
BackendLogID?: string;
/** Operating system the client runs on (a version.OS value) */
OS?: string;
/** Operating system the client runs on (a version.OS value) */
OS?: string;
/**
* Version of the OS, if available.
*
* - Android: "10", "11", "12", etc.
* - iOS/macOS: "15.6.1", "12.4.0", etc.
* - Windows: "10.0.19044.1889", etc.
* - FreeBSD: "12.3-STABLE", etc.
* - Linux (pre-1.32): "Debian 10.4; kernel=xxx; container; env=kn"
* - Linux (1.32+): Kernel version, e.g., "5.10.0-17-amd64".
*/
OSVersion?: string;
/**
* Version of the OS, if available.
*
* - Android: "10", "11", "12", etc.
* - iOS/macOS: "15.6.1", "12.4.0", etc.
* - Windows: "10.0.19044.1889", etc.
* - FreeBSD: "12.3-STABLE", etc.
* - Linux (pre-1.32): "Debian 10.4; kernel=xxx; container; env=kn"
* - Linux (1.32+): Kernel version, e.g., "5.10.0-17-amd64".
*/
OSVersion?: string;
/** Whether the client is running in a container (best-effort detection) */
Container?: boolean;
/** Whether the client is running in a container (best-effort detection) */
Container?: boolean;
/** Host environment type as a string */
Env?: string;
/** Host environment type as a string */
Env?: string;
/** Distribution name (e.g., "debian", "ubuntu", "nixos") */
Distro?: string;
/** Distribution name (e.g., "debian", "ubuntu", "nixos") */
Distro?: string;
/** Distribution version (e.g., "20.04") */
DistroVersion?: string;
/** Distribution version (e.g., "20.04") */
DistroVersion?: string;
/** Distribution code name (e.g., "jammy", "bullseye") */
DistroCodeName?: string;
/** Distribution code name (e.g., "jammy", "bullseye") */
DistroCodeName?: string;
/** Used to disambiguate Tailscale clients that run using tsnet */
App?: string;
/** Used to disambiguate Tailscale clients that run using tsnet */
App?: string;
/** Whether a desktop was detected on Linux */
Desktop?: boolean;
/** Whether a desktop was detected on Linux */
Desktop?: boolean;
/** Tailscale package identifier ("choco", "appstore", etc.; empty if unknown) */
Package?: string;
/** Tailscale package identifier ("choco", "appstore", etc.; empty if unknown) */
Package?: string;
/** Mobile phone model (e.g., "Pixel 3a", "iPhone12,3") */
DeviceModel?: string;
/** Mobile phone model (e.g., "Pixel 3a", "iPhone12,3") */
DeviceModel?: string;
/** macOS/iOS APNs device token for notifications (future support for Android) */
PushDeviceToken?: string;
/** macOS/iOS APNs device token for notifications (future support for Android) */
PushDeviceToken?: string;
/** Name of the host the client runs on */
Hostname?: string;
/** Name of the host the client runs on */
Hostname?: string;
/** Indicates whether the host is blocking incoming connections */
ShieldsUp?: boolean;
/** Indicates whether the host is blocking incoming connections */
ShieldsUp?: boolean;
/** Indicates this node exists in netmap because it's owned by a shared-to user */
ShareeNode?: boolean;
/** Indicates this node exists in netmap because it's owned by a shared-to user */
ShareeNode?: boolean;
/** Indicates user has opted out of sending logs and support */
NoLogsNoSupport?: boolean;
/** Indicates user has opted out of sending logs and support */
NoLogsNoSupport?: boolean;
/** Indicates the node wants the option to receive ingress connections */
WireIngress?: boolean;
/** Indicates the node wants the option to receive ingress connections */
WireIngress?: boolean;
/** Indicates node has opted-in to admin-console-driven remote updates */
AllowsUpdate?: boolean;
/** Indicates node has opted-in to admin-console-driven remote updates */
AllowsUpdate?: boolean;
/** Current host's machine type (e.g., uname -m) */
Machine?: string;
/** Current host's machine type (e.g., uname -m) */
Machine?: string;
/** `GOARCH` value of the built binary */
GoArch?: string;
/** `GOARCH` value of the built binary */
GoArch?: string;
/** Architecture variant (e.g., GOARM, GOAMD64) of the built binary */
GoArchVar?: string;
/** Architecture variant (e.g., GOARM, GOAMD64) of the built binary */
GoArchVar?: string;
/** Go version the binary was built with */
GoVersion?: string;
/** Go version the binary was built with */
GoVersion?: string;
/** Set of IP ranges this client can route */
RoutableIPs?: string[];
/** Set of IP ranges this client can route */
RoutableIPs?: string[];
/** Set of ACL tags this node wants to claim */
RequestTags?: string[];
/** Set of ACL tags this node wants to claim */
RequestTags?: string[];
/** MAC addresses to send Wake-on-LAN packets to wake this node */
WoLMACs?: string[];
/** MAC addresses to send Wake-on-LAN packets to wake this node */
WoLMACs?: string[];
/** Services advertised by this machine */
Services?: Service[];
/** Services advertised by this machine */
Services?: Service[];
/** Networking information about the node */
NetInfo?: NetInfo;
/** Networking information about the node */
NetInfo?: NetInfo;
/** SSH host keys if advertised */
sshHostKeys?: string[];
/** SSH host keys if advertised */
sshHostKeys?: string[];
/** Cloud provider information (if any) */
Cloud?: string;
/** Cloud provider information (if any) */
Cloud?: string;
/** Indicates if the client is running in userspace (netstack) mode */
Userspace?: boolean;
/** Indicates if the client is running in userspace (netstack) mode */
Userspace?: boolean;
/** Indicates if the client's subnet router is running in userspace (netstack) mode */
UserspaceRouter?: boolean;
/** Indicates if the client's subnet router is running in userspace (netstack) mode */
UserspaceRouter?: boolean;
/** Indicates if the client is running the app-connector service */
AppConnector?: boolean;
/** Indicates if the client is running the app-connector service */
AppConnector?: boolean;
/** Opaque hash of the most recent list of tailnet services (indicates config updates) */
ServicesHash?: string;
/** WireGuard endpoints (public IP:port pairs) from the network map */
Endpoints?: string[];
/** Geographical location data about the Tailscale host (optional) */
Location?: Location;
/** Home DERP region ID */
HomeDERP?: number;
/** Opaque hash of the most recent list of tailnet services (indicates config updates) */
ServicesHash?: string;
/** Geographical location data about the Tailscale host (optional) */
Location?: Location;
}
/** Represents a network service advertised by a node */
interface Service {
/** Protocol type (e.g., "tcp", "udp", "peerapi4") */
Proto: string;
/** Protocol type (e.g., "tcp", "udp", "peerapi4") */
Proto: string;
/** Port number */
Port: number;
/** Port number */
Port: number;
/** Textual description of the service (usually the process name) */
Description?: string;
/** Textual description of the service (usually the process name) */
Description?: string;
}
/** Networking information for a Tailscale node */
interface NetInfo {
/** Indicates if NAT mappings vary based on destination IP */
MappingVariesByDestIP?: boolean;
/** Indicates if NAT mappings vary based on destination IP */
MappingVariesByDestIP?: boolean;
/** Indicates if the router supports hairpinning */
HairPinning?: boolean;
/** Indicates if the router supports hairpinning */
HairPinning?: boolean;
/** Indicates if the host has IPv6 internet connectivity */
WorkingIPv6?: boolean;
/** Indicates if the host has IPv6 internet connectivity */
WorkingIPv6?: boolean;
/** Indicates if the OS supports IPv6 */
OSHasIPv6?: boolean;
/** Indicates if the OS supports IPv6 */
OSHasIPv6?: boolean;
/** Indicates if the host has UDP internet connectivity */
WorkingUDP?: boolean;
/** Indicates if the host has UDP internet connectivity */
WorkingUDP?: boolean;
/** Indicates if ICMPv4 works (empty if not checked) */
WorkingICMPv4?: boolean;
/** Indicates if ICMPv4 works (empty if not checked) */
WorkingICMPv4?: boolean;
/** Indicates if there is an existing portmap open (UPnP, PMP, PCP) */
HavePortMap?: boolean;
/** Indicates if there is an existing portmap open (UPnP, PMP, PCP) */
HavePortMap?: boolean;
/** Indicates if UPnP appears present on the LAN (empty if not checked) */
UPnP?: boolean;
/** Indicates if UPnP appears present on the LAN (empty if not checked) */
UPnP?: boolean;
/** Indicates if NAT-PMP appears present on the LAN (empty if not checked) */
PMP?: boolean;
/** Indicates if NAT-PMP appears present on the LAN (empty if not checked) */
PMP?: boolean;
/** Indicates if PCP appears present on the LAN (empty if not checked) */
PCP?: boolean;
/** Indicates if PCP appears present on the LAN (empty if not checked) */
PCP?: boolean;
/** Preferred DERP region ID */
PreferredDERP?: number;
/** Preferred DERP region ID */
PreferredDERP?: number;
/** Current link type ("wired", "wifi", "mobile") */
LinkType?: string;
/** Current link type ("wired", "wifi", "mobile") */
LinkType?: string;
/** Fastest recent time to reach various DERP STUN servers (seconds) */
DERPLatency?: Record<string, number>;
/** Fastest recent time to reach various DERP STUN servers (seconds) */
DERPLatency?: Record<string, number>;
/** Firewall mode on Linux-specific configurations */
FirewallMode?: string;
/** Firewall mode on Linux-specific configurations */
FirewallMode?: string;
}
/** Represents the geographical location of a Tailscale host */
interface Location {
/** Country name (user-friendly, properly capitalized) */
Country?: string;
/** Country name (user-friendly, properly capitalized) */
Country?: string;
/** ISO 3166-1 alpha-2 country code (upper case) */
CountryCode?: string;
/** ISO 3166-1 alpha-2 country code (upper case) */
CountryCode?: string;
/** City name (user-friendly, properly capitalized) */
City?: string;
/** City name (user-friendly, properly capitalized) */
City?: string;
/** City code to disambiguate between cities (e.g., IATA, ICAO, ISO 3166-2) */
CityCode?: string;
/** City code to disambiguate between cities (e.g., IATA, ICAO, ISO 3166-2) */
CityCode?: string;
/** Latitude of the node (in degrees, optional) */
Latitude?: number;
/** Latitude of the node (in degrees, optional) */
Latitude?: number;
/** Longitude of the node (in degrees, optional) */
Longitude?: number;
/** Longitude of the node (in degrees, optional) */
Longitude?: number;
/** Priority for exit node selection (0 means no priority, negative not allowed) */
Priority?: number;
/** Priority for exit node selection (0 means no priority, negative not allowed) */
Priority?: number;
}
+5 -5
View File
@@ -1,7 +1,7 @@
export type Key = {
id: string;
prefix: string;
expiration: string;
createdAt: Date;
lastSeen: Date;
id: string;
prefix: string;
expiration: string;
createdAt: Date;
lastSeen: Date;
};
+10 -10
View File
@@ -1,13 +1,13 @@
import type { User } from './User';
import type { User } from "./User";
export interface PreAuthKey {
id: string;
key: string;
user: User | null;
reusable: boolean;
ephemeral: boolean;
used: boolean;
expiration: string;
createdAt: string;
aclTags: string[];
id: string;
key: string;
user: User | null;
reusable: boolean;
ephemeral: boolean;
used: boolean;
expiration: string;
createdAt: string;
aclTags: string[];
}
+10 -10
View File
@@ -1,13 +1,13 @@
import type { Machine } from './Machine';
import type { Machine } from "./Machine";
export interface Route {
id: string;
node: Machine;
prefix: string;
advertised: boolean;
enabled: boolean;
isPrimary: boolean;
createdAt: string;
updatedAt: string;
deletedAt: string;
id: string;
node: Machine;
prefix: string;
advertised: boolean;
enabled: boolean;
isPrimary: boolean;
createdAt: string;
updatedAt: string;
deletedAt: string;
}
+8 -8
View File
@@ -1,10 +1,10 @@
export interface User {
id: string;
name: string;
createdAt: string;
displayName?: string;
email?: string;
providerId?: string;
provider?: string;
profilePicUrl?: string;
id: string;
name: string;
createdAt: string;
displayName?: string;
email?: string;
providerId?: string;
provider?: string;
profilePicUrl?: string;
}
+6 -6
View File
@@ -1,6 +1,6 @@
export * from './Key';
export * from './Machine';
export * from './Route';
export * from './User';
export * from './PreAuthKey';
export * from './HostInfo';
export * from "./Key";
export * from "./Machine";
export * from "./Route";
export * from "./User";
export * from "./PreAuthKey";
export * from "./HostInfo";
+2 -2
View File
@@ -1,5 +1,5 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
export default cn;
+26
View File
@@ -0,0 +1,26 @@
import { createCookie } from "react-router";
export type ColorScheme = "dark" | "light" | "system";
let cookie = createCookie("color_scheme", {
maxAge: 34560000,
sameSite: "lax",
});
export function isValidColorScheme(val: unknown): val is ColorScheme {
return typeof val === "string" && ["dark", "light", "system"].includes(val);
}
export async function getColorScheme(request: Request) {
const header = request.headers.get("Cookie");
const vals = await cookie.parse(header);
return ["dark", "light", "system"].includes(vals?.colorScheme) ? vals.colorScheme : "system";
}
export function setColorScheme(colorScheme: ColorScheme) {
if (colorScheme === "system") {
return cookie.serialize({}, { expires: new Date(0), maxAge: 0 });
}
return cookie.serialize({ colorScheme });
}
+25 -25
View File
@@ -1,36 +1,36 @@
import type { HostInfo } from '~/types';
import type { HostInfo } from "~/types";
export function getTSVersion(host: HostInfo) {
const { IPNVersion } = host;
if (!IPNVersion) {
return 'Unknown';
}
const { IPNVersion } = host;
if (!IPNVersion) {
return "Unknown";
}
// IPNVersion is <Semver>-<something>-<something>
return IPNVersion.split('-')[0];
// IPNVersion is <Semver>-<something>-<something>
return IPNVersion.split("-")[0];
}
export function getOSInfo(host: HostInfo) {
const { OS, OSVersion } = host;
// OS follows runtime.GOOS but uses iOS and macOS instead of darwin
const formattedOS = formatOS(OS);
const { OS, OSVersion } = host;
// OS follows runtime.GOOS but uses iOS and macOS instead of darwin
const formattedOS = formatOS(OS);
// Trim in case OSVersion is empty
return `${formattedOS} ${OSVersion ?? ''}`.trim();
// Trim in case OSVersion is empty
return `${formattedOS} ${OSVersion ?? ""}`.trim();
}
function formatOS(os?: string) {
switch (os) {
case 'macOS':
case 'iOS':
return os;
case 'windows':
return 'Windows';
case 'linux':
return 'Linux';
case undefined:
return 'Unknown';
default:
return os;
}
switch (os) {
case "macOS":
case "iOS":
return os;
case "windows":
return "Windows";
case "linux":
return "Linux";
case undefined:
return "Unknown";
default:
return os;
}
}
+6 -6
View File
@@ -12,6 +12,11 @@ export interface PopulatedNode extends Machine {
};
}
const GO_ZERO_TIMES = new Set(["0001-01-01 00:00:00", "0001-01-01T00:00:00Z"]);
export function isNoExpiry(expiry: string | null | undefined): boolean {
return expiry == null || GO_ZERO_TIMES.has(expiry);
}
export function mapNodes(
nodes: Machine[],
stats?: Record<string, HostInfo> | undefined,
@@ -35,12 +40,7 @@ export function mapNodes(
routes: Array.from(new Set(node.availableRoutes)),
hostInfo: stats?.[node.nodeKey],
customRouting,
expired:
node.expiry === "0001-01-01 00:00:00" ||
node.expiry === "0001-01-01T00:00:00Z" ||
node.expiry === null
? false
: new Date(node.expiry).getTime() < Date.now(),
expired: isNoExpiry(node.expiry) ? false : new Date(node.expiry!).getTime() < Date.now(),
};
});
}
+24 -24
View File
@@ -1,39 +1,39 @@
import { data } from 'react-router';
import { data } from "react-router";
export function send<T>(payload: T, init?: number | ResponseInit) {
return data(payload, init);
return data(payload, init);
}
export function send401<T>(payload: T) {
return data(payload, { status: 401 });
return data(payload, { status: 401 });
}
export function data400(message: string) {
return data(
{
success: false,
message,
},
{ status: 400 },
);
return data(
{
success: false,
message,
},
{ status: 400 },
);
}
export function data403(message: string) {
return data(
{
success: false,
message,
},
{ status: 403 },
);
return data(
{
success: false,
message,
},
{ status: 403 },
);
}
export function data404(message: string) {
return data(
{
success: false,
message,
},
{ status: 404 },
);
return data(
{
success: false,
message,
},
{ status: 404 },
);
}
+28 -28
View File
@@ -6,37 +6,37 @@
* - Over 1 month: "X months, Y days ago"
*/
export function formatTimeDelta(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const minutes = Math.floor(diffMs / (1000 * 60));
const hours = Math.floor(diffMs / (1000 * 60 * 60));
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
const months = Math.floor(days / 30);
const minutes = Math.floor(diffMs / (1000 * 60));
const hours = Math.floor(diffMs / (1000 * 60 * 60));
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
const months = Math.floor(days / 30);
if (minutes < 60) {
return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;
}
if (minutes < 60) {
return `${minutes} minute${minutes !== 1 ? "s" : ""} ago`;
}
if (hours < 24) {
const remainingMinutes = minutes % 60;
if (remainingMinutes === 0) {
return `${hours} hour${hours !== 1 ? 's' : ''} ago`;
}
return `${hours} hour${hours !== 1 ? 's' : ''}, ${remainingMinutes} minute${remainingMinutes !== 1 ? 's' : ''} ago`;
}
if (hours < 24) {
const remainingMinutes = minutes % 60;
if (remainingMinutes === 0) {
return `${hours} hour${hours !== 1 ? "s" : ""} ago`;
}
return `${hours} hour${hours !== 1 ? "s" : ""}, ${remainingMinutes} minute${remainingMinutes !== 1 ? "s" : ""} ago`;
}
if (days < 30) {
const remainingHours = hours % 24;
if (remainingHours === 0) {
return `${days} day${days !== 1 ? 's' : ''} ago`;
}
return `${days} day${days !== 1 ? 's' : ''}, ${remainingHours} hour${remainingHours !== 1 ? 's' : ''} ago`;
}
if (days < 30) {
const remainingHours = hours % 24;
if (remainingHours === 0) {
return `${days} day${days !== 1 ? "s" : ""} ago`;
}
return `${days} day${days !== 1 ? "s" : ""}, ${remainingHours} hour${remainingHours !== 1 ? "s" : ""} ago`;
}
const remainingDays = days % 30;
if (remainingDays === 0) {
return `${months} month${months !== 1 ? 's' : ''} ago`;
}
return `${months} month${months !== 1 ? 's' : ''}, ${remainingDays} day${remainingDays !== 1 ? 's' : ''} ago`;
const remainingDays = days % 30;
if (remainingDays === 0) {
return `${months} month${months !== 1 ? "s" : ""} ago`;
}
return `${months} month${months !== 1 ? "s" : ""}, ${remainingDays} day${remainingDays !== 1 ? "s" : ""} ago`;
}
+88 -57
View File
@@ -165,60 +165,91 @@ integration:
# OIDC Configuration for simpler authentication
# (This is optional, but recommended for the best experience)
# oidc:
# Set to false to define OIDC config without enabling it.
# Useful for Helm charts or generating docs from config files.
# enabled: true
# The OIDC issuer URL
# issuer: "https://accounts.google.com"
# DEPRECATED: Use headscale.api_key instead.
# If set, this will be used as a fallback for headscale.api_key.
# headscale_api_key: "<your-headscale-api-key>"
# If your OIDC provider does not support discovery (does not have the URL at
# `/.well-known/openid-configuration`), you need to manually set endpoints.
# This also works to override endpoints if you so desire or if your OIDC
# discovery is missing certain endpoints (ie GitHub).
# For some typical providers, see https://headplane.net/features/sso.
# authorization_endpoint: ""
# token_endpoint: ""
# userinfo_endpoint: ""
# The authentication method to use when communicating with the token endpoint.
# This is fully optional and Headplane will attempt to auto-detect the best
# method and fall back to `client_secret_basic` if unsure.
# token_endpoint_auth_method: "client_secret_post"
# The client ID for the OIDC client
# For the best experience please ensure this is *identical* to the client_id
# you are using for Headscale. because
# client_id: "your-client-id"
# The client secret for the OIDC client
# You may also provide `client_secret_path` instead to read a value from disk.
# See https://headplane.net/configuration/#sensitive-values
# client_secret: "<your-client-secret>"
# Whether to use PKCE when authenticating users. This is recommended as it
# adds an extra layer of security to the authentication process. Enabling this
# means your OIDC provider must support PKCE and it must be enabled on the
# client.
# use_pkce: true
# If you want to disable traditional login via Headscale API keys
# disable_api_key_login: false
# By default profile pictures are pulled from the OIDC provider when
# we go to fetch the userinfo endpoint. Optionally, this can be set to
# "oidc" or "gravatar" as of 0.6.1.
# profile_picture_source: "gravatar"
# The scopes to request when authenticating users. The default is below.
# scope: "openid email profile"
# Extra query parameters can be passed to the authorization endpoint
# by setting them here. This is useful for providers that require any kind
# of custom hinting.
# extra_params:
# prompt: "select_account" # Example: force account selection on Google
# # Set to false to define OIDC config without enabling it.
# # Useful for Helm charts or generating docs from config files.
# enabled: true
#
# # The OIDC issuer URL
# issuer: "https://accounts.google.com"
#
# # DEPRECATED: Use headscale.api_key instead.
# # If set, this will be used as a fallback for headscale.api_key.
# headscale_api_key: "<your-headscale-api-key>"
#
# # If your OIDC provider does not support discovery (does not have the URL at
# # `/.well-known/openid-configuration`), you need to manually set endpoints.
# # This also works to override endpoints if you so desire or if your OIDC
# # discovery is missing certain endpoints (ie GitHub).
# # For some typical providers, see https://headplane.net/features/sso.
# authorization_endpoint: ""
# token_endpoint: ""
# userinfo_endpoint: ""
#
# # RP-initiated logout (https://openid.net/specs/openid-connect-rpinitiated-1_0.html).
# # When true, /logout redirects the user to the IdP's end_session_endpoint
# # (auto-discovered or set manually below) so the upstream session is ended too.
# #
# # Disabled by default: the `post_logout_redirect_uri` MUST be pre-registered
# # in your OIDC client configuration on the IdP. If it isn't, users will land
# # on the provider's error page after logout.
# use_end_session: false
#
# # Optional. Override the auto-discovered end_session_endpoint, or supply one
# # if your provider does not advertise it via discovery.
# end_session_endpoint: ""
#
# # Where the identity provider should redirect after RP-initiated logout.
# # Most providers (Keycloak, Auth0, etc.) require this URL to be pre-registered
# # in the OIDC client configuration. If unset, Headplane defaults to its own
# # `<server.base_url>/admin/login?s=logout` page.
# post_logout_redirect_uri: ""
#
# # The authentication method to use when communicating with the token endpoint.
# # This is fully optional and Headplane will attempt to auto-detect the best
# # method and fall back to `client_secret_basic` if unsure.
# token_endpoint_auth_method: "client_secret_post"
#
# # The client ID for the OIDC client
# # For the best experience please ensure this is *identical* to the client_id
# # you are using for Headscale.
# client_id: "your-client-id"
#
# # The client secret for the OIDC client
# # You may also provide `client_secret_path` instead to read a value from disk.
# # See https://headplane.net/configuration/#sensitive-values
# client_secret: "<your-client-secret>"
#
# # Whether to use PKCE when authenticating users. This is recommended as it
# # adds an extra layer of security to the authentication process. Enabling
# # this means your OIDC provider must support PKCE and it must be enabled on
# # the client.
# use_pkce: true
#
# # If you want to disable traditional login via Headscale API keys
# disable_api_key_login: false
#
# # By default profile pictures are pulled from the OIDC provider when
# # we go to fetch the userinfo endpoint. Optionally, this can be set to
# # "oidc" or "gravatar" as of 0.6.1.
# profile_picture_source: "gravatar"
#
# # The scopes to request when authenticating users. The default is below.
# scope: "openid email profile"
#
# # Optional fallback claims to use when your provider does not return a standard
# # OIDC `sub` claim. Headplane always checks `sub` first, then each claim here
# # in order. For Feishu/Lark, `["open_id", "email"]` is a reasonable fallback.
# subject_claims:
# - "open_id"
# - "email"
#
# # Allow ID token verification with legacy RSA keys smaller than 2048 bits.
# # This is disabled by default because it lowers token verification security and
# # should only be used as a temporary compatibility workaround.
# allow_weak_rsa_keys: false
#
# # Extra query parameters can be passed to the authorization endpoint
# # by setting them here. This is useful for providers that require any kind
# # of custom hinting.
# extra_params:
# prompt: "select_account" # Example: force account selection on Google
+5
View File
@@ -1,6 +1,11 @@
import { defineConfig } from "vitepress";
export default defineConfig({
vite: {
define: {
__HEADPLANE_BETA_DOCS__: JSON.stringify(process.env.HEADPLANE_BETA_DOCS === "true"),
},
},
title: "Headplane",
description: "The missing dashboard for Headscale",
cleanUrls: true,
+31 -6
View File
@@ -1,17 +1,42 @@
html.dark .light-only {
display: none !important;
display: none !important;
}
html:not(.dark) .dark-only {
display: none !important;
display: none !important;
}
figure {
padding: 1em;
padding: 1em;
}
figcaption {
text-align: center;
font-size: 0.9em;
margin-top: 0.5em;
text-align: center;
font-size: 0.9em;
margin-top: 0.5em;
}
:root {
--vp-layout-top-height: 36px;
}
.beta-banner {
background-color: var(--vp-c-bg);
background-image: linear-gradient(var(--vp-c-warning-soft), var(--vp-c-warning-soft));
color: var(--vp-c-warning-1);
text-align: center;
padding: 8px 16px;
font-size: 14px;
font-weight: 500;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
}
.beta-banner a {
color: var(--vp-c-warning-1);
text-decoration: underline;
margin-left: 8px;
}
+3
View File
@@ -0,0 +1,3 @@
/// <reference types="vitepress/client" />
declare const __HEADPLANE_BETA_DOCS__: boolean;
+35 -3
View File
@@ -1,4 +1,36 @@
import DefaultTheme from 'vitepress/theme';
import './custom.css';
import type { Theme } from "vitepress";
import DefaultTheme from "vitepress/theme";
import { h } from "vue";
export default DefaultTheme;
import "./custom.css";
const beta = __HEADPLANE_BETA_DOCS__ ?? false;
export default {
extends: DefaultTheme,
Layout() {
return h(DefaultTheme.Layout, null, {
"layout-top": () =>
beta
? h(
"div",
{
class: "beta-banner",
},
[
"You are currently viewing the ",
h("strong", "beta"),
" documentation for Headplane. ",
h(
"a",
{
href: "https://headplane.net",
},
"Go to the stable docs →",
),
],
)
: null,
});
},
} satisfies Theme;
+4
View File
@@ -4,12 +4,15 @@ description: Common issues and their solutions
---
# Common Issues and Their Solutions
This document outlines some common issues users may encounter while using Headplane, along with their solutions.
## Login does not work
::: tip
Headplane tries to detect misconfigurations and will surface a warning banner on
the login page if it detects any abnormalities. You may see a banner like this:
<figure>
<img class="dark-only" src="../assets/login-banner-dark.png" />
<img class="light-only" src="../assets/login-banner-light.png" />
@@ -21,5 +24,6 @@ If you attempt to log in to Headplane but nothing happens, it may be due to a
misconfiguration of the server cookie settings. In your Headplane configuration,
ensure that `server.cookie_secure` is set appropriately based on how you are
accessing Headplane:
- Serving over HTTPS: `cookie_secure` should be enabled (`true`).
- Serving over HTTP: `cookie_secure` should be disabled (`false`).
+88 -5
View File
@@ -70,6 +70,8 @@ oidc:
# token_endpoint: ""
# userinfo_endpoint: ""
# scope: "openid email profile"
# subject_claims: ["open_id", "email"]
# allow_weak_rsa_keys: false
# extra_params:
# foo: "bar"
```
@@ -78,6 +80,40 @@ Headplane automatically discovers OIDC endpoints from your issuer's
`/.well-known/openid-configuration`. If your IdP does not support discovery,
you'll need to set the endpoints manually.
### Non-standard Subject Claims
Some providers do not return the standard OIDC `sub` claim in the ID token.
Headplane always uses `sub` first, but you can configure fallback claims with
`oidc.subject_claims`.
For Feishu/Lark, the recommended configuration is:
```yaml
oidc:
subject_claims: ["open_id", "email"]
```
This keeps identity matching stable by preferring `open_id` and only falling
back to `email` if needed.
### Legacy Weak RSA Signing Keys
Some legacy providers still sign ID tokens with RSA keys smaller than 2048
bits. Headplane rejects those keys by default.
If your provider cannot rotate to a stronger signing key yet, you can
explicitly enable the compatibility fallback:
```yaml
oidc:
allow_weak_rsa_keys: true
```
::: warning
This weakens ID token verification security and should only be used as a
temporary workaround while your provider rotates to a 2048-bit-or-larger key.
:::
### PKCE
::: warning
@@ -107,8 +143,9 @@ Headplane uses a two-step matching strategy:
1. **Subject match (primary)**: Headscale stores the IdP's `provider_id` for
each OIDC user (e.g. `https://idp.example.com/3d6f6e3f-...`). Headplane
extracts the last path segment and compares it to the `sub` claim from the
OIDC token. If they match, the user is linked.
extracts the last path segment and compares it to the resolved OIDC subject.
The resolved subject uses `sub` first, then falls back to any configured
`oidc.subject_claims`. If they match, the user is linked.
2. **Email match (fallback)**: If the subject doesn't match, Headplane falls
back to comparing the user's email address from the OIDC `userinfo` endpoint
@@ -198,6 +235,52 @@ When a new OIDC user signs in for the first time, they go through a brief
onboarding flow that helps them connect their first device to the Tailnet. This
flow can be skipped. Once completed, users are taken to the main dashboard.
## Single Logout (RP-Initiated Logout)
Headplane supports
[OpenID Connect RP-Initiated Logout](https://openid.net/specs/openid-connect-rpinitiated-1_0.html).
When enabled, clicking "Log Out" in the UI from an OIDC-backed session will:
1. Destroy the local Headplane session.
2. Redirect the browser to the identity provider's `end_session_endpoint`.
3. Pass along the original `id_token` as `id_token_hint`, plus a
`post_logout_redirect_uri` so the IdP can return the user to Headplane after
it has cleared its own session.
### Configuration
This feature is **disabled by default** because the `post_logout_redirect_uri`
must be pre-registered in your OIDC client on the IdP. Enabling it without that
registration will land users on the provider's error page after logout.
To enable it, set `oidc.use_end_session: true`:
```yaml
oidc:
# Required: opt in to RP-initiated logout
use_end_session: true
# Optional: override the auto-discovered end_session_endpoint, or set it
# manually if your provider does not expose it via discovery.
# end_session_endpoint: "https://idp.example.com/realms/main/protocol/openid-connect/logout"
# Optional. Defaults to `<server.base_url>/admin/login?s=logout`.
# post_logout_redirect_uri: "https://headplane.example.com/admin/login?s=logout"
```
If your provider exposes `end_session_endpoint` in its discovery document
(Keycloak, Authentik, Auth0, Azure AD, …) Headplane picks it up automatically
once `use_end_session` is `true`.
::: tip
Make sure the redirect URI you supply (or the default one Headplane builds) is
listed under the post-logout / valid redirect URIs in your IdP's client
configuration, otherwise the provider will refuse to redirect back.
:::
When `use_end_session` is `false` (the default), Headplane simply destroys its
own session and returns the user to the login page.
## Troubleshooting
### Common Issues
@@ -217,9 +300,9 @@ flow can be skipped. Once completed, users are taken to the main dashboard.
- **Invalid API Key**: The `headscale.api_key` may have expired. Generate
a new one with `headscale apikeys create --expiration 999d`.
- **Missing the `sub` claim**: Ensure your IdP includes the `sub` claim in the
ID token. This is required by the OIDC spec but some providers need explicit
configuration.
- **Missing the `sub` claim**: If your IdP omits `sub`, configure
`oidc.subject_claims` with a stable fallback such as `open_id`. Only use
`email` as a fallback when it is stable for your users.
- **Redirect URI Mismatch**: Ensure the redirect URI registered in your IdP
matches `{server.base_url}/admin/oidc/callback` exactly.
-1
View File
@@ -32,4 +32,3 @@ features:
details: "Manage settings hidden behind Headscale configuration such as DNS, networking, auth controls, etc."
icon: "📝"
---
+18 -13
View File
@@ -12,15 +12,16 @@ set up your configuration file and then pick the installation method that best
suits your needs.
## Configuration
Headplane requires a configuration file to operate. A
[sample file](https://github.com/tale/headplane/blob/main/config.example.yaml)
is available to use as a starting point. Some of the important fields include:
| Field | Description |
|---------------------|--------------------------------------------------------|
| **`headscale.url`** | Point to your Headscale server (e.g., `http://headscale.example.com` or `http://headscale:8080` in Docker). |
| **`server.cookie_secret`** | Used to encrypt cookies. You can generate a random string using a command like `openssl rand -base64 24`. |
| **`server.data_path`** | Just a path to keep in mind, especially if you're using Docker. |
| Field | Description |
| -------------------------- | ----------------------------------------------------------------------------------------------------------- |
| **`headscale.url`** | Point to your Headscale server (e.g., `http://headscale.example.com` or `http://headscale:8080` in Docker). |
| **`server.cookie_secret`** | Used to encrypt cookies. You can generate a random string using a command like `openssl rand -base64 24`. |
| **`server.data_path`** | Just a path to keep in mind, especially if you're using Docker. |
The configuration file is rather complicated and has many more options. Refer to
the [Configuration](../configuration/index.md) guide for a detailed explanation of all
@@ -28,24 +29,28 @@ the available options, as well as guidance on securely setting up your values
through secret path options and environment variables.
## Deployment Methods
Headplane can be deployed in several different ways, each with its own set of
advantages and trade-offs. Choose the method that best fits your needs:
### [Docker](./docker.md): Fast and easy deployment using Docker
- Recommended for most users due to its simplicity and ease of use.
- Allows for advanced features like network management and remote web SSH.
- Requires Docker and Docker Compose to be installed.
- Recommended for most users due to its simplicity and ease of use.
- Allows for advanced features like network management and remote web SSH.
- Requires Docker and Docker Compose to be installed.
---
### [Native Mode](./native-mode.md): Direct installation on a server
- Suitable for users who prefer not to use Docker.
- Allows for advanced features like network management and remote web SSH.
- Requires manual setup of dependencies and environment.
- Suitable for users who prefer not to use Docker.
- Allows for advanced features like network management and remote web SSH.
- Requires manual setup of dependencies and environment.
---
### [Limited Mode](./limited-mode.md): Quick and easy deployment with minimal features
- Ideal for testing or simple environments and not intended for production use.
- Lacks any advanced functionality or integrations such as network management
- Ideal for testing or simple environments and not intended for production use.
- Lacks any advanced functionality or integrations such as network management
or remote web SSH.
+9 -6
View File
@@ -12,16 +12,18 @@ deployment. Limited mode lacks advanced features such as network management,
remote web SSH, and more.
:::
Limited Mode is good for users who want to test out the *basic* functionality
Limited Mode is good for users who want to test out the _basic_ functionality
provided by Headplane. It only interacts with the Headplane API and lacks all
advanced features, making it suitable for local testing and development.
## Prerequisites
- Docker (and optionally Docker Compose)
- Headscale version 0.26.0 or later installed and running
- A [completed configuration file](/index.md#configuration) for Headplane.
- A [completed configuration file](/index.md#configuration) for Headplane.
## Installation
::: tip
If you want to test Limited Mode without Docker, you can follow the
[Native Mode](./native-mode.md) installation guide and simply avoid setting
@@ -29,6 +31,7 @@ up any of the advanced features.
:::
Running Headplane in Limited Mode is as simple as running 1 command:
```bash
docker run -d \
-p 3000:3000 \
@@ -44,6 +47,7 @@ storage location for Headplane to store its own data. You can also change the
port mapping if you want to run it on a different port.
### Optional: Docker Compose
If you prefer using Docker Compose, here is a minimal example of a
`compose.yaml` file that runs Headplane in Limited Mode:
@@ -54,10 +58,10 @@ services:
container_name: headplane
restart: unless-stopped
ports:
- '3000:3000'
- "3000:3000"
volumes:
- '/path/to/your/config.yaml:/etc/headplane/config.yaml'
- '/path/to/data/storage:/var/lib/headplane'
- "/path/to/your/config.yaml:/etc/headplane/config.yaml"
- "/path/to/data/storage:/var/lib/headplane"
```
## Accessing Headplane
@@ -83,4 +87,3 @@ Limited Mode also technically supports
[Single Sign-On (SSO) authentication](../features/sso.md), but some parts of it
may not work as expected. For a full-featured experience with SSO, please use
one of the other installation methods.
+12
View File
@@ -0,0 +1,12 @@
{
"include": [".vitepress/**/*"],
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ES2022",
"lib": ["DOM", "DOM.Iterable", "ES2023"],
"noEmit": true,
"strict": true,
"skipLibCheck": true
}
}
+6 -6
View File
@@ -1,8 +1,8 @@
import { defineConfig } from 'drizzle-kit';
import { defineConfig } from "drizzle-kit";
export default defineConfig({
dialect: 'sqlite',
schema: './app/server/db/schema.ts',
dbCredentials: {
url: 'file:test/hp_persist.db',
},
dialect: "sqlite",
schema: "./app/server/db/schema.ts",
dbCredentials: {
url: "file:test/hp_persist.db",
},
});
@@ -0,0 +1 @@
ALTER TABLE `auth_sessions` ADD `oidc_id_token` text;
@@ -0,0 +1,276 @@
{
"version": "7",
"dialect": "sqlite",
"id": "f0bdd789-6848-40b3-a8b6-99b4d1f6ef07",
"prevIds": ["dd3ce0c0-106f-4628-8c36-bdf9747e5f41"],
"ddl": [
{
"name": "auth_sessions",
"entityType": "tables"
},
{
"name": "host_info",
"entityType": "tables"
},
{
"name": "users",
"entityType": "tables"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "auth_sessions"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "kind",
"entityType": "columns",
"table": "auth_sessions"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "user_id",
"entityType": "columns",
"table": "auth_sessions"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "api_key_hash",
"entityType": "columns",
"table": "auth_sessions"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "api_key_display",
"entityType": "columns",
"table": "auth_sessions"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "oidc_id_token",
"entityType": "columns",
"table": "auth_sessions"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "expires_at",
"entityType": "columns",
"table": "auth_sessions"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "created_at",
"entityType": "columns",
"table": "auth_sessions"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "host_id",
"entityType": "columns",
"table": "host_info"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "payload",
"entityType": "columns",
"table": "host_info"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "updated_at",
"entityType": "columns",
"table": "host_info"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "users"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "sub",
"entityType": "columns",
"table": "users"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "name",
"entityType": "columns",
"table": "users"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "email",
"entityType": "columns",
"table": "users"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "picture",
"entityType": "columns",
"table": "users"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": "'member'",
"generated": null,
"name": "role",
"entityType": "columns",
"table": "users"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "headscale_user_id",
"entityType": "columns",
"table": "users"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "created_at",
"entityType": "columns",
"table": "users"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "updated_at",
"entityType": "columns",
"table": "users"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "last_login_at",
"entityType": "columns",
"table": "users"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": "0",
"generated": null,
"name": "caps",
"entityType": "columns",
"table": "users"
},
{
"columns": ["id"],
"nameExplicit": false,
"name": "auth_sessions_pk",
"table": "auth_sessions",
"entityType": "pks"
},
{
"columns": ["host_id"],
"nameExplicit": false,
"name": "host_info_pk",
"table": "host_info",
"entityType": "pks"
},
{
"columns": ["id"],
"nameExplicit": false,
"name": "users_pk",
"table": "users",
"entityType": "pks"
},
{
"columns": ["sub"],
"nameExplicit": false,
"name": "users_sub_unique",
"entityType": "uniques",
"table": "users"
},
{
"columns": ["headscale_user_id"],
"nameExplicit": false,
"name": "users_headscale_user_id_unique",
"entityType": "uniques",
"table": "users"
}
],
"renames": []
}
Generated
+3 -3
View File
@@ -40,11 +40,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1775126147,
"narHash": "sha256-J0dZU4atgcfo4QvM9D92uQ0Oe1eLTxBVXjJzdEMQpD0=",
"lastModified": 1776949667,
"narHash": "sha256-GMSVw35Q+294GlrTUKlx087E31z7KurReQ1YHSKp5iw=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "8d8c1fa5b412c223ffa47410867813290cdedfef",
"rev": "01fbdeef22b76df85ea168fbfe1bfd9e63681b30",
"type": "github"
},
"original": {
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Headplane</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/app/spa/main.tsx"></script>
</body>
</html>
+20 -1
View File
@@ -133,11 +133,30 @@ func (s *TSAgent) FetchAllHostInfo(ctx context.Context) (map[string]json.RawMess
return
}
data, err := json.Marshal(whois.Node.Hostinfo)
// Merge hostinfo with connection status from PeerStatus
var merged map[string]any
raw, err := json.Marshal(whois.Node.Hostinfo)
if err != nil {
log.Debug("Failed to marshal hostinfo for %s (%s): %s", nodeID, ip, err)
return
}
if err := json.Unmarshal(raw, &merged); err != nil {
log.Debug("Failed to unmarshal hostinfo for %s (%s): %s", nodeID, ip, err)
return
}
endpoints := make([]string, len(whois.Node.Endpoints))
for i, ep := range whois.Node.Endpoints {
endpoints[i] = ep.String()
}
merged["Endpoints"] = endpoints
merged["HomeDERP"] = whois.Node.HomeDERP
data, err := json.Marshal(merged)
if err != nil {
log.Debug("Failed to marshal merged info for %s (%s): %s", nodeID, ip, err)
return
}
mu.Lock()
result[nodeID] = json.RawMessage(data)
+23 -23
View File
@@ -1,34 +1,34 @@
import fs from "node:fs";
function renderOptions(options) {
const blocks = Object.keys(options).map((key) => {
const opt = options[key];
const name = key.split(".").slice(2).join(".");
const lines = [];
lines.push(`## ${name}`);
lines.push(`*Description:* ${opt.description}\n`);
lines.push(`*Type:* ${opt.type}\n`);
if (opt.default) {
lines.push(`*Default:* \`${opt.default.text}\`\n`);
}
if (opt.example) {
lines.push(`*Example:* \`${opt.example.text}\`\n`);
}
return lines.join("\n");
});
const blocks = Object.keys(options).map((key) => {
const opt = options[key];
const name = key.split(".").slice(2).join(".");
const lines = [];
lines.push(`## ${name}`);
lines.push(`*Description:* ${opt.description}\n`);
lines.push(`*Type:* ${opt.type}\n`);
if (opt.default) {
lines.push(`*Default:* \`${opt.default.text}\`\n`);
}
if (opt.example) {
lines.push(`*Example:* \`${opt.example.text}\`\n`);
}
return lines.join("\n");
});
return [
`# NixOS module options
return [
`# NixOS module options
|
|All options must be under \`services.headplane\`.
|
|For example: \`settings.headscale.config_path\` becomes \`services.headplane.settings.headscale.config_path\`.`
.split("|")
.map((s) => s.replace(/\n\s+/g, ""))
.join("\n"),
]
.concat(blocks)
.join("\n\n");
.split("|")
.map((s) => s.replace(/\n\s+/g, ""))
.join("\n"),
]
.concat(blocks)
.join("\n\n");
}
const filename = process.argv[2];
+1 -1
View File
@@ -33,7 +33,7 @@ in
inherit (finalAttrs) pname version src;
fetcherVersion = 3;
pnpm = pnpm_10;
hash = "sha256-oJt5ysYXytwNR8Yx5nkEN++YQNaf9EO4fP4CPrChUPo=";
hash = "sha256-NGIeboj/2kXuWsmTVl1fv4LgU1VYRdO+qSnNLVuneC8=";
};
buildPhase = ''
+45 -43
View File
@@ -1,15 +1,15 @@
{
"name": "headplane",
"version": "0.7.0-beta.2",
"version": "0.7.0-beta.3",
"private": true,
"type": "module",
"sideEffects": false,
"scripts": {
"preinstall": "npx only-allow pnpm",
"build": "react-router build",
"dev": "HEADPLANE_CONFIG_PATH=./config.example.yaml react-router dev",
"start": "node build/server/index.js",
"typecheck": "react-router typegen && tsgo",
"build": "vite build",
"dev": "HEADPLANE_CONFIG_PATH=./config.example.yaml HEADPLANE_SERVER__DATA_PATH=./.data tsx app/server/hono-dev.ts",
"start": "tsx app/server/hono-main.ts",
"typecheck": "fate generate && react-router typegen && tsgo",
"test:unit": "vitest run --project unit",
"test:integration": "vitest run --project integration:*",
"docs:dev": "vitepress dev docs",
@@ -20,65 +20,70 @@
"format": "oxfmt"
},
"dependencies": {
"@base-ui/react": "^1.2.0",
"@base-ui/react": "^1.3.0",
"@codemirror/language": "^6.12.3",
"@codemirror/view": "^6.41.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^7.0.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/inter": "^5.2.8",
"@hono/node-server": "^2.0.2",
"@iconify/react": "^6.0.2",
"@kubernetes/client-node": "^1.4.0",
"@react-router/node": "^7.13.1",
"@readme/openapi-parser": "^5.5.0",
"@uiw/codemirror-theme-github": "4.25.1",
"@uiw/codemirror-theme-xcode": "4.25.5",
"@uiw/react-codemirror": "4.25.5",
"arktype": "^2.1.29",
"@lezer/highlight": "^1.2.3",
"@nkzw/fate": "^1.0.2",
"@react-router/node": "^7.14.0",
"@readme/openapi-parser": "^6.0.1",
"@tanstack/react-router": "^1.170.3",
"@uiw/react-codemirror": "4.25.9",
"arktype": "^2.2.0",
"clsx": "^2.1.1",
"drizzle-orm": "1.0.0-beta.16-c2458b2",
"isbot": "5.1.35",
"jose": "6.2.1",
"drizzle-orm": "1.0.0-beta.21",
"hono": "^4.12.19",
"isbot": "5.1.37",
"jose": "6.2.2",
"js-yaml": "^4.1.1",
"lucide-react": "^0.575.0",
"lucide-react": "^1.8.0",
"mime": "^4.1.0",
"openapi-types": "^12.1.3",
"react": "19.2.4",
"react-codemirror-merge": "4.25.5",
"react-dom": "19.2.4",
"react": "19.2.5",
"react-codemirror-merge": "4.25.9",
"react-dom": "19.2.5",
"react-error-boundary": "^6.1.1",
"react-router": "^7.13.1",
"react-router-dom": "^7.13.1",
"react-router-hono-server": "2.25.0",
"remix-utils": "^9.0.1",
"react-fate": "^1.0.2",
"react-router": "^7.14.0",
"react-router-dom": "^7.14.0",
"restty": "^0.1.35",
"tailwind-merge": "3.5.0",
"ulidx": "2.4.1",
"undici": "7.22.0",
"yaml": "2.8.2"
"undici": "8.0.2",
"yaml": "2.8.3"
},
"devDependencies": {
"@react-router/dev": "^7.13.1",
"@react-router/dev": "^7.14.0",
"@shopify/lang-jsonc": "^1.0.1",
"@tailwindcss/vite": "^4.2.1",
"@tailwindcss/vite": "^4.2.2",
"@tanstack/router-plugin": "^1.168.4",
"@types/js-yaml": "^4.0.9",
"@types/node": "^25.3.1",
"@types/node": "^25.6.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@typescript/native-preview": "7.0.0-dev.20260225.1",
"drizzle-kit": "1.0.0-beta.16-c2458b2",
"lefthook": "^2.1.1",
"oxfmt": "^0.35.0",
"oxlint": "^1.50.0",
"oxlint-tsgolint": "^0.16.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.2.1",
"@typescript/native-preview": "7.0.0-dev.20260410.1",
"@vitejs/plugin-react": "^6.0.2",
"drizzle-kit": "1.0.0-beta.21",
"lefthook": "^2.1.5",
"oxfmt": "^0.44.0",
"oxlint": "^1.59.0",
"oxlint-tsgolint": "^0.20.0",
"tailwindcss": "^4.2.2",
"tailwindcss-animate": "^1.0.7",
"testcontainers": "^11.12.0",
"testcontainers": "^11.14.0",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vite": "^8.0.0",
"typescript": "^6.0.2",
"vite": "^8.0.8",
"vitepress": "next",
"vitest": "^4.1.0"
"vitest": "^4.1.4"
},
"engines": {
"node": ">=24.2 <25",
@@ -106,9 +111,6 @@
"ssh2",
"utf-8-validate"
],
"patchedDependencies": {
"react-router-hono-server": "patches/react-router-hono-server.patch"
},
"ignoredBuiltDependencies": [
"better-sqlite3"
],
-20
View File
@@ -1,20 +0,0 @@
diff --git a/dist/adapters/node.js b/dist/adapters/node.js
index e081ab36a04ea100cf5da3fb5eb54c5174186f4d..35433810d05a829f2c824d63714e71bf5ed069a0 100644
--- a/dist/adapters/node.js
+++ b/dist/adapters/node.js
@@ -49,13 +49,13 @@ async function createHonoServer(options) {
}
await mergedOptions.beforeAll?.(app);
app.use(
- `/${import.meta.env.REACT_ROUTER_HONO_SERVER_ASSETS_DIR}/*`,
+ `${import.meta.env.REACT_ROUTER_HONO_SERVER_BASENAME}${import.meta.env.REACT_ROUTER_HONO_SERVER_ASSETS_DIR}/*`,
cache(60 * 60 * 24 * 365),
// 1 year
serveStatic({ root: clientBuildPath, ...mergedOptions.serveStaticOptions?.clientAssets })
);
app.use(
- "*",
+ `${import.meta.env.REACT_ROUTER_HONO_SERVER_BASENAME}*`,
cache(60 * 60),
// 1 hour
serveStatic({ root: PRODUCTION ? clientBuildPath : "./public", ...mergedOptions.serveStaticOptions?.publicAssets })

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