Compare commits

...

75 Commits

Author SHA1 Message Date
Aarnav Tale 262c7bf82a chore: v0.7.0-beta.4 2026-05-31 14:46:03 -04:00
Aarnav Tale f8b23a5c89 chore: changelog 2026-05-31 14:39:53 -04:00
Aarnav Tale 775b81a7fa feat: support multiple CAs through documentation 2026-05-31 14:38:10 -04:00
Aarnav Tale 62817efa6e feat: gate the healthcheck listen file to docker only 2026-05-30 20:46:32 -04:00
Aarnav Tale b95d601ff6 feat: automate health check with a written file 2026-05-30 20:45:15 -04:00
Aarnav Tale ea27c846e2 feat: add support for https 2026-05-30 19:39:02 -04:00
Aarnav Tale 8c508e0602 feat(users): expose rename and delete for unlinked Headscale users
The user-actions handler already supported `rename_user` and
`delete_user` (both keyed by headscale_user_id), but the Unlinked
Headscale Users table had no menu to invoke them. That left admins
with no UI path to remove or rename Headscale users that have no
Headplane counterpart — e.g. users created via the Headscale CLI
before an OIDC migration.

Wire the existing dialogs into a new HeadscaleUserMenu rendered in
the row's actions column. OIDC-managed users still can't be renamed
(Headscale rejects it), so the Rename item hides for those.

Closes #525

Amp-Thread-ID: https://ampcode.com/threads/T-019e7ae4-6862-760c-a3e7-239350eab71d
Co-authored-by: Amp <amp@ampcode.com>
2026-05-30 18:49:27 -04:00
Aarnav Tale 0a51182eed fix(pre-auth-keys): pass Headscale numeric user id when expiring on 0.27.x
The pre-0.28 ExpirePreAuthKey RPC takes a uint64 `user` field plus the
key string. The API layer reads that uint64 from `key.user?.id`, but
the action was wrapping the form's user_id as `{ name: user }` — so
.id was always undefined and the wire request sent an empty string,
which Headscale rejects with "proto: invalid value for uint64 field
user". 0.28+ is unaffected because the new expire endpoint only reads
`key.id` (the stable preauthkey id).

Pass `{ id: user }` so the numeric Headscale user id reaches the wire.

Amp-Thread-ID: https://ampcode.com/threads/T-019e7ae4-6862-760c-a3e7-239350eab71d
Co-authored-by: Amp <amp@ampcode.com>
2026-05-30 18:49:11 -04:00
Aarnav Tale 22a521dff5 fix(machines): register node by Headscale username, not numeric id
Headscale's RegisterNodeRequest.user proto field is a string that the
RegisterNode handler resolves with GetUserByName (a strict WHERE name=?
SQL match) — there is no numeric-id fallback. The Owner select in the
Register Machine Key dialog was sending user.id, so registration failed
with ErrUserNotFound whenever the display name and the numeric id
disagreed (which is always for unlinked Headscale users).

Send user.name instead.

Closes #532

Amp-Thread-ID: https://ampcode.com/threads/T-019e7ae4-6862-760c-a3e7-239350eab71d
Co-authored-by: Amp <amp@ampcode.com>
2026-05-30 18:48:57 -04:00
Aarnav Tale de07372427 fix(tooltip): anchor above trigger with collision padding
The last row of the machines table opened tooltips below the trigger,
where they got clipped by the viewport — the page could scroll to
reveal them, but the popup was invisible on hover. Explicitly pin the
side to top and add 8px of collision padding so the Base UI flip logic
has enough room to keep the popup on-screen for last-row triggers.

Closes #508

Amp-Thread-ID: https://ampcode.com/threads/T-019e7ae4-6862-760c-a3e7-239350eab71d
Co-authored-by: Amp <amp@ampcode.com>
2026-05-30 18:48:39 -04:00
Aarnav Tale 2584a4ef55 fix(dialog): stop clipping focus rings in scrollable content
#556 added overflow-y-auto to the dialog content container. Per the CSS
spec, when one overflow axis is non-visible the other resets from
visible to auto, so overflow-x was being silently clamped too — clipping
the ~2px focus ring on inputs/buttons against the left edge of the
container.

Add px-1/-mx-1 so the layout width is unchanged while focus rings get
room to render.

Amp-Thread-ID: https://ampcode.com/threads/T-019e7ae4-6862-760c-a3e7-239350eab71d
Co-authored-by: Amp <amp@ampcode.com>
2026-05-30 18:48:27 -04:00
Aarnav Tale 30b528c491 docs(install): use CMD prefix in Docker healthcheck example
Docker Compose requires the test array to start with NONE, CMD, or CMD-SHELL.
The previous example was missing the prefix, so Compose treated the binary
path as the shell command, the healthcheck was rejected, and the container
was marked unhealthy — which causes Traefik (and similar) to skip it.

Closes #535

Amp-Thread-ID: https://ampcode.com/threads/T-019e7ae4-6862-760c-a3e7-239350eab71d
Co-authored-by: Amp <amp@ampcode.com>
2026-05-30 18:47:57 -04:00
Aarnav Tale d5acba88c7 chore: changelog 2026-05-30 17:43:24 -04:00
Aarnav Tale d7f1d665a4 feat: bump headscale minimum to 0.27 2026-05-30 17:32:00 -04:00
Aarnav Tale 7901f37002 fix: correctly handle user id passthrough on headscale actions 2026-05-30 17:14:28 -04:00
Aarnav Tale 7a62359b6a chore: add a resilience test 2026-05-30 16:25:00 -04:00
Vitalij Dovhanyc 2e38a1d5e3 fix: constrain dialog panel height and make content scrollable (#556)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-05-30 16:21:17 -04:00
github-actions[bot] 7221f1c2c3 chore: update flake.lock (#545) 2026-05-27 20:37:47 -04:00
Aarnav Tale 56c5e5ac8c feat: make api calls more resilient and stuff 2026-05-25 17:14:26 -04:00
github-actions[bot] 09be95c7bc chore: update nix pnpm deps hash 2026-05-25 15:53:44 +00:00
Aarnav Tale 0512565f8e feat: replace openapi hashing system with /version
Apparently I didn't use my brain cells and rely on the /version
endpoint that Headscale has exposed since 0.26 (our lowest supported
version). Switching to that significantly simplifies the API surface.
2026-05-25 11:51:02 -04:00
Aarnav Tale d4eee702e9 refactor(server): replace AppContext undefined sentinels with Feature<T>
Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019e5550-4435-7118-8393-cdcc97042178
2026-05-23 16:56:34 -04:00
Aarnav Tale fb4b0b1404 chore: v0.7.0-beta.3 2026-05-14 13:50:54 -04:00
Aarnav Tale 1e0ff7ead6 fix: encode headscale rename path segments
(cherry picked from commit 623e7c03f1)
2026-05-14 13:47:04 -04:00
github-actions[bot] c6b6cbc122 chore: update nix pnpm deps hash 2026-04-27 03:56:34 +00:00
Aarnav Tale deb284e2b4 feat: ditch hono 2026-04-26 23:52:49 -04:00
Aarnav Tale 5a2098eea5 chore: format everything with oxfmt 2026-04-26 20:38:45 -04:00
Aarnav Tale b961b339bb feat: add support for OIDC logouts
Closes HP-407.
2026-04-26 20:36:52 -04:00
Aarnav Tale ac6f9e4f7e feat: support toggling light or dark color schemes
Fixes HP-375.
2026-04-26 20:33:48 -04:00
github-actions[bot] 3026b33834 chore: update flake.lock (#533)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-26 16:10:33 -04:00
Aarnav Tale ecd284b5d8 Merge pull request #537 from croatialu/feat/oidc-weak-rsa-fallback 2026-04-26 16:10:05 -04:00
Aarnav Tale 4cf4e5c040 fix: use thin scrollbars that actually work
Closes HP-536.
2026-04-26 10:15:44 -04:00
croatialu 9e5e5a613a fix: harden OIDC weak RSA fallback 2026-04-22 00:07:47 +08:00
Aarnav Tale 4d252833ef fix(ui): correctly handle mobile breakpoints for the navbar
Fixes HP-529.
2026-04-20 21:46:57 -04:00
Aarnav Tale 9238f69bfc Merge pull request #524 from tale/update_flake_lock_action 2026-04-17 16:59:14 -04:00
croatialu d110dd2bcb feat: add OIDC subject claim fallbacks 2026-04-16 18:07:57 +08:00
croatialu addef55f30 Add weak RSA OIDC verification fallback 2026-04-16 18:04:48 +08:00
github-actions[bot] 67c6c0b453 flake.lock: Update
Flake lock file updates:

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

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

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

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

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/9cf7092' (2026-03-18)
  → 'github:nixos/nixpkgs/8d8c1fa' (2026-04-02)
2026-04-05 08:45:30 +00:00
Aarnav Tale 4b47b1bbed chore: update auth-service to not be class based 2026-04-03 16:57:39 -04:00
Aarnav Tale 003985d192 docs: document the modular architecture for services 2026-04-03 16:36:52 -04:00
Aarnav Tale 1259642f8a feat: replace openid-client with clean-room oidc system 2026-04-03 16:36:27 -04:00
Aarnav Tale 4cd0c1e206 fix: use headscale.api_key where possible 2026-04-03 16:34:50 -04:00
Robbe Pincket b2dfa773b0 fix: change oidc config error notice url 2026-04-01 22:17:11 +02:00
212 changed files with 11138 additions and 7411 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
+1
View File
@@ -13,3 +13,4 @@ node_modules
/docs/.vitepress/dist/
/docs/.vitepress/cache/
/.direnv
/vendor
+1
View File
@@ -1 +1,2 @@
side-effects-cache = false
public-hoist-pattern[]=vue
+59 -23
View File
@@ -1,31 +1,67 @@
# 0.7.0-beta.1 (March 27, 2026)
# 0.7.0-beta.4 (May 31, 2026)
> This is a beta release. Please report any issues you encounter.
- **Migrated all UI components from react-aria/react-stately to @base-ui-components/react.**
- Removed `react-aria`, `react-stately`, and `tailwindcss-react-aria-components` as dependencies.
- **Rearchitected the Headplane Agent** from a long-running stdin/stdout daemon to a one-shot sync model (closes [#350](https://github.com/tale/headplane/issues/350), closes [#455](https://github.com/tale/headplane/issues/455)).
- The Go binary connects to the Tailnet, fetches all peer hostinfo as JSON, and exits.
- The Node.js manager auto-generates ephemeral tag-only pre-auth keys (requires Headscale 0.28+).
- Deprecated `integration.agent.pre_authkey` and `integration.agent.cache_path` config fields.
- Added `integration.agent.executable_path` config field.
- **Consolidated the Headscale API key** under `headscale.api_key` (and `headscale.api_key_path`).
- Deprecated `oidc.headscale_api_key` — it is still read as a fallback but will be removed in a future release.
- Both the agent and OIDC now use the same key from `headscale.api_key`.
- **Reworked the authentication system** with a new `AuthService` that consolidates session management and role enforcement (via [#489](https://github.com/tale/headplane/pull/489)).
- Added an agent status page at `/settings/agent` showing sync status, node count, errors, and a "Sync Now" button.
- Added additional machine list filters for user, tag, status, and route (via [#507](https://github.com/tale/headplane/pull/507), closes [#506](https://github.com/tale/headplane/issues/506)).
- **Headplane now requires Headscale 0.27.0 or newer.** Support for 0.26.x has been dropped. If `/version` returns 404 (the endpoint was added in 0.27.0), Headplane logs an error and keeps retrying so an in-place Headscale upgrade is picked up without a restart.
- **Replaced the OpenAPI hash detection with `/version`.** Capabilities are now derived from the version reported by `/version` instead of fingerprinting the OpenAPI schema. This dramatically simplifies version detection and works with every supported release out of the box.
- **Made Headscale boot resilient.** Headplane now boots even when Headscale is unreachable; capabilities default permissively and a background retry settles them once Headscale responds. No more cold-start ordering problems with docker-compose.
- **Added optional in-process TLS termination.** Setting `server.tls_cert_path` and `server.tls_key_path` makes Headplane serve HTTPS/1.1 on `server.port` directly — no reverse proxy required. `server.cookie_secure` is auto-forced to `true` (with a warning) whenever TLS is enabled, since browsers refuse `Secure`-less cookies over HTTPS. HTTP/2 and HTTP/3 are intentionally not supported in-process; terminate those at a reverse proxy if you need them (closes [#403](https://github.com/tale/headplane/issues/403)).
- **Made the bundled Docker healthcheck zero-config across HTTP and HTTPS.** Headplane writes its loopback URL (scheme, port, and basename included) to `/tmp/headplane-listen` on startup, and `hp_healthcheck` reads that file and probes the URL verbatim. Enabling TLS or changing `server.port` no longer requires any healthcheck-specific configuration. Native installs are unaffected — the listen file is only written when `HEADPLANE_LISTEN_FILE` is set, which the Dockerfile does automatically.
- Added Rename and Delete actions for unlinked Headscale users on the Users page so admins can manage Headscale users that have no Headplane account (closes [#525](https://github.com/tale/headplane/issues/525)).
- Documented [Custom Certificate Authorities](/configuration/tls#custom-certificate-authorities) for trusting private or self-signed CAs across every outbound TLS connection (OIDC, Headscale, Docker, etc.) via Node's `NODE_EXTRA_CA_CERTS`. This replaces the previous workaround of rebuilding the Docker image to extend the system trust store (closes [#313](https://github.com/tale/headplane/issues/313)).
- Fixed user-management actions (link, change role, transfer ownership) using the wrong ID type for unlinked Headplane users. Form fields are now explicitly `headplane_user_id` vs. `headscale_user_id`, and the auth layer no longer round-trips through Headscale to recover the OIDC subject.
- Fixed the "Register Machine Key" dialog passing the Headscale numeric user id instead of the username. Headscale's `RegisterNodeRequest.user` proto field is a `string` looked up via `GetUserByName` (no numeric fallback), so registration was failing whenever the selected owner's display name differed from their numeric id (closes [#532](https://github.com/tale/headplane/issues/532)).
- Fixed pre-auth key expiration on Headscale 0.27.x. The pre-0.28 expire endpoint takes a `uint64 user` field which the API layer reads from `key.user?.id`, but the caller was wrapping the id as `{ name: user }`, causing the request to send an empty user field. Headplane now correctly passes the numeric Headscale user id.
- Fixed dialog panels growing beyond the viewport; dialog content is now constrained and scrollable (via [#556](https://github.com/tale/headplane/pull/556)).
- Fixed focus rings on inputs and buttons inside dialogs being clipped by the scrollable content container.
- Fixed tooltips on the last row of the machines table being clipped by the viewport; tooltips now anchor above the trigger with collision padding (closes [#508](https://github.com/tale/headplane/issues/508)).
- Corrected the Docker healthcheck example in the docs to use the required `CMD` prefix so reverse proxies don't see the container as unhealthy (closes [#535](https://github.com/tale/headplane/issues/535)).
---
# 0.7.0-beta.3 (May 14, 2026)
> This is a beta release. Please report any issues you encounter.
- Fixed GHSA-vgj6-hcf2-fqf6, a path traversal / RBAC bypass in Headscale node and user rename API calls.
---
# 0.6.3 (May 14, 2026)
- Fixed GHSA-vgj6-hcf2-fqf6, a path traversal / RBAC bypass in Headscale node and user rename API calls.
---
# 0.7.0-beta.2 (April 9, 2026)
> This is a beta release. Please report any issues you encounter.
- **Rebuilt the user model to enable "account linking" between Headplane and Headscale.** OIDC users are automatically linked to their Headscale counterparts based on subject and email. Users who cannot be automatically linked can claim an unlinked Headscale user during onboarding. See the [SSO docs](/features/sso) for details (via [#489](https://github.com/tale/headplane/pull/489)).
- **Rebuilt Browser SSH** with a new terminal powered by [Ghostty WASM](https://restty.dev), improved session handling, and support for custom DERP ports. See the [Browser SSH docs](/features/ssh) for details (closes [#515](https://github.com/tale/headplane/issues/515), closes [#386](https://github.com/tale/headplane/issues/386)).
- **Rearchitected the Headplane Agent** with a periodic sync model and extensive caching. The agent now auto-generates ephemeral pre-auth keys (requires Headscale 0.28+). See the [Agent docs](/features/agent) for details (closes [#350](https://github.com/tale/headplane/issues/350), closes [#455](https://github.com/tale/headplane/issues/455)).
- **Replaced `openid-client` with a new OIDC implementation.** Fixes `client_secret_basic` not working with Google SSO and other providers (closes [#493](https://github.com/tale/headplane/issues/493), closes [#516](https://github.com/tale/headplane/issues/516)).
- **Migrated all UI components from react-aria to [Base UI](https://base-ui.com).**
- **Consolidated the Headscale API key** under `headscale.api_key` (and `headscale.api_key_path`). Deprecated `oidc.headscale_api_key` — it is still read as a fallback but will be removed in a future release.
- Added machine list filters for user, tag, status, and route (via [#507](https://github.com/tale/headplane/pull/507), closes [#506](https://github.com/tale/headplane/issues/506)).
- Added self-service pre-auth key creation for auditor role users (via [#478](https://github.com/tale/headplane/pull/478), closes [#453](https://github.com/tale/headplane/issues/453)).
- Fetch OIDC profile pictures server-side when the URL requires authentication (via [#510](https://github.com/tale/headplane/pull/510), closes [#326](https://github.com/tale/headplane/issues/326)).
- Fixed first user not being assigned the owner role on OIDC login (via [#480](https://github.com/tale/headplane/pull/480), closes [#266](https://github.com/tale/headplane/issues/266)).
- Fixed login errors throwing an unexpected server error instead of showing form validation (via [#475](https://github.com/tale/headplane/pull/475), closes [#474](https://github.com/tale/headplane/issues/474)).
- Fixed agent HostInfo not refreshing periodically using `cache_ttl` (via [#477](https://github.com/tale/headplane/pull/477), closes [#427](https://github.com/tale/headplane/issues/427)).
- Fixed a race condition where the SSE controller could be used after being closed.
- Fixed WebSSH WASM prefix paths for correct asset loading (closes [#386](https://github.com/tale/headplane/issues/386)).
- Fixed Dockerfile WASM copy paths.
- Fixed CodeMirror version mismatch override in the ACL editor.
- Fixed cookie secret generation using incorrect byte length (via [#501](https://github.com/tale/headplane/pull/501)).
- Added an agent status page at `/settings/agent` showing sync status, node count, and errors.
- Added local endpoint and address information to the machine detail page.
- Improved the ACL editor appearance and fixed a CodeMirror version mismatch.
- Store OIDC profile pictures in the database to prevent cookie overflow (via [#510](https://github.com/tale/headplane/pull/510), closes [#326](https://github.com/tale/headplane/issues/326)).
- Detect unsupported Docker API versions early with a clear error message (via [#497](https://github.com/tale/headplane/pull/497)).
- Fixed "No expiry" badge not displaying for nodes with zero-time expiry values (via [#527](https://github.com/tale/headplane/pull/527), closes [#526](https://github.com/tale/headplane/issues/526)).
- Fixed first user not being assigned the owner role on OIDC login (via [#480](https://github.com/tale/headplane/pull/480), closes [#266](https://github.com/tale/headplane/issues/266)).
- Fixed login errors throwing a server error instead of showing form validation (via [#475](https://github.com/tale/headplane/pull/475), closes [#474](https://github.com/tale/headplane/issues/474)).
- Fixed pre-auth key expiration on Headscale 0.28+ (closes [#519](https://github.com/tale/headplane/issues/519)).
- Fixed OIDC subject matching for providers with special characters in user IDs, e.g. Auth0 (closes [#428](https://github.com/tale/headplane/issues/428)).
- Fixed `headscale.api_key` not being used consistently across all code paths.
- Fixed agent HostInfo not refreshing periodically using `cache_ttl` (via [#477](https://github.com/tale/headplane/pull/477), closes [#427](https://github.com/tale/headplane/issues/427)).
- Fixed agent working directory being wiped on restart.
- Fixed a race condition where the SSE controller could be used after being closed.
- Fixed cookie secret generation using incorrect byte length (via [#501](https://github.com/tale/headplane/pull/501)).
- Fixed OIDC configuration error troubleshooting link (via [#518](https://github.com/tale/headplane/pull/518), closes [#517](https://github.com/tale/headplane/issues/517)).
- Fixed deprecated Nix package attributes (via [#521](https://github.com/tale/headplane/pull/521)).
- Updated NixOS module options: removed deprecated agent fields, added `headscale.api_key_path` and `integration.agent.executable_path`.
---
+9
View File
@@ -1,7 +1,9 @@
FROM --platform=$BUILDPLATFORM golang:1.25.1 AS go-base
WORKDIR /run
RUN apt-get update && apt-get install -y --no-install-recommends patch && rm -rf /var/lib/apt/lists/*
COPY go.mod go.sum build.sh ./
COPY patches/ ./patches/
RUN go mod download
COPY cmd/ ./cmd/
@@ -55,6 +57,11 @@ COPY --from=go-base /bin/hp_healthcheck /bin/hp_healthcheck
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD ["/bin/hp_healthcheck"]
# Tells Headplane to publish its loopback healthcheck URL to this
# file on startup; `hp_healthcheck` reads it. Docker-only — native
# installs don't ship a consumer.
ENV HEADPLANE_LISTEN_FILE=/tmp/headplane-listen
WORKDIR /app
CMD [ "/app/build/server/index.js" ]
@@ -71,5 +78,7 @@ COPY --from=go-base /bin/hp_healthcheck /bin/hp_healthcheck
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD ["/bin/hp_healthcheck"]
ENV HEADPLANE_LISTEN_FILE=/tmp/headplane-listen
WORKDIR /app
CMD [ "node", "/app/build/server/index.js" ]
+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>
+10 -3
View File
@@ -58,7 +58,8 @@ function Panel(props: DialogPanelProps) {
return (
<AlertDialog.Popup
className={cn(
"w-full max-w-lg rounded-xl p-4",
"flex w-full max-w-lg flex-col rounded-xl p-4",
"max-h-[90dvh]",
"outline-hidden",
"bg-white dark:bg-mist-900",
"border border-mist-200 dark:border-mist-800",
@@ -67,6 +68,7 @@ function Panel(props: DialogPanelProps) {
>
<Form
method={method ?? "POST"}
className="flex min-h-0 flex-1 flex-col"
onSubmit={(event) => {
if (onSubmit) {
onSubmit(event);
@@ -77,8 +79,13 @@ function Panel(props: DialogPanelProps) {
}
}}
>
<div className="flex flex-col gap-4">{children}</div>
<div className="mt-5 flex justify-end gap-3">
{/* px-1 -mx-1 gives focus rings on inputs/buttons room to render
without being clipped by overflow-y-auto (which implicitly forces
overflow-x: auto per CSS spec). */}
<div className="-mx-1 flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-1">
{children}
</div>
<div className="mt-5 flex shrink-0 justify-end gap-3">
{variant === "unactionable" ? (
<AlertDialog.Close render={<Button>Close</Button>} />
) : (
+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,
)}
+1 -1
View File
@@ -24,7 +24,7 @@ export default function Tooltip({ children, content, className }: TooltipProps)
{children}
</BaseTooltip.Trigger>
<BaseTooltip.Portal>
<BaseTooltip.Positioner sideOffset={4}>
<BaseTooltip.Positioner side="top" sideOffset={4} collisionPadding={8}>
<BaseTooltip.Popup
className={cn(
"z-50 rounded-lg p-3 text-sm w-48",
+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;
+4 -9
View File
@@ -2,7 +2,6 @@ import { Outlet, redirect, type ShouldRevalidateFunction } from "react-router";
import { ErrorBanner } from "~/components/error-banner";
import StatusBanner from "~/components/status-banner";
import { pruneEphemeralNodes } from "~/server/db/pruner";
import { isDataUnauthorizedError } from "~/server/headscale/api/error-client";
import { usersResource } from "~/server/headscale/live-store";
import { Capabilities } from "~/server/web/roles";
@@ -30,12 +29,9 @@ export const shouldRevalidate: ShouldRevalidateFunction = ({
return false;
};
export async function loader({ request, context, ...rest }: Route.LoaderArgs) {
export async function loader({ request, context }: Route.LoaderArgs) {
try {
const principal = await context.auth.require(request);
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const api = context.hsApi.getRuntimeClient(apiKey);
const { principal, api } = await context.apiForRequest(request);
const user =
principal.kind === "oidc"
@@ -49,11 +45,10 @@ export async function loader({ request, context, ...rest }: Route.LoaderArgs) {
: { name: principal.displayName, subject: "api_key" };
// MARK: The session should stay valid if Headscale isn't healthy
const isHealthy = await api.isHealthy();
const isHealthy = await context.headscale.health();
if (isHealthy) {
try {
await api.getApiKeys();
await pruneEphemeralNodes({ context, request, ...rest });
await api.apiKeys.list();
} catch (error) {
if (isDataUnauthorizedError(error)) {
const displayName =
+87 -7
View File
@@ -1,5 +1,17 @@
import { CircleQuestionMark, CircleUser, Globe, Lock, Server, Settings, Users } from "lucide-react";
import { NavLink, useSubmit } from "react-router";
import {
Check,
CircleQuestionMark,
CircleUser,
Globe,
Lock,
Monitor,
Moon,
Server,
Settings,
Sun,
Users,
} from "lucide-react";
import { NavLink, unstable_useRoute as useRoute, useLocation, useSubmit } from "react-router";
import Link from "~/components/link";
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "~/components/menu";
@@ -7,6 +19,7 @@ import logoBg from "~/logo/dark-bg.svg";
import logoDark from "~/logo/dark.svg";
import logoLight from "~/logo/light.svg";
import cn from "~/utils/cn";
import type { ColorScheme } from "~/utils/color-scheme";
export interface HeaderProps {
user: {
@@ -35,9 +48,26 @@ const tabs = [
{ to: "/settings", icon: Settings, label: "Settings", key: "settings" },
] as const;
const colorSchemes = [
{ value: "system", label: "System", icon: Monitor },
{ value: "light", label: "Light", icon: Sun },
{ value: "dark", label: "Dark", icon: Moon },
] as const satisfies ReadonlyArray<{
value: ColorScheme;
label: string;
icon: typeof Monitor;
}>;
export default function Header({ user, access, configAvailable }: HeaderProps) {
const submit = useSubmit();
const showTabs = access.ui;
const rootRoute = useRoute("root");
const currentColorScheme: ColorScheme = rootRoute?.loaderData?.colorScheme ?? "system";
// useLocation returns the path with the basename already stripped, which is
// what `redirect()` expects — react-router re-applies the basename when
// following the redirect on the client.
const location = useLocation();
const returnTo = location.pathname + location.search;
return (
<header
@@ -46,10 +76,10 @@ export default function Header({ user, access, configAvailable }: HeaderProps) {
"dark:border-b dark:border-mist-800 shadow-inner",
)}
>
<div className="container flex items-center justify-between py-4">
<div className="flex items-center gap-x-8">
<div className="container flex items-center gap-x-4 py-4">
<div className="flex min-w-0 items-center gap-x-4">
<div className="flex items-center gap-x-2">
<picture>
<picture className="min-w-8">
<source srcSet={logoLight} media="(prefers-color-scheme: dark)" />
<source srcSet={logoDark} media="(prefers-color-scheme: light)" />
<img src={logoBg} alt="Headplane logo" />
@@ -57,7 +87,7 @@ export default function Header({ user, access, configAvailable }: HeaderProps) {
<h1 className="text-2xl font-semibold">headplane</h1>
</div>
{showTabs && (
<nav className="hidden items-center gap-x-2 text-sm font-medium md:flex">
<nav className="hidden items-center gap-x-2 overflow-x-auto p-1 text-sm font-medium md:flex">
{tabs.map((tab) => {
if (!access[tab.key]) return null;
if ((tab.key === "dns" || tab.key === "settings") && !configAvailable) return null;
@@ -87,7 +117,7 @@ export default function Header({ user, access, configAvailable }: HeaderProps) {
</nav>
)}
</div>
<div className="grid grid-cols-2 gap-x-4">
<div className="ml-auto grid shrink-0 grid-cols-2 gap-x-4">
<Menu>
<MenuTrigger className="size-8 rounded-full p-1">
<CircleQuestionMark className="w-5" />
@@ -135,6 +165,24 @@ export default function Header({ user, access, configAvailable }: HeaderProps) {
</div>
</MenuItem>
<MenuSeparator />
{colorSchemes.map(({ value, label, icon: Icon }) => (
<MenuItem
key={value}
onClick={() =>
submit(
{ colorScheme: value, returnTo },
{ action: "/api/color-scheme", method: "POST" },
)
}
>
<div className="flex items-center gap-x-2">
<Icon className="size-4" />
<span className="flex-1">{label}</span>
{currentColorScheme === value && <Check className="size-4" />}
</div>
</MenuItem>
))}
<MenuSeparator />
<MenuItem
variant="danger"
onClick={() => submit({}, { action: "/logout", method: "POST" })}
@@ -145,6 +193,38 @@ export default function Header({ user, access, configAvailable }: HeaderProps) {
</Menu>
</div>
</div>
{showTabs && (
<div className="block overflow-x-auto p-2 md:hidden">
<nav className="flex items-center gap-x-2 text-sm font-medium">
{tabs.map((tab) => {
if (!access[tab.key]) return null;
if ((tab.key === "dns" || tab.key === "settings") && !configAvailable) return null;
return (
<NavLink
key={tab.to}
className={({ isActive }) =>
cn(
"relative px-3 py-1.5 flex items-center gap-x-1.5 rounded-md text-nowrap",
"hover:bg-mist-300/50 dark:hover:bg-mist-800",
"focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1",
"dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
"text-mist-600 dark:text-mist-300",
isActive &&
"text-mist-900 dark:text-mist-50 after:content-[''] after:absolute after:-bottom-2 after:inset-x-1 after:h-0.5 after:rounded-full after:bg-indigo-500",
)
}
prefetch="intent"
to={tab.to}
>
<tab.icon className="w-4" />
{tab.label}
</NavLink>
);
})}
</nav>
</div>
)}
</header>
);
}
-6
View File
@@ -1,6 +0,0 @@
{
"0.26.1": ["0.26.0", "0.26.1"],
"0.27.0": ["0.27.0"],
"0.27.1": ["0.27.1"],
"0.28.0": ["0.28.0"]
}
-135
View File
@@ -1,135 +0,0 @@
{
"0.26.0": {
"GET /api/v1/apikey": "efe31b6dc980e158",
"POST /api/v1/apikey": "39953a96c1da5312",
"POST /api/v1/apikey/expire": "ca56add866802f17",
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
"GET /api/v1/node": "8bb18b8c7cfb4f20",
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
"POST /api/v1/node/register": "539f7cb3a84d43d4",
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
"POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784",
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
"GET /api/v1/policy": "d6c639be304cd3c0",
"PUT /api/v1/policy": "6cbe80bde771a388",
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
"GET /api/v1/user": "228831b58ccc5a17",
"POST /api/v1/user": "a4e1d889d7962da5",
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
},
"0.26.1": {
"GET /api/v1/apikey": "efe31b6dc980e158",
"POST /api/v1/apikey": "39953a96c1da5312",
"POST /api/v1/apikey/expire": "ca56add866802f17",
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
"GET /api/v1/node": "8bb18b8c7cfb4f20",
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
"POST /api/v1/node/register": "539f7cb3a84d43d4",
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
"POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784",
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
"GET /api/v1/policy": "d6c639be304cd3c0",
"PUT /api/v1/policy": "6cbe80bde771a388",
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
"GET /api/v1/user": "228831b58ccc5a17",
"POST /api/v1/user": "a4e1d889d7962da5",
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
},
"0.27.0": {
"GET /api/v1/apikey": "efe31b6dc980e158",
"POST /api/v1/apikey": "39953a96c1da5312",
"POST /api/v1/apikey/expire": "ca56add866802f17",
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
"GET /api/v1/health": "5e447272e72b2e5f",
"GET /api/v1/node": "8bb18b8c7cfb4f20",
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
"POST /api/v1/node/register": "539f7cb3a84d43d4",
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
"POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784",
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
"GET /api/v1/policy": "d6c639be304cd3c0",
"PUT /api/v1/policy": "6cbe80bde771a388",
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
"GET /api/v1/user": "228831b58ccc5a17",
"POST /api/v1/user": "a4e1d889d7962da5",
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
},
"0.27.1": {
"GET /api/v1/apikey": "efe31b6dc980e158",
"POST /api/v1/apikey": "39953a96c1da5312",
"POST /api/v1/apikey/expire": "ca56add866802f17",
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
"GET /api/v1/health": "5e447272e72b2e5f",
"GET /api/v1/node": "8bb18b8c7cfb4f20",
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
"POST /api/v1/node/register": "539f7cb3a84d43d4",
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
"POST /api/v1/node/{nodeId}/expire": "53efc8e2017c16ae",
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
"GET /api/v1/policy": "d6c639be304cd3c0",
"PUT /api/v1/policy": "6cbe80bde771a388",
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
"GET /api/v1/user": "228831b58ccc5a17",
"POST /api/v1/user": "a4e1d889d7962da5",
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
},
"0.28.0": {
"GET /api/v1/apikey": "efe31b6dc980e158",
"POST /api/v1/apikey": "39953a96c1da5312",
"POST /api/v1/apikey/expire": "ca56add866802f17",
"DELETE /api/v1/apikey/{prefix}": "b10ca7d2750405b2",
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
"GET /api/v1/health": "5e447272e72b2e5f",
"GET /api/v1/node": "8bb18b8c7cfb4f20",
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
"POST /api/v1/node/register": "539f7cb3a84d43d4",
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
"POST /api/v1/node/{nodeId}/expire": "53efc8e2017c16ae",
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
"GET /api/v1/policy": "d6c639be304cd3c0",
"PUT /api/v1/policy": "6cbe80bde771a388",
"GET /api/v1/preauthkey": "8428b44e3a821e9e",
"DELETE /api/v1/preauthkey": "f05ea1bc8ad89a09",
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
"GET /api/v1/user": "228831b58ccc5a17",
"POST /api/v1/user": "a4e1d889d7962da5",
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
}
}
+29 -9
View File
@@ -1,7 +1,12 @@
import type { LinksFunction, MetaFunction } from "react-router";
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router";
import "@fontsource-variable/inter";
import { ExternalScripts } from "remix-utils/external-scripts";
import type { MetaFunction } from "react-router";
import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
unstable_useRoute as useRoute,
} from "react-router";
import { LiveDataProvider } from "~/utils/live-data";
import ToastProvider from "~/utils/toast-provider";
@@ -9,7 +14,9 @@ import ToastProvider from "~/utils/toast-provider";
import type { Route } from "./+types/root";
import { ErrorBanner } from "./components/error-banner";
import stylesheet from "~/tailwind.css?url";
import "@fontsource-variable/inter/opsz.css";
import "./tailwind.css";
import { getColorScheme } from "./utils/color-scheme";
export const meta: MetaFunction = () => [
{ title: "Headplane" },
@@ -19,15 +26,29 @@ export const meta: MetaFunction = () => [
},
];
export const links: LinksFunction = () => [{ rel: "stylesheet", href: stylesheet }];
export async function loader({ request }: Route.LoaderArgs) {
const colorScheme = await getColorScheme(request);
return { colorScheme };
}
export function Layout({ children }: { readonly children: React.ReactNode }) {
const { loaderData } = useRoute("root");
// LiveDataProvider is wrapped at the top level since dialogs and things
// that control its state are usually open in portal containers which
// are not a part of the normal React tree.
return (
<LiveDataProvider>
<html lang="en">
<html
lang="en"
className={
loaderData?.colorScheme === "dark"
? "dark"
: loaderData?.colorScheme === "light"
? "light"
: ""
}
>
<head>
<meta charSet="utf-8" />
<meta content="width=device-width, initial-scale=1" name="viewport" />
@@ -35,12 +56,11 @@ export function Layout({ children }: { readonly children: React.ReactNode }) {
<Links />
<link href={`${__PREFIX__}/favicon.ico`} rel="icon" />
</head>
<body className="overflow-x-hidden overscroll-none dark:bg-mist-900 dark:text-mist-50">
<body className="w-full overflow-x-hidden overscroll-none dark:bg-mist-900 dark:text-mist-50">
{children}
<ToastProvider />
<ScrollRestoration />
<Scripts />
<ExternalScripts />
</body>
</html>
</LiveDataProvider>
+5 -2
View File
@@ -5,7 +5,10 @@ export default [
route("/healthz", "routes/util/healthz.ts"),
// API Routes
...prefix("/api", [route("/info", "routes/util/info.ts")]),
...prefix("/api", [
route("/info", "routes/util/info.ts"),
route("/color-scheme", "routes/util/color-scheme.ts"),
]),
...prefix("/events", [route("/live", "routes/util/live.ts")]),
// Authentication Routes
@@ -13,7 +16,7 @@ export default [
route("/logout", "routes/auth/logout.ts"),
route("/oidc/callback", "routes/auth/oidc-callback.ts"),
route("/oidc/start", "routes/auth/oidc-start.ts"),
route("/ssh", "routes/ssh/console.tsx"),
route("/ssh/:id", "routes/ssh/page.tsx"),
// All the main logged-in routes
layout("layout/app.tsx", [
+22 -65
View File
@@ -26,10 +26,9 @@ export async function aclAction({ request, context }: Route.ActionArgs) {
});
}
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const api = context.hsApi.getRuntimeClient(apiKey);
const { api } = await context.apiForRequest(request);
try {
const { policy, updatedAt } = await api.setPolicy(policyData);
const { policy, updatedAt } = await api.policy.set(policyData);
return data({
success: true,
error: undefined,
@@ -55,71 +54,29 @@ export async function aclAction({ request, context }: Route.ActionArgs) {
throw error;
}
// Starting in Headscale 0.27.0 the ACLs parsing was changed meaning
// we need to reference other error messages based on API version.
if (context.hsApi.clientHelpers.isAtleast("0.27.0")) {
if (message.includes("parsing HuJSON:")) {
const cutIndex = message.indexOf("parsing HuJSON:");
const trimmed =
cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 16).trim()}` : message;
// Policy parse errors carry one of two prefixes (HuJSON syntax vs.
// structural unmarshal). Headscale 0.27.0+ uses these forms; older
// releases are no longer supported.
if (message.includes("parsing HuJSON:")) {
const cutIndex = message.indexOf("parsing HuJSON:");
const trimmed =
cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 16).trim()}` : message;
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
return data(
{ success: false, error: trimmed, policy: undefined, updatedAt: undefined },
400,
);
}
if (message.includes("parsing policy from bytes:")) {
const cutIndex = message.indexOf("parsing policy from bytes:");
const trimmed =
cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 26).trim()}` : message;
if (message.includes("parsing policy from bytes:")) {
const cutIndex = message.indexOf("parsing policy from bytes:");
const trimmed =
cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 26).trim()}` : message;
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
} else {
// Pre-0.27.0 error messages
if (message.includes("parsing hujson")) {
const cutIndex = message.indexOf("err: hujson:");
const trimmed = cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 12)}` : message;
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
if (message.includes("unmarshalling policy")) {
const cutIndex = message.indexOf("err:");
const trimmed = cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 5)}` : message;
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
return data(
{ success: false, error: trimmed, policy: undefined, updatedAt: undefined },
400,
);
}
}
+6 -4
View File
@@ -29,17 +29,19 @@ export async function aclLoader({ request, context }: Route.LoaderArgs) {
};
// Try to load the ACL policy from the API.
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const api = context.hsApi.getRuntimeClient(apiKey);
const { api } = await context.apiForRequest(request);
try {
const { policy, updatedAt } = await api.getPolicy();
const { policy, updatedAt } = await api.policy.get();
flags.writable = updatedAt !== null;
flags.policy = policy;
return flags;
} catch (error) {
if (isDataWithApiError(error)) {
// Headscale returns "acl policy not found" when the policy mode is
// set to file but no file exists, and returns a 500 when database
// mode is used but the policies table is empty.
// https://github.com/juanfont/headscale/blob/c4600346f9c29b514dc9725ac103efb9d0381f23/hscontrol/types/policy.go#L10
if (error.data.rawData.includes("acl policy not found")) {
if (error.data.rawData.includes("acl policy not found") || error.data.statusCode === 500) {
flags.policy = "";
flags.writable = true;
return flags;
+61 -100
View File
@@ -1,112 +1,73 @@
import * as shopify from '@shopify/lang-jsonc';
import { xcodeDark, xcodeLight } from '@uiw/codemirror-theme-xcode';
import CodeMirror from '@uiw/react-codemirror';
import { BookCopy, CircleX } from 'lucide-react';
import { useEffect, useState } from 'react';
import Merge from 'react-codemirror-merge';
import { ErrorBoundary } from 'react-error-boundary';
import { ClientOnly } from 'remix-utils/client-only';
import Fallback from './fallback';
import * as shopify from "@shopify/lang-jsonc";
import CodeMirror from "@uiw/react-codemirror";
import { BookCopy, CircleX } from "lucide-react";
import Merge from "react-codemirror-merge";
import { ErrorBoundary } from "react-error-boundary";
import { headplaneTheme } from "./theme";
interface EditorProps {
isDisabled?: boolean;
value: string;
onChange: (value: string) => void;
isDisabled?: boolean;
value: string;
onChange: (value: string) => void;
}
// TODO: Remove ClientOnly
export function Editor(props: EditorProps) {
const [light, setLight] = useState(false);
useEffect(() => {
const theme = window.matchMedia('(prefers-color-scheme: light)');
setLight(theme.matches);
theme.addEventListener('change', (theme) => {
setLight(theme.matches);
});
});
return (
<div className="overflow-y-scroll h-editor text-sm">
<ErrorBoundary
fallback={
<div className="flex flex-col items-center gap-2.5 py-8">
<CircleX />
<p className="text-lg font-semibold">Failed to load the editor.</p>
</div>
}
>
<ClientOnly fallback={<Fallback acl={props.value} />}>
{() => (
<CodeMirror
editable={!props.isDisabled}
extensions={[shopify.jsonc()]} // Allow editing unless disabled
height="100%" // Use readOnly if disabled
onChange={(value) => props.onChange(value)}
readOnly={props.isDisabled}
style={{ height: '100%' }}
theme={light ? xcodeLight : xcodeDark}
value={props.value}
/>
)}
</ClientOnly>
</ErrorBoundary>
</div>
);
return (
<div className="text-sm">
<ErrorBoundary
fallback={
<div className="flex flex-col items-center gap-2.5 py-8">
<CircleX />
<p className="text-lg font-semibold">Failed to load the editor.</p>
</div>
}
>
<CodeMirror
editable={!props.isDisabled}
extensions={[shopify.jsonc()]}
minHeight="24rem"
maxHeight="var(--height-editor)"
onChange={(value) => props.onChange(value)}
readOnly={props.isDisabled}
theme={headplaneTheme}
value={props.value}
/>
</ErrorBoundary>
</div>
);
}
interface DifferProps {
left: string;
right: string;
left: string;
right: string;
}
export function Differ(props: DifferProps) {
const [light, setLight] = useState(false);
useEffect(() => {
const theme = window.matchMedia('(prefers-color-scheme: light)');
setLight(theme.matches);
theme.addEventListener('change', (theme) => {
setLight(theme.matches);
});
});
return (
<div className="text-sm">
{props.left === props.right ? (
<div className="flex flex-col items-center gap-2.5 py-8">
<BookCopy />
<p className="text-lg font-semibold">No changes</p>
</div>
) : (
<div className="h-editor overflow-y-scroll">
<ErrorBoundary
fallback={
<div className="flex flex-col items-center gap-2.5 py-8">
<CircleX />
<p className="text-lg font-semibold">
Failed to load the editor.
</p>
</div>
}
>
<ClientOnly fallback={<Fallback acl={props.right} />}>
{() => (
<Merge orientation="a-b" theme={light ? xcodeLight : xcodeDark}>
<Merge.Original
extensions={[shopify.jsonc()]}
readOnly
value={props.left}
/>
<Merge.Modified
extensions={[shopify.jsonc()]}
readOnly
value={props.right}
/>
</Merge>
)}
</ClientOnly>
</ErrorBoundary>
</div>
)}
</div>
);
return (
<div className="text-sm">
{props.left === props.right ? (
<div className="flex flex-col items-center gap-2.5 py-8">
<BookCopy />
<p className="text-lg font-semibold">No changes</p>
</div>
) : (
<div className="h-editor">
<ErrorBoundary
fallback={
<div className="flex flex-col items-center gap-2.5 py-8">
<CircleX />
<p className="text-lg font-semibold">Failed to load the editor.</p>
</div>
}
>
<Merge orientation="a-b" theme={headplaneTheme}>
<Merge.Original extensions={[shopify.jsonc()]} readOnly value={props.left} />
<Merge.Modified extensions={[shopify.jsonc()]} readOnly value={props.right} />
</Merge>
</ErrorBoundary>
</div>
)}
</div>
);
}
+11 -29
View File
@@ -1,36 +1,18 @@
import { Loader2 } from "lucide-react";
import cn from "~/utils/cn";
interface Props {
readonly acl: string;
}
export default function Fallback({ acl }: Props) {
export default function Fallback() {
return (
<div className="h-editor relative flex w-full">
<div
className={cn(
"h-full w-8 flex justify-center p-1",
"border-r border-mist-400 dark:border-mist-800",
)}
>
<div
aria-hidden
className={cn(
"h-5 w-5 animate-spin rounded-full",
"border-mist-900 dark:border-mist-100",
"border-2 border-t-transparent dark:border-t-transparent",
)}
/>
<div
className={cn("h-editor overflow-hidden rounded-md", "bg-[var(--cm-bg)] text-[var(--cm-fg)]")}
>
<div className="flex h-full items-center justify-center">
<div className="flex flex-col items-center gap-2 text-[var(--cm-gutter-fg)]">
<Loader2 className="size-5 animate-spin" />
<p className="text-sm">Loading editor</p>
</div>
</div>
<textarea
className={cn(
"w-full h-editor font-mono resize-none text-sm",
"bg-mist-50 dark:bg-mist-950 opacity-60",
"pl-1 pt-1 leading-snug",
)}
readOnly
value={acl}
/>
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
import { EditorView } from "@codemirror/view";
import { tags as t } from "@lezer/highlight";
const editorTheme = EditorView.theme({
"&": {
backgroundColor: "var(--cm-bg)",
color: "var(--cm-fg)",
},
"&.cm-editor.cm-focused": {
outline: "none",
},
".cm-content": {
caretColor: "var(--cm-caret)",
fontFamily: "var(--font-mono, ui-monospace, monospace)",
},
"&.cm-editor .cm-scroller": {
fontFamily: "var(--font-mono, ui-monospace, monospace)",
},
".cm-cursor, .cm-dropCursor": {
borderLeftColor: "var(--cm-caret)",
},
"&.cm-focused .cm-selectionBackground, & .cm-line::selection, & .cm-selectionLayer .cm-selectionBackground, .cm-content ::selection":
{
background: "var(--cm-selection) !important",
},
"& .cm-selectionMatch": {
backgroundColor: "var(--cm-selection-match)",
},
".cm-activeLine": {
backgroundColor: "var(--cm-line-highlight)",
},
".cm-gutters": {
backgroundColor: "var(--cm-gutter-bg)",
color: "var(--cm-gutter-fg)",
borderRight: "1px solid var(--cm-gutter-border)",
},
".cm-activeLineGutter": {
backgroundColor: "var(--cm-line-highlight)",
color: "var(--cm-gutter-fg-active)",
},
".cm-scroller": {
scrollbarColor: "var(--cm-gutter-border) transparent",
scrollbarWidth: "auto",
},
});
const highlightStyle = HighlightStyle.define([
{ tag: [t.comment, t.quote], color: "var(--cm-comment)" },
{ tag: [t.keyword], color: "var(--cm-keyword)", fontWeight: "bold" },
{ tag: [t.string, t.meta], color: "var(--cm-string)" },
{ tag: [t.typeName, t.typeOperator], color: "var(--cm-type)" },
{ tag: [t.definition(t.variableName)], color: "var(--cm-definition)" },
{ tag: [t.name], color: "var(--cm-name)" },
{ tag: [t.variableName], color: "var(--cm-variable)" },
{ tag: [t.propertyName], color: "var(--cm-property)" },
{ tag: [t.atom, t.bool, t.special(t.variableName)], color: "var(--cm-atom)" },
{ tag: [t.number], color: "var(--cm-number)" },
{ tag: [t.regexp, t.link], color: "var(--cm-link)" },
{ tag: [t.bracket], color: "var(--cm-bracket)" },
]);
export const headplaneTheme = [editorTheme, syntaxHighlighting(highlightStyle)];
+15 -4
View File
@@ -1,5 +1,5 @@
import { AlertCircle, Construction, Eye, FlaskConical, Pencil } from "lucide-react";
import { useEffect, useState } from "react";
import { Suspense, lazy, useEffect, useState } from "react";
import { isRouteErrorResponse, useFetcher, useRevalidator } from "react-router";
import Button from "~/components/button";
@@ -15,7 +15,14 @@ import toast from "~/utils/toast";
import type { Route } from "./+types/overview";
import { aclAction } from "./acl-action";
import { aclLoader } from "./acl-loader";
import { Differ, Editor } from "./components/cm.client";
import Fallback from "./components/fallback";
const LazyEditor = lazy(() =>
import("./components/cm.client").then((m) => ({ default: m.Editor })),
);
const LazyDiffer = lazy(() =>
import("./components/cm.client").then((m) => ({ default: m.Differ })),
);
export const loader = aclLoader;
export const action = aclAction;
@@ -101,10 +108,14 @@ export default function Page({ loaderData: { access, writable, policy } }: Route
</TabsTab>
</TabsList>
<TabsPanel value="edit">
<Editor isDisabled={disabled} onChange={setCodePolicy} value={codePolicy} />
<Suspense fallback={<Fallback />}>
<LazyEditor isDisabled={disabled} onChange={setCodePolicy} value={codePolicy} />
</Suspense>
</TabsPanel>
<TabsPanel value="diff">
<Differ left={policy} right={codePolicy} />
<Suspense fallback={<Fallback />}>
<LazyDiffer left={policy} right={codePolicy} />
</Suspense>
</TabsPanel>
<TabsPanel value="preview">
<div className="flex flex-col items-center py-8">
+4 -2
View File
@@ -33,9 +33,11 @@ export async function loginAction({ request, context }: Route.LoaderArgs) {
};
}
const api = context.hsApi.getRuntimeClient(apiKey);
// Build a client with the candidate API key the user just submitted, so the
// GET /api/v1/apikey call below validates the key against Headscale itself.
const api = context.headscale.client(apiKey);
try {
const apiKeys = await api.getApiKeys();
const apiKeys = await api.apiKeys.list();
// We don't need to check for 0 API keys because this request cannot
// be authenticated correctly without an API key
+14 -40
View File
@@ -3,7 +3,7 @@ import { AlertCircle, CloudOff } from "lucide-react";
import Card from "~/components/card";
import Code from "~/components/code";
import Link from "~/components/link";
import type { OidcConnectorError } from "~/server/web/oidc-connector";
import type { OidcErrorCode } from "~/server/oidc/provider";
export function OidcDiscoveryFailedNotice() {
return (
@@ -20,7 +20,7 @@ export function OidcDiscoveryFailedNotice() {
);
}
export function OidcConfigErrorNotice({ errors }: { errors: OidcConnectorError[] }) {
export function OidcConfigErrorNotice({ errors }: { errors: OidcErrorCode[] }) {
return (
<Card className="m-4 mb-4 max-w-md border border-red-500 sm:m-0 sm:mb-4">
<div className="flex items-center justify-between gap-4">
@@ -34,7 +34,7 @@ export function OidcConfigErrorNotice({ errors }: { errors: OidcConnectorError[]
<li key={code.key}>{code.node}</li>
))}
</ul>{" "}
<Link external styled to="https://headplane.net/configuration/sso#troubleshooting">
<Link external styled to="https://headplane.net/features/sso#troubleshooting">
Learn more
</Link>
</Card.Text>
@@ -42,7 +42,7 @@ export function OidcConfigErrorNotice({ errors }: { errors: OidcConnectorError[]
);
}
function mapOidcErrorsToMessages(errors: OidcConnectorError[]) {
function mapOidcErrorsToMessages(errors: OidcErrorCode[]) {
const messages: {
key: string;
node: React.ReactNode;
@@ -50,7 +50,7 @@ function mapOidcErrorsToMessages(errors: OidcConnectorError[]) {
for (const error of errors) {
switch (error) {
case "INVALID_API_KEY": {
case "invalid_api_key": {
messages.push({
key: error,
node: (
@@ -63,65 +63,39 @@ function mapOidcErrorsToMessages(errors: OidcConnectorError[]) {
break;
}
case "MISSING_AUTHORIZATION_ENDPOINT": {
case "missing_endpoints": {
messages.push({
key: error,
node: (
<Card.Text className="inline">
The OIDC provided does not have a configured <Code>authorization_endpoint</Code>.
Ensure discovery URL or manual configuration is correct.
The OIDC provider is missing required endpoints. Ensure the discovery URL is correct
or provide manual endpoint overrides in your configuration.
</Card.Text>
),
});
break;
}
case "MISSING_TOKEN_ENDPOINT": {
case "discovery_failed": {
messages.push({
key: error,
node: (
<Card.Text className="inline">
The OIDC provided does not have a configured <Code>token_endpoint</Code>. Ensure
discovery URL or manual configuration is correct.
Unable to reach the OIDC provider for discovery. SSO will retry on the next login
attempt.
</Card.Text>
),
});
break;
}
case "MISSING_USERINFO_ENDPOINT": {
default: {
messages.push({
key: error,
node: (
<Card.Text className="inline">
The OIDC provided does not have a configured <Code>user_endpoint</Code>. Ensure
discovery URL or manual configuration is correct.
</Card.Text>
),
});
break;
}
case "MISSING_REQUIRED_CLAIMS": {
messages.push({
key: error,
node: (
<Card.Text className="inline">
The OIDC provider does not support the <Code>sub</Code> claim, which is required for
authentication. Your OIDC provider may be misconfigured.
</Card.Text>
),
});
break;
}
case "UNKNOWN_ERROR": {
messages.push({
key: error,
node: (
<Card.Text className="inline">
An unknown error occurred during OIDC configuration. Please check the Headplane logs
for more information.
An unknown OIDC configuration error occurred. Please check the Headplane logs for more
information.
</Card.Text>
),
});
+16 -7
View File
@@ -24,16 +24,25 @@ export async function loader({ request, context }: Route.LoaderArgs) {
const qp = new URL(request.url).searchParams;
const urlState = qp.get("s") ?? undefined;
const oidcConnector = await context.oidc?.connector.get();
const oidcService = context.oidc.state === "enabled" ? context.oidc.value : undefined;
const oidcStatus = oidcService
? await oidcService.discover().then(
(r) => (r.ok ? oidcService.status() : oidcService.status()),
() => oidcService.status(),
)
: undefined;
// MARK: This works because the OIDC connector will always return false
// For `isExclusive` if the OIDC config isn't usable.
if (oidcConnector?.isExclusive && urlState !== "logout") {
if (
oidcService &&
context.config.oidc?.disable_api_key_login &&
oidcStatus?.state === "ready" &&
urlState !== "logout"
) {
return redirect("/oidc/start");
}
const isOidcConnectorEnabled = oidcConnector?.isValid;
const oidcErrorCodes = !isOidcConnectorEnabled ? (oidcConnector?.errors ?? []) : [];
const isOidcConnectorEnabled = oidcStatus?.state === "ready";
const oidcErrorCodes = oidcStatus?.state === "error" ? [oidcStatus.error.code] : [];
return {
isCookieSecureEnabled: context.config.server.cookie_secure,
@@ -88,7 +97,7 @@ export default function Page({ loaderData, actionData }: Route.ComponentProps) {
<div>
{urlState?.startsWith("error_") ? (
<OidcErrorNotice code={urlState} />
) : oidcErrorCodes.includes("DISCOVERY_FAILED") ? (
) : oidcErrorCodes.includes("discovery_failed") ? (
<OidcDiscoveryFailedNotice />
) : oidcErrorCodes.length > 0 ? (
<OidcConfigErrorNotice errors={oidcErrorCodes} />
+30 -5
View File
@@ -1,21 +1,46 @@
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.state === "enabled" &&
context.config.oidc?.use_end_session
) {
const service = context.oidc.value;
const status = service.status();
if (status.state !== "ready") {
// Trigger discovery if it hasn't happened yet so we can find the
// end_session_endpoint without forcing a re-login.
await service.discover();
}
const endSessionUrl = service.buildEndSessionUrl(principal.idToken);
if (endSessionUrl) {
url = endSessionUrl;
}
}
return redirect(url, {
headers: {
+54 -136
View File
@@ -1,6 +1,3 @@
import { createHash } from "node:crypto";
import * as oidc from "openid-client";
import { data, redirect } from "react-router";
import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity";
@@ -10,10 +7,10 @@ import { createOidcStateCookie } from "~/utils/oidc-state";
import type { Route } from "./+types/oidc-callback";
export async function loader({ request, context }: Route.LoaderArgs) {
const oidcConnector = await context.oidc?.connector.get();
if (!oidcConnector?.isValid) {
throw data("OIDC is not enabled or misconfigured", { status: 501 });
if (context.oidc.state !== "enabled") {
throw data(`OIDC is unavailable: ${context.oidc.reason}`, { status: 501 });
}
const service = context.oidc.value;
const url = new URL(request.url);
if (url.searchParams.toString().length === 0) {
@@ -34,138 +31,59 @@ export async function loader({ request, context }: Route.LoaderArgs) {
return redirect("/login?s=error_invalid_session");
}
try {
const callbackUrl = new URL(redirect_uri);
const currentUrl = new URL(request.url);
callbackUrl.search = currentUrl.search;
const flowState = {
state,
nonce,
codeVerifier: verifier,
redirectUri: redirect_uri,
};
const tokens = await oidc.authorizationCodeGrant(oidcConnector.client, callbackUrl, {
expectedState: state,
expectedNonce: nonce,
...(oidcConnector.usePKCE ? { pkceCodeVerifier: verifier } : {}),
});
const claims = tokens.claims();
if (claims?.sub == null) {
log.warn("auth", "No subject found in OIDC claims");
return redirect("/login?s=error_no_sub");
}
const userInfo = await oidc.fetchUserInfo(
oidcConnector.client,
tokens.access_token,
claims.sub,
);
// We have defaults that closely follow what Headscale uses, maybe we
// can make it configurable in the future, but for now we only need the
// `sub` claim.
const username = userInfo.preferred_username ?? userInfo.email?.split("@")[0] ?? "user";
const name =
userInfo.name ??
(userInfo.given_name && userInfo.family_name
? `${userInfo.given_name} ${userInfo.family_name}`
: (userInfo.preferred_username ?? "SSO User"));
const picture = await (async () => {
if (context.config.oidc?.profile_picture_source === "gravatar") {
if (!userInfo.email) {
return undefined;
}
const emailHash = userInfo.email.trim().toLowerCase();
const hash = createHash("sha256").update(emailHash).digest("hex");
return `https://www.gravatar.com/avatar/${hash}?s=200&d=identicon&r=x`;
}
if (!userInfo.picture) {
return undefined;
}
try {
const response = await fetch(userInfo.picture, {
headers: { Authorization: `Bearer ${tokens.access_token}` },
});
if (response.ok) {
const contentType = response.headers.get("content-type");
if (contentType?.startsWith("image/")) {
const buffer = await response.arrayBuffer();
const base64 = Buffer.from(buffer).toString("base64");
return `data:${contentType};base64,${base64}`;
}
}
} catch {}
return userInfo.picture;
})();
const userId = await context.auth.findOrCreateUser(claims.sub, {
name,
email: userInfo.email,
picture,
});
try {
const hsApi = context.hsApi.getRuntimeClient(context.oidc!.apiKey);
const hsUsers = await hsApi.getUsers();
const hsUser = findHeadscaleUserBySubject(hsUsers, claims.sub, userInfo.email);
if (hsUser) {
await context.auth.linkHeadscaleUser(userId, hsUser.id);
}
} catch (error) {
log.warn("auth", "Failed to link Headscale user: %s", String(error));
}
return redirect("/", {
headers: {
"Set-Cookie": await context.auth.createOidcSession(userId, {
name,
email: userInfo.email,
username,
}),
},
});
} catch (error) {
if (error instanceof oidc.ResponseBodyError) {
log.error("auth", "Got an OIDC response error body: %s", JSON.stringify(error.cause));
// Check for PKCE-related errors
if (
error.error.toLowerCase().includes("code_verifier") ||
error.error.toLowerCase().includes("code verifier") ||
error.error.toLowerCase().includes("pkce")
) {
log.error(
"auth",
"PKCE error detected. Your OIDC provider may require PKCE to be enabled. Current setting: use_pkce=%s",
oidcConnector.usePKCE,
);
if (!oidcConnector.usePKCE) {
log.error(
"auth",
"Consider setting oidc.use_pkce=true in your configuration if your provider requires PKCE",
);
}
}
} else if (error instanceof oidc.AuthorizationResponseError) {
log.error("auth", "Got an OIDC authorization response error: %s", error.error);
} else if (error instanceof oidc.WWWAuthenticateChallengeError) {
log.error("auth", "Got an OIDC WWW-Authenticate challenge error");
} else if (error instanceof oidc.ClientError) {
log.error(
"auth",
"Got an OIDC authorization client error: %s",
error.cause instanceof Error ? error.cause.message : String(error.cause),
);
} else {
log.error(
"auth",
"Got an OIDC error: %s",
error instanceof Error && error.cause ? JSON.stringify(error.cause) : String(error),
);
const result = await service.handleCallback(url.searchParams, flowState);
if (!result.ok) {
log.error("auth", "OIDC callback failed [%s]: %s", result.error.code, result.error.message);
if (result.error.hint) {
log.error("auth", "Hint: %s", result.error.hint);
}
return redirect("/login?s=error_auth_failed");
}
const identity = result.value;
const userId = await context.auth.findOrCreateUser(identity.subject, {
name: identity.name,
email: identity.email,
picture: identity.picture,
});
try {
// Looks up the Headscale user that matches this OIDC identity. We use
// the configured admin API key here — not a per-request one — because
// there is no per-request key yet (the session is being created).
const hsApi = context.headscale.client(context.headscaleApiKey!);
const hsUsers = await hsApi.users.list();
const hsUser = findHeadscaleUserBySubject(hsUsers, identity.subject, identity.email);
if (hsUser) {
await context.auth.linkHeadscaleUser(userId, hsUser.id);
}
} catch (error) {
log.warn("auth", "Failed to link Headscale user: %s", String(error));
}
// Only persist the id_token when RP-initiated logout is enabled — otherwise
// we'd be storing a credential we never use.
const idToken = context.config.oidc?.use_end_session ? identity.idToken : undefined;
return redirect("/", {
headers: {
"Set-Cookie": await context.auth.createOidcSession(
userId,
{
name: identity.name,
email: identity.email,
username: identity.username,
},
{ idToken },
),
},
});
}
+14 -58
View File
@@ -1,7 +1,5 @@
import * as oidc from "openid-client";
import { data, redirect } from "react-router";
import { HeadplaneConfig } from "~/server/config/config-schema";
import { createOidcStateCookie } from "~/utils/oidc-state";
import type { Route } from "./+types/oidc-start";
@@ -12,70 +10,28 @@ export async function loader({ request, context }: Route.LoaderArgs) {
return redirect("/");
} catch {}
const oidcConnector = await context.oidc?.connector.get();
if (!oidcConnector?.isValid) {
throw data("OIDC is not enabled or misconfigured", { status: 501 });
if (context.oidc.state !== "enabled") {
throw data(`OIDC is unavailable: ${context.oidc.reason}`, { status: 501 });
}
const service = context.oidc.value;
const result = await service.startFlow();
if (!result.ok) {
return redirect(`/login?s=${result.error.code}`);
}
const { url, flowState } = result.value;
const cookie = createOidcStateCookie(context.config);
const redirect_uri = getRedirectUri(context.config, request);
const nonce = oidc.randomNonce();
const verifier = oidc.randomPKCECodeVerifier();
const state = oidc.randomState();
const url = oidc.buildAuthorizationUrl(oidcConnector.client, {
...oidcConnector.extraParams,
scope: oidcConnector.scope,
redirect_uri,
state,
nonce,
...(oidcConnector.usePKCE
? {
code_challenge_method: "S256",
code_challenge: await oidc.calculatePKCECodeChallenge(verifier),
}
: {}),
});
return redirect(url.href, {
return redirect(url, {
status: 302,
headers: {
"Set-Cookie": await cookie.serialize({
state,
nonce,
verifier,
redirect_uri,
state: flowState.state,
nonce: flowState.nonce,
verifier: flowState.codeVerifier,
redirect_uri: flowState.redirectUri,
}),
},
});
}
function getRedirectUri(config: HeadplaneConfig, req: Request): string {
if (config.server.base_url != null) {
const url = new URL(`${__PREFIX__}/oidc/callback`, config.server.base_url);
return url.href;
}
if (config.oidc?.redirect_uri != null) {
const url = new URL(`${__PREFIX__}/oidc/callback`, config.oidc.redirect_uri);
return url.href;
}
const url = new URL(`${__PREFIX__}/oidc/callback`, req.url);
let host = req.headers.get("Host");
if (!host) {
host = req.headers.get("X-Forwarded-Host");
}
if (!host) {
throw data("Cannot determine redirect URI: no Host or X-Forwarded-Host header", {
status: 500,
});
}
const proto = req.headers.get("X-Forwarded-Proto");
url.protocol = proto ?? "http:";
url.host = host;
return url.href;
}
+9 -12
View File
@@ -16,9 +16,6 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
return data({ success: false }, 403);
}
// We only need it for health checks which don't require auth
const api = context.hsApi.getRuntimeClient("fake-api-key");
const formData = await request.formData();
const action = formData.get("action_id")?.toString();
if (!action) {
@@ -39,7 +36,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
},
]);
await context.integration?.onConfigChange(api);
await context.integration?.onConfigChange(context.headscale);
return { message: "Tailnet renamed successfully" };
}
case "toggle_magic": {
@@ -55,7 +52,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
},
]);
await context.integration?.onConfigChange(api);
await context.integration?.onConfigChange(context.headscale);
return { message: "Magic DNS state updated successfully" };
}
case "remove_ns": {
@@ -88,7 +85,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
]);
}
await context.integration?.onConfigChange(api);
await context.integration?.onConfigChange(context.headscale);
return { message: "Nameserver removed successfully" };
}
case "add_ns": {
@@ -123,7 +120,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
]);
}
await context.integration?.onConfigChange(api);
await context.integration?.onConfigChange(context.headscale);
return { message: "Nameserver added successfully" };
}
case "remove_domain": {
@@ -141,7 +138,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
},
]);
await context.integration?.onConfigChange(api);
await context.integration?.onConfigChange(context.headscale);
return { message: "Domain removed successfully" };
}
case "add_domain": {
@@ -161,7 +158,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
},
]);
await context.integration?.onConfigChange(api);
await context.integration?.onConfigChange(context.headscale);
return { message: "Domain added successfully" };
}
case "remove_record": {
@@ -183,7 +180,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
return;
}
await context.integration?.onConfigChange(api);
await context.integration?.onConfigChange(context.headscale);
return { message: "DNS record removed successfully" };
}
case "add_record": {
@@ -205,7 +202,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
return;
}
await context.integration?.onConfigChange(api);
await context.integration?.onConfigChange(context.headscale);
return { message: "DNS record added successfully" };
}
case "override_dns": {
@@ -222,7 +219,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
},
]);
await context.integration?.onConfigChange(api);
await context.integration?.onConfigChange(context.headscale);
return { message: "DNS override updated successfully" };
}
default:
+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");
}
+3 -5
View File
@@ -24,12 +24,11 @@ export async function loader({ request, context }: Route.LoaderArgs) {
// Unclaimed users they can pick from before anything else.
let unlinked = false;
if (
typeof context.oidc === "object" &&
context.oidc.state === "enabled" &&
principal.kind === "oidc" &&
!principal.user.headscaleUserId
) {
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const api = context.hsApi.getRuntimeClient(apiKey);
const { api } = await context.apiForRequest(request);
let headscaleUsers: { id: string; name: string }[] = [];
try {
@@ -64,8 +63,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
}
// No UI access — show the download/connect page
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const api = context.hsApi.getRuntimeClient(apiKey);
const { api } = await context.apiForRequest(request);
let linkedUserName: string | undefined;
if (principal.kind === "oidc" && principal.user.headscaleUserId) {
@@ -13,7 +13,7 @@ import { TailscaleSSHTag } from "~/components/tags/TailscaleSSH";
import type { User } from "~/types";
import cn from "~/utils/cn";
import * as hinfo from "~/utils/host-info";
import type { PopulatedNode } from "~/utils/node-info";
import { isNoExpiry, type PopulatedNode } from "~/utils/node-info";
import { formatTimeDelta } from "~/utils/time";
import toast from "~/utils/toast";
import { getUserDisplayName } from "~/utils/user";
@@ -50,8 +50,8 @@ export default function MachineRow({
}, [magic, node.ipAddresses]);
return (
<tr className="group hover:bg-mist-50 dark:hover:bg-mist-950" key={node.id}>
<td className="py-2 pl-0.5 focus-within:ring-3">
<tr className="group hover:bg-mist-100 dark:hover:bg-mist-800" key={node.id}>
<td className="py-2 pl-2 focus-within:ring-3">
<Link className={cn("group/link h-full focus:outline-hidden")} to={`/machines/${node.id}`}>
<p
className={cn(
@@ -156,7 +156,7 @@ export function uiTagsForNode(node: PopulatedNode, isAgent?: boolean) {
uiTags.push("expired");
}
if (node.expiry === null) {
if (!node.expired && isNoExpiry(node.expiry)) {
uiTags.push("no-expiry");
}
+4 -7
View File
@@ -107,7 +107,7 @@ export default function MachineMenu({
// in a new WINDOW since href can only
// do a new TAB.
window.open(
`${__PREFIX__}/ssh?hostname=${node.givenName}`,
`${__PREFIX__}/ssh/${node.givenName}`,
"_blank",
"noopener,noreferrer,width=800,height=600",
);
@@ -120,17 +120,14 @@ export default function MachineMenu({
) : (
<Button
className={cn(
"py-0.5",
"py-0.5 rounded-lg",
"opacity-0 pointer-events-none group-hover:opacity-100",
"group-hover:pointer-events-auto",
)}
variant="ghost"
variant="light"
onClick={() => {
// We need to use JS to open the SSH URL
// in a new WINDOW since href can only
// do a new TAB.
window.open(
`${__PREFIX__}/ssh?hostname=${node.givenName}`,
`${__PREFIX__}/ssh/${node.givenName}`,
"_blank",
"noopener,noreferrer,width=800,height=600",
);
+3 -1
View File
@@ -52,7 +52,9 @@ export default function NewMachine(data: NewMachineProps) {
onValueChange={(v) => form.setValue("user", v)}
placeholder="Select a user"
items={data.users.map((user) => ({
value: user.id,
// Headscale's v1/node/register endpoint resolves the owner by
// username via GetUserByName, so we must pass user.name (not id).
value: user.name,
label: getUserDisplayName(user),
}))}
/>
+14 -12
View File
@@ -7,12 +7,9 @@ import { Capabilities } from "~/server/web/roles";
import type { Route } from "./+types/machine";
export async function machineAction({ request, context }: Route.ActionArgs) {
const principal = await context.auth.require(request);
const { principal, api } = await context.apiForRequest(request);
const formData = await request.formData();
const api = context.hsApi.getRuntimeClient(
context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey),
);
const action = formData.get("action_id")?.toString();
if (!action) {
@@ -43,7 +40,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
});
}
const node = await api.registerNode(user, registrationKey);
const node = await api.nodes.register(user, registrationKey);
await context.hsLive.refresh(nodesResource, api);
return redirect(`/machines/${node.id}`);
}
@@ -56,7 +53,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
});
}
const node = await api.getNode(nodeId);
const node = await api.nodes.get(nodeId);
if (!node) {
throw data(`Machine with ID ${nodeId} not found`, {
status: 404,
@@ -79,19 +76,19 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
}
const name = String(formData.get("name"));
await api.renameNode(nodeId, name);
await api.nodes.rename(nodeId, name);
await context.hsLive.refresh(nodesResource, api);
return { message: "Machine renamed" };
}
case "delete": {
await api.deleteNode(nodeId);
await api.nodes.delete(nodeId);
await context.hsLive.refresh(nodesResource, api);
return redirect("/machines");
}
case "expire": {
await api.expireNode(nodeId);
await api.nodes.expire(nodeId);
await context.hsLive.refresh(nodesResource, api);
return { message: "Machine expired" };
}
@@ -105,7 +102,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
}
try {
await api.setNodeTags(
await api.nodes.setTags(
nodeId,
tags.map((tag) => tag.trim()).filter((tag) => tag !== ""),
);
@@ -174,7 +171,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
}
}
await api.approveNodeRoutes(nodeId, newApproved);
await api.nodes.approveRoutes(nodeId, newApproved);
await context.hsLive.refresh(nodesResource, api);
return { message: "Routes updated" };
}
@@ -187,7 +184,12 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
});
}
await api.setNodeUser(nodeId, user);
if (!api.nodes.reassignUser) {
throw data("Reassigning a node owner is no longer supported on this Headscale version.", {
status: 400,
});
}
await api.nodes.reassignUser(nodeId, user);
await context.hsLive.refresh(nodesResource, api);
return { message: "Machine reassigned" };
}
+17 -12
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";
@@ -22,7 +22,6 @@ import Routes from "./dialogs/routes";
import { machineAction } from "./machine-actions";
export async function loader({ request, params, context }: Route.LoaderArgs) {
const principal = await context.auth.require(request);
if (!params.id) {
throw new Error("No machine ID provided");
}
@@ -38,9 +37,7 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
}
}
const api = context.hsApi.getRuntimeClient(
context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey),
);
const { api } = await context.apiForRequest(request);
const [nodesSnap, usersSnap] = await Promise.all([
context.hsLive.get(nodesResource, api),
context.hsLive.get(usersResource, api),
@@ -52,18 +49,19 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
throw data(null, { status: 404 });
}
const lookup = await context.agents?.lookup([node.nodeKey]);
const agents = context.agents.state === "enabled" ? context.agents.value : undefined;
const lookup = await agents?.lookup([node.nodeKey]);
const [enhancedNode] = mapNodes([node], lookup);
const tags = [...node.tags].toSorted();
const supportsNodeOwnerChange = !context.hsApi.clientHelpers.isAtleast("0.28.0");
const agentSync = context.agents?.lastSync();
const supportsNodeOwnerChange = !context.headscale.capabilities.nodeOwnerIsImmutable;
const agentSync = agents?.lastSync();
return {
agent: agentSync
? {
syncedAt: agentSync.syncedAt?.toISOString() ?? null,
nodeCount: agentSync.nodeCount,
nodeKey: context.agents?.agentNodeKey(),
nodeKey: agents?.agentNodeKey(),
}
: undefined,
existingTags: sortNodeTags(nodes),
@@ -283,14 +281,16 @@ export default function Page({
/>
<Attribute
name="Key expiry"
value={node.expiry !== null ? new Date(node.expiry).toLocaleString() : "Never"}
value={!isNoExpiry(node.expiry) ? new Date(node.expiry!).toLocaleString() : "Never"}
/>
{magic ? (
<Attribute isCopyable name="Domain" value={`${node.givenName}.${magic}`} />
) : undefined}
</div>
<div className="flex flex-col gap-1">
<p className="text-sm font-semibold uppercase opacity-75">Addresses</p>
<p className="text-sm font-semibold text-mist-600 uppercase dark:text-mist-300">
Addresses
</p>
<Attribute
isCopyable
name="Tailscale IPv4"
@@ -317,9 +317,14 @@ export default function Page({
value={`${node.givenName}.${magic}`}
/>
) : undefined}
{stats?.Endpoints ? (
<Attribute name="Endpoints" value={stats?.Endpoints?.join("\n") ?? "—"} />
) : undefined}
{stats ? (
<>
<p className="mt-4 text-sm font-semibold uppercase opacity-75">Client Connectivity</p>
<p className="mt-4 text-sm font-semibold text-mist-600 uppercase dark:text-mist-300">
Client Connectivity
</p>
<Attribute
name="Varies"
tooltip="Whether the machine is behind a difficult NAT that varies the machines IP address depending on the destination."
+6 -7
View File
@@ -30,9 +30,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
const writablePermission = context.auth.can(principal, Capabilities.write_machines);
const api = context.hsApi.getRuntimeClient(
context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey),
);
const { api } = await context.apiForRequest(request);
const [nodesSnap, usersSnap] = await Promise.all([
context.hsLive.get(nodesResource, api),
context.hsLive.get(usersResource, api),
@@ -47,17 +45,18 @@ export async function loader({ request, context }: Route.LoaderArgs) {
}
}
const stats = await context.agents?.lookup(nodes.map((node) => node.nodeKey));
const agents = context.agents.state === "enabled" ? context.agents.value : undefined;
const stats = await agents?.lookup(nodes.map((node) => node.nodeKey));
const populatedNodes = mapNodes(nodes, stats);
const supportsNodeOwnerChange = !context.hsApi.clientHelpers.isAtleast("0.28.0");
const agentSync = context.agents?.lastSync();
const supportsNodeOwnerChange = !context.headscale.capabilities.nodeOwnerIsImmutable;
const agentSync = agents?.lastSync();
return {
agent: agentSync
? {
syncedAt: agentSync.syncedAt?.toISOString() ?? null,
nodeCount: agentSync.nodeCount,
nodeKey: context.agents?.agentNodeKey(),
nodeKey: agents?.agentNodeKey(),
}
: undefined,
headscaleUserId: principal.kind === "oidc" ? principal.user.headscaleUserId : undefined,
+9 -9
View File
@@ -11,13 +11,13 @@ import { formatTimeDelta } from "~/utils/time";
import type { Route } from "./+types/agent";
export async function loader({ request, context }: Route.LoaderArgs) {
const principal = await context.auth.require(request);
await context.auth.require(request);
if (!context.agents) {
return { enabled: false as const };
if (context.agents.state !== "enabled") {
return { enabled: false as const, reason: context.agents.reason };
}
const sync = context.agents.lastSync();
const sync = context.agents.value.lastSync();
return {
enabled: true as const,
syncedAt: sync.syncedAt?.toISOString() ?? null,
@@ -29,12 +29,12 @@ export async function loader({ request, context }: Route.LoaderArgs) {
export async function action({ request, context }: Route.ActionArgs) {
await context.auth.require(request);
if (!context.agents) {
return { success: false, error: "Agent is not enabled" };
if (context.agents.state !== "enabled") {
return { success: false, error: context.agents.reason };
}
await context.agents.triggerSync();
const sync = context.agents.lastSync();
await context.agents.value.triggerSync();
const sync = context.agents.value.lastSync();
return { success: !sync.error, error: sync.error };
}
@@ -47,7 +47,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
<div className="flex max-w-(--breakpoint-lg) flex-col gap-8">
<Title>Headplane Agent</Title>
<Notice title="Agent Not Enabled">
The Headplane Agent is not enabled. To learn how to set up the agent, visit the{" "}
{loaderData.reason}. To learn how to set up the agent, visit the{" "}
<Link external styled to="https://headplane.dev/docs/agent">
documentation
</Link>
+23 -14
View File
@@ -2,13 +2,12 @@ import { data } from "react-router";
import { getOidcSubject } from "~/server/web/headscale-identity";
import { Capabilities } from "~/server/web/roles";
import type { PreAuthKey } from "~/types";
import type { Route } from "./+types/overview";
export async function authKeysAction({ request, context }: Route.ActionArgs) {
const principal = await context.auth.require(request);
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const api = context.hsApi.getRuntimeClient(apiKey);
const { principal, api } = await context.apiForRequest(request);
const canGenerateAny = context.auth.can(principal, Capabilities.generate_authkeys);
const canGenerateOwn = context.auth.can(principal, Capabilities.generate_own_authkeys);
@@ -21,7 +20,7 @@ export async function authKeysAction({ request, context }: Route.ActionArgs) {
async function checkSelfServiceOwnership(userId: string) {
if (canGenerateAny || !canGenerateOwn) return;
const [targetUser] = await api.getUsers(userId);
const [targetUser] = await api.users.list({ id: userId });
if (!targetUser) {
throw data("User not found.", { status: 404 });
}
@@ -85,20 +84,22 @@ export async function authKeysAction({ request, context }: Route.ActionArgs) {
const date = new Date();
date.setDate(date.getDate() + day);
const key = await api.createPreAuthKey(
const key = await api.preAuthKeys.create({
user,
ephemeral === "on",
reusable === "on",
date,
aclTags.length > 0 ? aclTags : null,
);
ephemeral: ephemeral === "on",
reusable: reusable === "on",
expiration: date,
aclTags: aclTags.length > 0 ? aclTags : null,
});
return data({ success: true as const, key: key.key });
}
case "expire_preauthkey": {
const keyId = formData.get("key_id")?.toString();
const key = formData.get("key")?.toString();
if (!key) {
return data("Missing `key` in the form data.", {
if (!keyId || !key) {
return data("Missing `key_id` or `key` in the form data.", {
status: 400,
});
}
@@ -111,10 +112,18 @@ export async function authKeysAction({ request, context }: Route.ActionArgs) {
}
await checkSelfServiceOwnership(user);
await api.expirePreAuthKey(user, key);
// `user` here is the Headscale numeric user id (form field is wired
// from User.id). Pre-0.28 expire posts a uint64 `user` field, which
// the API layer reads from `key.user?.id`. Headscale 0.28+ only
// looks at `key.id` (the stable preauthkey id).
await api.preAuthKeys.expire({
id: keyId,
key,
user: { id: user },
} as unknown as PreAuthKey);
return data("Pre-auth key expired");
}
default:
return data("Invalid action", {
status: 400,
@@ -29,7 +29,8 @@ function findCurrentUser(users: User[], subject: string | undefined): User | und
if (u.provider !== "oidc" || !u.providerId) {
return false;
}
return u.providerId.split("/").pop() === subject;
const segment = u.providerId.split("/").pop();
return segment ? decodeURIComponent(segment) === subject : false;
});
}
@@ -17,6 +17,7 @@ export default function ExpireAuthKey({ authKey, user }: ExpireAuthKeyProps) {
<Title>Expire auth key?</Title>
<input name="action_id" type="hidden" value="expire_preauthkey" />
<input name="user_id" type="hidden" value={user.id} />
<input name="key_id" type="hidden" value={authKey.id} />
<input name="key" type="hidden" value={authKey.key} />
<Text>
Expiring this authentication key will immediately prevent it from being used to
+8 -8
View File
@@ -19,9 +19,7 @@ import AuthKeyRow from "./auth-key-row";
import AddAuthKey from "./dialogs/add-auth-key";
export async function loader({ request, context }: Route.LoaderArgs) {
const principal = await context.auth.require(request);
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const api = context.hsApi.getRuntimeClient(apiKey);
const { principal, api } = await context.apiForRequest(request);
const usersSnap = await context.hsLive.get(usersResource, api);
const users = usersSnap.data;
@@ -31,10 +29,12 @@ export async function loader({ request, context }: Route.LoaderArgs) {
// Try fetching all keys at once (Headscale 0.28+), fall back to per-user
let allKeys: PreAuthKey[] | null = null;
try {
allKeys = await api.getAllPreAuthKeys();
} catch {
// Older versions don't support this endpoint
if (api.preAuthKeys.listAll) {
try {
allKeys = await api.preAuthKeys.listAll();
} catch {
// Treat any failure as "no global list available" and fall through.
}
}
if (allKeys !== null) {
@@ -67,7 +67,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
.filter((u) => u.id?.length > 0)
.map(async (user) => {
try {
const preAuthKeys = await api.getPreAuthKeys(user.id);
const preAuthKeys = await api.preAuthKeys.listForUser(user.id);
return { preAuthKeys, success: true as const, user };
} catch (error) {
log.error("api", "GET /v1/preauthkey for %s: %o", user.name, error);
+2 -2
View File
@@ -6,10 +6,10 @@ import PageError from "~/components/page-error";
import type { Route } from "./+types/overview";
export async function loader({ context }: Route.LoaderArgs) {
const oidcConnector = await context.oidc?.connector.get();
return {
config: context.hs.writable(),
isOidcEnabled: oidcConnector?.isValid ?? false,
isOidcEnabled:
context.oidc.state === "enabled" && context.oidc.value.status().state === "ready",
};
}
+6 -8
View File
@@ -28,8 +28,6 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
});
}
// We only need healthchecks which don't rely on an API key
const api = context.hsApi.getRuntimeClient("fake-api-key");
switch (action) {
case "add_domain": {
const domain = formData.get("domain")?.toString()?.trim();
@@ -48,7 +46,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
},
]);
context.integration?.onConfigChange(api);
context.integration?.onConfigChange(context.headscale);
return data("Domain added successfully.");
}
@@ -76,7 +74,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
value: domains,
},
]);
context.integration?.onConfigChange(api);
context.integration?.onConfigChange(context.headscale);
return data("Domain removed successfully.");
}
@@ -97,7 +95,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
},
]);
context.integration?.onConfigChange(api);
context.integration?.onConfigChange(context.headscale);
return data("Group added successfully.");
}
@@ -126,7 +124,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
},
]);
context.integration?.onConfigChange(api);
context.integration?.onConfigChange(context.headscale);
return data("Group removed successfully.");
}
@@ -147,7 +145,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
},
]);
context.integration?.onConfigChange(api);
context.integration?.onConfigChange(context.headscale);
return data("User added successfully.");
}
@@ -176,7 +174,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
},
]);
context.integration?.onConfigChange(api);
context.integration?.onConfigChange(context.headscale);
return data("User removed successfully.");
}
-278
View File
@@ -1,278 +0,0 @@
import { faker } from "@faker-js/faker";
import { eq } from "drizzle-orm";
import { Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import { data, type ShouldRevalidateFunction, useSubmit } from "react-router";
import { ExternalScriptsHandle } from "remix-utils/external-scripts";
import { EphemeralNodeInsert, ephemeralNodes } from "~/server/db/schema";
import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity";
import { useLiveData } from "~/utils/live-data";
import log from "~/utils/log";
import type { Route } from "./+types/console";
import UserPrompt from "./user-prompt";
import XTerm from "./xterm.client";
export const shouldRevalidate: ShouldRevalidateFunction = () => {
return false;
};
export async function loader({ request, context }: Route.LoaderArgs) {
const origin = new URL(request.url).origin;
const assets = [`${__PREFIX__}/wasm_exec.js`, `${__PREFIX__}/hp_ssh.wasm`];
const missing: string[] = [];
for (const file of assets) {
const res = await fetch(`${origin}${file}`, { method: "HEAD" });
if (!res.ok) missing.push(file);
}
if (missing.length > 0) {
throw data("WebSSH is not configured in this build.", 405);
}
if (!context.agents) {
throw data("WebSSH is only available with the Headplane agent integration", 400);
}
const principal = await context.auth.require(request);
if (principal.kind === "api_key") {
throw data("Only OAuth users are allowed to use WebSSH", 403);
}
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const api = context.hsApi.getRuntimeClient(apiKey);
const users = await api.getUsers();
// MARK: This assumes that a user has authenticated with Headscale first
// Since the only way to enforce permissions via ACLs is to generate a
// pre-authkey which REQUIRES a user ID, meaning the user has to have
// authenticated with Headscale first.
const lookup = findHeadscaleUserBySubject(users, principal.user.subject, principal.profile.email);
if (!lookup) {
throw data(`User with subject ${principal.user.subject} not found within Headscale`, 404);
}
const preAuthKey = await api.createPreAuthKey(
lookup.id,
true, // ephemeral
false, // reusable
new Date(Date.now() + 60 * 1000), // expiration: 1 minute
null, // aclTags
);
// TODO: Enable config to enforce generate_authkeys capability
// For now, any user is capable of WebSSH connections
// const check = await context.sessions.check(
// request,
// Capabilities.generate_authkeys,
// );
const qp = new URL(request.url).searchParams;
const username = qp.get("username") || undefined;
const hostname = qp.get("hostname") || undefined;
if (!hostname) {
throw data("Missing required parameter: hostname", 400);
}
if (!username) {
return {
ipnDetails: undefined,
sshDetails: {
username,
hostname,
},
};
}
// We're making a request to <url>/key?v=116 to check the CORS headers
const u = context.config.headscale.public_url ?? context.config.headscale.url;
// const res = await fetch(`${u}/key?v=116`, {
// method: 'GET',
// });
// const corsOrigin = res.headers.get('Access-Control-Allow-Origin');
// const corsMethods = res.headers.get('Access-Control-Allow-Methods');
// const corsHeaders = res.headers.get('Access-Control-Allow-Headers');
// console.log(corsOrigin, corsMethods, corsHeaders);
// if (!corsOrigin || !corsMethods || !corsHeaders) {
// throw data(
// 'Headscale server does not have the required CORS headers for WebSSH',
// 500,
// );
// }
const nodes = await api.getNodes();
const lookupNode = nodes.find((n) => n.givenName === hostname);
if (!lookupNode) {
throw data(`Node with hostname ${hostname} not found`, 404);
}
// Last thing is keeping track of the ephemeral node in the database
// because Headscale doesn't automatically delete ephemeral nodes???
const [_ephemeralNode] = await context.db
.insert(ephemeralNodes)
.values({
auth_key: preAuthKey.key,
} satisfies EphemeralNodeInsert)
.returning();
return {
ipnDetails: {
PreAuthKey: preAuthKey.key,
Hostname: generateHostname(username),
ControlURL: u,
},
sshDetails: {
username,
hostname,
},
};
}
function generateHostname(username: string) {
const adjective = faker.word.adjective({
length: {
min: 3,
max: 6,
},
});
const noun = faker.word.noun({
length: {
min: 3,
max: 6,
},
});
return `ssh-${adjective}-${noun}-${username}`;
}
export async function action({ request, context }: Route.ActionArgs) {
await context.auth.require(request);
if (!context.agents) {
throw data("WebSSH is only available with the Headplane agent integration", 400);
}
const form = await request.formData();
const nodeKey = form.get("node_key");
const authKey = form.get("auth_key");
if (nodeKey === null || typeof nodeKey !== "string") {
throw data("Missing node_key", 400);
}
if (authKey === null || typeof authKey !== "string") {
throw data("Missing auth_key", 400);
}
await context.db
.update(ephemeralNodes)
.set({
node_key: nodeKey,
})
.where(eq(ephemeralNodes.auth_key, authKey));
context.agents?.triggerSync().catch((err) => {
log.debug("agent", "Background agent sync failed: %s", err);
});
}
export const links: Route.LinksFunction = () => [
{
rel: "preload",
href: `${__PREFIX__}/hp_ssh.wasm`,
as: "fetch",
type: "application/wasm",
crossOrigin: "anonymous",
},
];
export const handle: ExternalScriptsHandle = {
scripts: [
{
src: `${__PREFIX__}/wasm_exec.js`,
crossOrigin: "anonymous",
preload: true,
},
],
};
export default function Page({ loaderData: { ipnDetails, sshDetails } }: Route.ComponentProps) {
const submit = useSubmit();
const { pause } = useLiveData();
const [ipn, setIpn] = useState<TsWasmNet | null>(null);
const [nodeKey, setNodeKey] = useState<string | null>(null);
useEffect(() => {
if (!ipnDetails) {
return;
}
pause();
const go = new Go(); // Go is defined by wasm_exec.js
WebAssembly.instantiateStreaming(fetch(`${__PREFIX__}/hp_ssh.wasm`), go.importObject).then(
(value) => {
go.run(value.instance);
const handle = TsWasmNet(ipnDetails, {
NotifyState: (state) => {
console.log("State changed:", state);
if (state === "Running") {
setIpn(handle);
}
},
NotifyNetMap: (netmap) => {
// Only set NodeKey if it is not already set and then
// also dispatch that to the backend to track the
// ephemeral node.
//
// We open an SSE connection to the backend
// so that when the connection is closed,
// the backend can delete the ephemeral node.
if (nodeKey === null) {
setNodeKey(netmap.NodeKey);
submit(
{
node_key: netmap.NodeKey,
auth_key: ipnDetails.PreAuthKey,
},
{ method: "POST" },
);
}
},
NotifyBrowseToURL: (url) => {
console.log("Browse to URL:", url);
},
NotifyPanicRecover: (message) => {
console.error("Panic recover:", message);
},
});
handle.Start();
},
);
}, []);
if (!sshDetails.username) {
return <UserPrompt hostname={sshDetails.hostname} />;
}
return (
<div className="h-screen w-screen bg-mist-900">
{ipn === null ? (
<div className="mx-auto flex h-screen items-center justify-center">
<Loader2 className="size-10 animate-spin text-mist-50" />
</div>
) : (
<div className="flex h-screen flex-col">
<XTerm hostname={sshDetails.hostname} ipn={ipn} username={sshDetails.username} />
</div>
)}
</div>
);
}
+77
View File
@@ -0,0 +1,77 @@
import { AlertCircle } from "lucide-react";
import Card from "~/components/card";
import Link from "~/components/link";
export const sshErrors = {
wasm_missing: {
title: "Browser SSH is not available",
message: "This version of Headplane was not built with browser SSH support.",
anchor: "#ssh-not-available",
},
agent_required: {
title: "Browser SSH requires the Headplane agent",
message: "Browser SSH is only available when the Headplane agent integration is enabled.",
anchor: "#agent-required",
},
oidc_required: {
title: "Browser SSH requires OIDC authentication",
message: "Browser SSH is only available when OIDC authentication is enabled.",
anchor: "#oidc-required",
},
node_not_found: (hostname: string) => ({
title: "Node not found",
message: `No node found with hostname ${hostname}.`,
anchor: "#node-not-found",
}),
user_not_linked: {
title: "User account not linked",
message:
"You'll need to link your user account to a Headscale user before you can use Browser SSH.",
anchor: "#user-not-linked",
},
} as const;
interface SSHErrorBoundaryProps {
title: string;
message: string;
anchor: string;
}
export function isSSHError(error: unknown): error is SSHErrorBoundaryProps {
return (
typeof error === "object" &&
error !== null &&
"title" in error &&
"message" in error &&
"anchor" in error &&
typeof error.title === "string" &&
typeof error.message === "string" &&
typeof error.anchor === "string"
);
}
const DOCS_BASE = "https://headplane.net/features/ssh";
export function SSHErrorBoundary({ title, message, anchor }: SSHErrorBoundaryProps) {
return (
<Card className="w-screen" variant="flat">
<div className="flex items-center justify-between gap-4">
<Card.Title>{title}</Card.Title>
<AlertCircle className="mb-2 h-6 w-6 text-red-500" />
</div>
<Card.Text>
{message}
<br />
<br />
<Link to={`${DOCS_BASE}${anchor}`} external styled>
Headplane SSH Documentation
</Link>{" "}
</Card.Text>
</Card>
);
}
+137
View File
@@ -0,0 +1,137 @@
import { useEffect, useRef } from "react";
import { Restty } from "restty";
import type { GhosttyTheme } from "restty";
import type { PtyTransport } from "restty/internal";
import type { HeadplaneSSH, TunnelSession } from "./wasm.client";
const FONT_BASE = `${__PREFIX__}/fonts`;
// Ghostty's default canvas background is rgb(20,23,26) — a dark gray, not black.
// Override it so the terminal matches the page and pane container backgrounds.
const HEADPLANE_THEME: GhosttyTheme = {
colors: {
background: { r: 0, g: 0, b: 0 },
foreground: { r: 235, g: 237, b: 242 },
palette: [],
},
raw: {},
};
function createSSHTransport(ssh: HeadplaneSSH, ipAddress: string, username: string): PtyTransport {
let session: TunnelSession | null = null;
return {
connect(options) {
session = ssh.openTunnel({
ipAddress,
username,
onData: (data) => options.callbacks.onData?.(data),
onConnect: () => options.callbacks.onConnect?.(),
onDisconnect: () => {
options.callbacks.onDisconnect?.();
session = null;
},
});
if (options.cols && options.rows) {
session.resize(options.cols, options.rows);
}
},
disconnect() {
session?.close();
session = null;
},
sendInput(data) {
session?.writeInput(data);
return session != null;
},
resize(cols, rows) {
session?.resize(cols, rows);
return session != null;
},
isConnected() {
return session != null;
},
destroy() {
session?.close();
session = null;
},
};
}
interface GhosttyProps {
ssh: HeadplaneSSH;
ipAddress: string;
username: string;
onConnected: () => void;
}
export default function Ghostty({ ssh, ipAddress, username, onConnected }: GhosttyProps) {
const divRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!divRef.current) return;
const transport = createSSHTransport(ssh, ipAddress, username);
const restty = new Restty({
root: divRef.current,
createInitialPane: true,
defaultContextMenu: false,
shortcuts: false,
searchUi: false,
paneStyles: {
inactivePaneOpacity: 1,
activePaneOpacity: 1,
},
appOptions: {
fontSize: 20,
ligatures: true,
fontPreset: "none",
fontSources: [
{
type: "url",
url: `${FONT_BASE}/JetBrainsMonoNLNerdFontMono-Regular.ttf`,
label: "JetBrains Mono Nerd Font",
},
{
type: "url",
url: `${FONT_BASE}/JetBrainsMonoNLNerdFontMono-Bold.ttf`,
label: "JetBrains Mono Nerd Font Bold",
},
{
type: "url",
url: `${FONT_BASE}/JetBrainsMonoNLNerdFontMono-Italic.ttf`,
label: "JetBrains Mono Nerd Font Italic",
},
{
type: "url",
url: `${FONT_BASE}/JetBrainsMonoNLNerdFontMono-BoldItalic.ttf`,
label: "JetBrains Mono Nerd Font Bold Italic",
},
{
type: "url",
url: `${FONT_BASE}/SymbolsNerdFontMono-Regular.ttf`,
label: "Symbols Nerd Font",
},
],
ptyTransport: transport,
callbacks: {
onPtyStatus: (status) => {
if (status === "connected") onConnected();
},
},
},
});
restty.applyTheme(HEADPLANE_THEME);
restty.updateSize(true);
restty.connectPty();
return () => {
restty.destroy();
};
}, [ssh, ipAddress, username]);
return <div className="min-h-0 min-w-0 flex-1 overflow-hidden bg-black" ref={divRef} />;
}
-57
View File
@@ -1,57 +0,0 @@
declare function TsWasmNet(
options: TsWasmNetOptions,
callbacks: TsWasmNetCallbacks,
): TsWasmNet;
interface TsWasmNetOptions {
ControlURL: string;
PreAuthKey: string;
Hostname: string;
}
interface TsWasmNetCallbacks {
NotifyState: (state: IPNState) => void;
NotifyNetMap: (netmap: TsWasmNetMap) => void;
NotifyBrowseToURL: (url: string) => void;
NotifyPanicRecover: (err: string) => void;
}
interface TsWasmNetMap {
NodeKey: string;
}
interface TsWasmNet {
Start: () => void;
OpenSSH: (
hostname: string,
username: string,
options: XtermConfig,
) => SSHSession;
}
type IPNState =
| 'NoState'
| 'InUseOtherUser'
| 'NeedsLogin'
| 'NeedsMachineAuth'
| 'Stopped'
| 'Starting'
| 'Running';
interface XtermConfig {
rows: number;
cols: number;
timeout?: number;
onStdout: (data: Uint8Array) => void;
onStderr: (data: Uint8Array) => void;
onStdin: (func: (input: Uint8Array) => void) => void;
onConnect: () => void;
onDisconnect: () => void;
}
interface SSHSession {
Close(): boolean;
Resize(rows: number, cols: number): boolean;
}
+226
View File
@@ -0,0 +1,226 @@
import { Loader2, WifiOff } from "lucide-react";
import { useEffect, useState } from "react";
import { data, isRouteErrorResponse, type ShouldRevalidateFunction } from "react-router";
import Button from "~/components/button";
import Card from "~/components/card";
import Code from "~/components/code";
import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity";
import type { Route } from "./+types/page";
import { isSSHError, SSHErrorBoundary, sshErrors } from "./errors";
import Ghostty from "./ghostty.client";
import UserPrompt from "./user-prompt";
import type { HeadplaneSSH } from "./wasm.client";
import { loadHeadplaneWASM } from "./wasm.client";
const WASM_MODULE_URL = `${__PREFIX__}/hp_ssh.wasm`;
const WASM_HELPER_URL = `${__PREFIX__}/wasm_exec.js`;
export const shouldRevalidate: ShouldRevalidateFunction = () => {
return false;
};
export async function loader({ request, params, context }: Route.LoaderArgs) {
const origin = new URL(request.url).origin;
const assets = [WASM_HELPER_URL, WASM_MODULE_URL];
const missing: string[] = [];
for (const file of assets) {
const res = await fetch(`${origin}${file}`, { method: "HEAD" });
if (!res.ok) {
missing.push(file);
}
}
if (missing.length > 0) {
throw data(sshErrors.wasm_missing, 405);
}
if (context.agents.state !== "enabled") {
throw data(sshErrors.agent_required, 400);
}
const { principal, api } = await context.apiForRequest(request);
if (principal.kind === "api_key") {
throw data(sshErrors.oidc_required, 403);
}
const hostname = params.id;
const username = new URL(request.url).searchParams.get("user") || undefined;
const nodes = await api.nodes.list();
const node = nodes.find((n) => n.givenName === hostname);
if (!node) {
throw data(sshErrors.node_not_found(hostname), 404);
}
if (!node.online) {
return { hostname, username, offline: true, node: undefined };
}
if (!username) {
return { hostname, username: undefined, offline: false, node: undefined };
}
// The user must exist within Headscale to generate a pre-auth key
const users = await api.users.list();
const hsUser = findHeadscaleUserBySubject(users, principal.user.subject, principal.profile.email);
if (!hsUser) {
throw data(sshErrors.user_not_linked, 404);
}
const preAuthKey = await api.preAuthKeys.create({
user: hsUser.id,
ephemeral: true,
reusable: false,
expiration: new Date(Date.now() + 60 * 1000), // 1 minute expiry
aclTags: null,
});
const controlURL = context.config.headscale.public_url ?? context.config.headscale.url;
return {
hostname,
username,
offline: false,
node: {
ipAddress: node.ipAddresses[0],
controlURL,
preAuthKey: preAuthKey.key,
ephemeralHostname: generateHostname(username),
},
};
}
function generateHostname(username: string) {
const hex = crypto.randomUUID().replace(/-/g, "").slice(0, 8);
return `ssh-${hex}-${username}`;
}
export const links: Route.LinksFunction = () => [
{
rel: "preload",
href: WASM_MODULE_URL,
as: "fetch",
type: "application/wasm",
crossOrigin: "anonymous",
},
];
export default function Page({ loaderData }: Route.ComponentProps) {
const { hostname, username, offline, node } = loaderData;
if (offline) {
return (
<div className="flex h-screen w-screen items-center justify-center bg-black">
<Card className="w-screen" variant="flat">
<div className="flex items-center justify-between gap-4">
<Card.Title>Node Offline</Card.Title>
<WifiOff className="mb-2 h-6 w-6 text-red-500" />
</div>
<Card.Text>
<Code>{hostname}</Code> is not currently connected to the Tailnet.
</Card.Text>
<Button className="mt-8 w-full" onClick={() => window.location.reload()}>
Retry Connection
</Button>
</Card>
</div>
);
}
if (!username || !node) {
return <UserPrompt hostname={hostname} />;
}
return <SSHConsole hostname={hostname} username={username} node={node} />;
}
function SSHConsole({
hostname,
username,
node,
}: {
hostname: string;
username: string;
node: { ipAddress: string; controlURL: string; preAuthKey: string; ephemeralHostname: string };
}) {
const [ssh, setSsh] = useState<HeadplaneSSH | null>(null);
const [connected, setConnected] = useState(false);
const [status, setStatus] = useState("Starting tunnel…");
useEffect(() => {
let cancelled = false;
console.log("[ssh] Loading WASM factory");
loadHeadplaneWASM().then((create) => {
console.log("[ssh] Factory loaded, creating IPN", create);
if (cancelled) {
return;
}
setStatus("Joining Tailnet…");
const instance = create({
controlURL: node.controlURL,
preAuthKey: node.preAuthKey,
hostname: node.ephemeralHostname,
onReady: () => {
console.log("[ssh] IPN ready (Running)");
if (!cancelled) {
setStatus(`Connecting to ${hostname}`);
setSsh(instance);
}
},
onError: (msg) => console.error("[ssh] IPN error:", msg),
});
console.log("[ssh] IPN instance created", instance);
});
return () => {
cancelled = true;
};
}, [node]);
return (
<div className="fixed inset-0 flex flex-col bg-black">
{!connected && (
<div className="absolute inset-0 z-50 flex items-center justify-center">
<div className="flex flex-col items-center gap-3">
<Loader2 className="size-8 animate-spin text-mist-200" />
<p className="text-sm text-mist-400">{status}</p>
</div>
</div>
)}
{ssh && (
<Ghostty
ssh={ssh}
username={username}
ipAddress={node.ipAddress}
onConnected={() => setConnected(true)}
/>
)}
</div>
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
const routeError = isRouteErrorResponse(error) ? error.data : null;
if (routeError == null || !isSSHError(routeError)) {
// Pass through further down the tree to the global error boundary
throw error;
}
return (
<div className="flex h-screen w-screen items-center justify-center">
<SSHErrorBoundary
title={routeError.title}
message={routeError.message}
anchor={routeError.anchor}
/>
</div>
);
}
+40 -21
View File
@@ -1,17 +1,16 @@
import { useState } from "react";
import { Form } from "react-router";
import Button from "~/components/button";
import Card from "~/components/card";
import Code from "~/components/code";
import Input from "~/components/input";
import Link from "~/components/link";
interface UserPromptProps {
hostname: string;
}
export default function UserPrompt({ hostname }: UserPromptProps) {
const [username, setUsername] = useState("");
return (
<div className="flex h-screen items-center justify-center">
<Card>
@@ -19,27 +18,47 @@ export default function UserPrompt({ hostname }: UserPromptProps) {
<Card.Text className="mb-4">
Enter the username you want to use to connect to <Code>{hostname}</Code>
{". "}
WebSSH follows the Headscale ACLs, so only permitted usernames will be able to connect.
SSH via the web follows the same ACL rules as regular SSH access in Headscale, so only
permitted usernames will work.
<br />
<br />
See the{" "}
<Link external styled to="https://headplane.net/features/ssh#troubleshooting">
troubleshooting guide
</Link>{" "}
for common errors.
</Card.Text>
<Input
labelHidden
type="text"
label="Username"
placeholder="Username"
className="mb-2"
onChange={setUsername}
/>
<Button
variant="heavy"
className="w-full"
onClick={() => {
// We can't use the navigate hook here as we need to do a
// full page reload to ensure the SSH connection is established
window.location.href = `${__PREFIX__}/ssh?hostname=${hostname}&username=${username}`;
<Form
method="GET"
onSubmit={(e) => {
const formData = new FormData(e.currentTarget);
const username = formData.get("user");
if (!username) {
e.preventDefault();
return;
}
// We have to do a full navigation, since the page needs a full
// reload to initialize the SSH connection due to us disabling the
// revalidator.
const url = new URL(window.location.href);
url.searchParams.set("user", username.toString());
window.location.assign(url.toString());
}}
>
Connect
</Button>
<Input
labelHidden
type="text"
label="Username"
name="user"
placeholder="Username"
className="mb-2"
required
/>
<Button type="submit" variant="heavy" className="w-full">
Connect
</Button>
</Form>
</Card>
</div>
);
+82
View File
@@ -0,0 +1,82 @@
const WASM_MODULE_URL = `${__PREFIX__}/hp_ssh.wasm`;
const WASM_HELPER_URL = `${__PREFIX__}/wasm_exec.js`;
declare global {
type HeadplaneSSHFactory = (config: HeadplaneSSHConfig) => HeadplaneSSH;
var __hp_ssh_resolve: ((factory: HeadplaneSSHFactory) => void) | undefined;
var Go: {
new (): {
importObject: WebAssembly.Imports;
run(instance: WebAssembly.Instance): Promise<void>;
argv?: string[];
env?: Record<string, string>;
exit?: (code: number) => void;
};
};
}
interface HeadplaneSSHConfig {
controlURL: string;
preAuthKey: string;
hostname: string;
onReady: () => void;
onError?: (message: string) => void;
}
export interface HeadplaneSSH {
openTunnel(config: TunnelConfig): TunnelSession;
}
interface TunnelConfig {
ipAddress: string;
username: string;
timeout?: number;
onData: (data: string) => void;
onConnect: () => void;
onDisconnect: () => void;
}
export interface TunnelSession {
writeInput(data: string): void;
resize(cols: number, rows: number): void;
close(): void;
}
let resolvedFactory: Promise<HeadplaneSSHFactory> | null = null;
function loadGoHelper(): Promise<void> {
if (typeof globalThis.Go !== "undefined") {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = WASM_HELPER_URL;
script.crossOrigin = "anonymous";
script.onload = () => resolve();
script.onerror = () => reject(new Error("Failed to load Go WASM helper"));
document.head.appendChild(script);
});
}
/**
* One-shot function that loads the Go WASM binary and returns the SSH factory.
* Automatically loads the Go JS helper if it hasn't been loaded yet.
*/
export async function loadHeadplaneWASM(): Promise<HeadplaneSSHFactory> {
if (!resolvedFactory) {
await loadGoHelper();
const go = new Go();
const result = await WebAssembly.instantiateStreaming(fetch(WASM_MODULE_URL), go.importObject);
resolvedFactory = new Promise<HeadplaneSSHFactory>((resolve) => {
globalThis.__hp_ssh_resolve = resolve;
});
go.run(result.instance);
}
return resolvedFactory;
}
-7
View File
@@ -1,7 +0,0 @@
declare class Go {
importObject: WebAssembly.Imports;
run(instance: WebAssembly.Instance): Promise<void>;
argv?: string[];
env?: Record<string, string>;
exit?: (code: number) => void;
}
-218
View File
@@ -1,218 +0,0 @@
import { ClipboardAddon } from "@xterm/addon-clipboard";
import { FitAddon } from "@xterm/addon-fit";
import { Unicode11Addon } from "@xterm/addon-unicode11";
import { WebLinksAddon } from "@xterm/addon-web-links";
import * as xterm from "@xterm/xterm";
import { Loader2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import cn from "~/utils/cn";
import { useLiveData } from "~/utils/live-data";
import toast from "~/utils/toast";
import "@xterm/xterm/css/xterm.css";
interface XTermProps {
ipn: TsWasmNet;
username: string;
hostname: string;
}
// Go's WASM -> JS crosses realms so we might have to normalize the data under
// certain conditions. This also enforces bytes instead of strings being sent.
function normU8(data: unknown) {
if (data instanceof Uint8Array) {
return data;
}
if (data && typeof data === "object") {
const any = data as {
buffer?: ArrayBufferLike;
byteOffset?: number;
byteLength?: number;
};
if (any.buffer instanceof ArrayBuffer && typeof any.byteLength === "number") {
return new Uint8Array(
any.buffer.slice(any.byteOffset ?? 0, (any.byteOffset ?? 0) + any.byteLength),
);
}
}
throw new Error("Data is not a Uint8Array or ArrayBuffer-like object");
}
export default function XTerm({ ipn, username, hostname }: XTermProps) {
const { pause } = useLiveData();
const genRef = useRef(0);
const termRef = useRef<xterm.Terminal>(null);
const roRef = useRef<ResizeObserver>(null);
const inputRef = useRef<(input: Uint8Array) => void>(null);
const sshRef = useRef<SSHSession>(null);
const divRef = useRef<HTMLDivElement>(null);
const [isResizing, setIsResizing] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
pause();
});
useEffect(() => {
if (!divRef.current) {
return;
}
const currentGen = ++genRef.current;
const term = new xterm.Terminal({
allowProposedApi: true,
cursorBlink: true,
convertEol: true,
fontSize: 14,
});
const fit = new FitAddon();
term.loadAddon(fit);
term.loadAddon(new Unicode11Addon());
term.loadAddon(new ClipboardAddon());
term.loadAddon(
new WebLinksAddon((event, uri) => {
event.view?.open(uri, "_blank", "noopener noreferrer");
}),
);
term.unicode.activeVersion = "11";
termRef.current = term;
term.open(divRef.current!);
fit.fit();
term.focus();
const session = ipn.OpenSSH(hostname, username, {
rows: term.rows,
cols: term.cols,
onStdout: (data) => {
if (currentGen !== genRef.current || term !== termRef.current) {
console.warn("Stale terminal instance, ignoring stdout");
return;
}
const text = normU8(data);
term.write(text);
},
onStderr: (data) => {
if (currentGen !== genRef.current || term !== termRef.current) {
console.warn("Stale terminal instance, ignoring stderr");
return;
}
const text = normU8(data);
term.write(text);
const str = new TextDecoder().decode(text);
setError(str);
},
onStdin: (func) => {
inputRef.current = func;
},
onConnect: () => {
if (currentGen !== genRef.current) {
console.warn("Stale terminal instance, ignoring onConnect");
return;
}
setIsLoading(false);
},
onDisconnect: () => {
if (currentGen !== genRef.current) {
console.warn("Stale terminal instance, ignoring onDisconnect");
return;
}
roRef.current?.disconnect();
term.dispose();
termRef.current = null;
inputRef.current = null;
sshRef.current = null;
setIsLoading(false);
},
});
sshRef.current = session;
const enc = new TextEncoder();
term.onData((data) => {
if (currentGen !== genRef.current) {
console.warn("Stale terminal instance, ignoring onData");
return;
}
const bytes = enc.encode(data);
inputRef.current?.(bytes);
});
const ro = new ResizeObserver(() => {
if (currentGen !== genRef.current || term !== termRef.current) {
console.warn("Stale terminal instance, ignoring resize");
return;
}
setIsResizing(true);
fit.fit();
sshRef.current?.Resize(term.cols, term.rows);
setTimeout(() => setIsResizing(false), 100);
});
roRef.current = ro;
ro.observe(divRef.current!);
return () => {
++genRef.current;
roRef.current?.disconnect();
roRef.current = null;
sshRef.current?.Close();
sshRef.current = null;
term.dispose();
if (termRef.current === term) {
termRef.current = null;
}
inputRef.current = null;
};
}, [ipn, username, hostname]);
return (
<>
{isLoading ? (
<div className="absolute z-50 mx-auto flex h-screen w-screen items-center justify-center">
<Loader2 className="size-10 animate-spin text-mist-50" />
</div>
) : undefined}
<div className={cn("w-full h-full", isLoading ? "opacity-0" : "opacity-100")} ref={divRef} />
{termRef.current && isResizing ? (
<div
className={cn(
"absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2",
"px-4 py-2 bg-mist-800 text-white rounded-full shadow z-50",
)}
>
{termRef.current.cols}x{termRef.current.rows}
</div>
) : undefined}
{error !== null ? (
<div
className={cn(
"absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-center",
"px-4 py-2 bg-mist-800 text-white rounded-full shadow z-50",
)}
>
Failed to connect to SSH session
{error}
</div>
) : undefined}
</>
);
}
@@ -30,8 +30,8 @@ export default function HeadplaneUserRow({
const displayEmail = user.linkedHeadscaleUser?.email ?? user.email;
return (
<tr className="group hover:bg-mist-50 dark:hover:bg-mist-950" key={user.id}>
<td className="py-2 pl-0.5">
<tr className="group hover:bg-mist-100 dark:hover:bg-mist-800" key={user.id}>
<td className="py-2 pl-2">
<div className="flex items-center">
{user.profilePicUrl ? (
<img alt={displayName} className="h-10 w-10 rounded-full" src={user.profilePicUrl} />
@@ -0,0 +1,58 @@
import { Ellipsis } from "lucide-react";
import { useState } from "react";
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "~/components/menu";
import Delete from "../dialogs/delete-user";
import Rename from "../dialogs/rename-user";
import type { UnlinkedHeadscaleUser } from "../overview";
interface HeadscaleUserMenuProps {
user: UnlinkedHeadscaleUser;
}
type Modal = "rename" | "delete" | null;
export default function HeadscaleUserMenu({ user }: HeadscaleUserMenuProps) {
const [modal, setModal] = useState<Modal>(null);
// Headscale-managed OIDC users cannot be renamed via the API.
const canRename = user.provider !== "oidc";
return (
<>
{modal === "rename" && canRename && (
<Rename
isOpen={modal === "rename"}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
user={user}
/>
)}
{modal === "delete" && (
<Delete
isOpen={modal === "delete"}
machines={user.machines}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
user={user}
/>
)}
<Menu>
<MenuTrigger className="w-10 rounded-full bg-transparent p-1 py-0.5 hover:bg-mist-100 dark:hover:bg-mist-800">
<Ellipsis className="h-5" />
</MenuTrigger>
<MenuContent>
{canRename && <MenuItem onClick={() => setModal("rename")}>Rename</MenuItem>}
{canRename && <MenuSeparator />}
<MenuItem variant="danger" onClick={() => setModal("delete")}>
Delete
</MenuItem>
</MenuContent>
</Menu>
</>
);
}
@@ -4,12 +4,14 @@ import StatusCircle from "~/components/status-circle";
import cn from "~/utils/cn";
import type { UnlinkedHeadscaleUser } from "../overview";
import HeadscaleUserMenu from "./headscale-user-menu";
interface HeadscaleUserRowProps {
user: UnlinkedHeadscaleUser;
writable?: boolean;
}
export default function HeadscaleUserRow({ user }: HeadscaleUserRowProps) {
export default function HeadscaleUserRow({ user, writable }: HeadscaleUserRowProps) {
const isOnline = user.machines.some((machine) => machine.online);
const lastSeen = user.machines.reduce(
(acc, machine) => Math.max(acc, new Date(machine.lastSeen).getTime()),
@@ -54,9 +56,7 @@ export default function HeadscaleUserRow({ user }: HeadscaleUserRowProps) {
<p className="text-sm text-mist-600 dark:text-mist-300">No machines</p>
)}
</td>
<td className="py-2 pr-0.5">
{/* Unlinked users only get basic Headscale operations (rename, delete) */}
</td>
<td className="py-2 pr-0.5">{writable ? <HeadscaleUserMenu user={user} /> : null}</td>
</tr>
);
}
+3 -3
View File
@@ -54,24 +54,24 @@ export default function UserMenu({
{modal === "reassign" && (
<Reassign
displayName={displayName}
headplaneUserId={user.id}
isOpen={modal === "reassign"}
role={user.role}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
userId={user.linkedHeadscaleUser?.id ?? user.id}
/>
)}
{modal === "link" && (
<LinkUser
currentLink={currentLink}
displayName={displayName}
headplaneUserId={user.id}
headscaleUsers={linkableUsers}
isOpen={modal === "link"}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
userId={user.linkedHeadscaleUser?.id ?? user.id}
/>
)}
{modal === "transfer" && (
@@ -81,7 +81,7 @@ export default function UserMenu({
if (!isOpen) setModal(null);
}}
targetDisplayName={displayName}
targetUserId={user.linkedHeadscaleUser?.id ?? user.id}
targetHeadplaneUserId={user.id}
/>
)}
+1 -1
View File
@@ -34,7 +34,7 @@ export default function DeleteUser({ user, machines, isOpen, setIsOpen }: Delete
</Text>
)}
<input name="action_id" type="hidden" value="delete_user" />
<input name="user_id" type="hidden" value={user.id} />
<input name="headscale_user_id" type="hidden" value={user.id} />
</DialogPanel>
</Dialog>
);
+3 -3
View File
@@ -5,7 +5,7 @@ import Title from "~/components/title";
import cn from "~/utils/cn";
interface LinkUserProps {
userId: string;
headplaneUserId: string;
displayName: string;
headscaleUsers: { id: string; name: string }[];
currentLink?: string;
@@ -14,7 +14,7 @@ interface LinkUserProps {
}
export default function LinkUser({
userId,
headplaneUserId,
displayName,
headscaleUsers,
currentLink,
@@ -34,7 +34,7 @@ export default function LinkUser({
) : (
<>
<input name="action_id" type="hidden" value="link_user" />
<input name="user_id" type="hidden" value={userId} />
<input name="headplane_user_id" type="hidden" value={headplaneUserId} />
<select
className={cn(
"w-full rounded-lg border p-2",
+3 -3
View File
@@ -8,7 +8,7 @@ import { Roles } from "~/server/web/roles";
import type { Role } from "~/server/web/roles";
interface ReassignProps {
userId: string;
headplaneUserId: string;
displayName: string;
role: Role;
isOpen: boolean;
@@ -16,7 +16,7 @@ interface ReassignProps {
}
export default function ReassignUser({
userId,
headplaneUserId,
displayName,
role,
isOpen,
@@ -38,7 +38,7 @@ export default function ReassignUser({
) : (
<>
<input name="action_id" type="hidden" value="reassign_user" />
<input name="user_id" type="hidden" value={userId} />
<input name="headplane_user_id" type="hidden" value={headplaneUserId} />
<RadioGroup className="gap-4" defaultValue={role} label="Role" name="new_role">
{Object.keys(Roles)
.filter((r) => r !== "owner")
+1 -1
View File
@@ -21,7 +21,7 @@ export default function RenameUser({ user, isOpen, setIsOpen }: RenameProps) {
update any ACL policies that may refer to this user by their old username.
</Text>
<input name="action_id" type="hidden" value="rename_user" />
<input name="user_id" type="hidden" value={user.id} />
<input name="headscale_user_id" type="hidden" value={user.id} />
<Input
defaultValue={user.name}
required
@@ -4,14 +4,14 @@ import Text from "~/components/text";
import Title from "~/components/title";
interface TransferOwnershipProps {
targetUserId: string;
targetHeadplaneUserId: string;
targetDisplayName: string;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
export default function TransferOwnership({
targetUserId,
targetHeadplaneUserId,
targetDisplayName,
isOpen,
setIsOpen,
@@ -29,7 +29,7 @@ export default function TransferOwnership({
ownership.
</Notice>
<input name="action_id" type="hidden" value="transfer_ownership" />
<input name="user_id" type="hidden" value={targetUserId} />
<input name="headplane_user_id" type="hidden" value={targetHeadplaneUserId} />
</DialogPanel>
</Dialog>
);
+2 -3
View File
@@ -54,8 +54,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
let apiError: string | undefined;
try {
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const api = context.hsApi.getRuntimeClient(apiKey);
const { api } = await context.apiForRequest(request);
const [nodesSnap, usersSnap] = await Promise.all([
context.hsLive.get(nodesResource, api),
context.hsLive.get(usersResource, api),
@@ -235,7 +234,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
)}
>
{loaderData.unlinkedHeadscaleUsers.map((user) => (
<HeadscaleUserRow key={user.id} user={user} />
<HeadscaleUserRow key={user.id} user={user} writable={loaderData.writable} />
))}
</tbody>
</table>
+24 -62
View File
@@ -1,7 +1,6 @@
import { data } from "react-router";
import { usersResource } from "~/server/headscale/live-store";
import { getOidcSubject } from "~/server/web/headscale-identity";
import { Capabilities } from "~/server/web/roles";
import type { Role } from "~/server/web/roles";
@@ -24,8 +23,7 @@ export async function userAction({ request, context }: Route.ActionArgs) {
});
}
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const api = context.hsApi.getRuntimeClient(apiKey);
const { api } = await context.apiForRequest(request);
switch (action) {
case "create_user": {
const name = formData.get("username")?.toString();
@@ -38,33 +36,33 @@ export async function userAction({ request, context }: Route.ActionArgs) {
});
}
await api.createUser(name, email, displayName);
await api.users.create({ name, email, displayName });
await context.hsLive.refresh(usersResource, api);
return { message: "User created successfully" };
}
case "delete_user": {
const userId = formData.get("user_id")?.toString();
if (!userId) {
throw data("Missing `user_id` in the form data.", {
const headscaleUserId = formData.get("headscale_user_id")?.toString();
if (!headscaleUserId) {
throw data("Missing `headscale_user_id` in the form data.", {
status: 400,
});
}
await api.deleteUser(userId);
await api.users.delete(headscaleUserId);
await context.hsLive.refresh(usersResource, api);
return { message: "User deleted successfully" };
}
case "rename_user": {
const userId = formData.get("user_id")?.toString();
const headscaleUserId = formData.get("headscale_user_id")?.toString();
const newName = formData.get("new_name")?.toString();
if (!userId || !newName) {
if (!headscaleUserId || !newName) {
return data({ success: false }, 400);
}
const users = await api.getUsers(userId);
const user = users.find((user) => user.id === userId);
const users = await api.users.list({ id: headscaleUserId });
const user = users.find((user) => user.id === headscaleUserId);
if (!user) {
throw data(`No user found with id: ${userId}`, { status: 400 });
throw data(`No user found with id: ${headscaleUserId}`, { status: 400 });
}
if (user.provider === "oidc") {
@@ -74,34 +72,20 @@ export async function userAction({ request, context }: Route.ActionArgs) {
});
}
await api.renameUser(userId, newName);
await api.users.rename(headscaleUserId, newName);
await context.hsLive.refresh(usersResource, api);
return { message: "User renamed successfully" };
}
case "reassign_user": {
const userId = formData.get("user_id")?.toString();
const headplaneUserId = formData.get("headplane_user_id")?.toString();
const newRole = formData.get("new_role")?.toString();
if (!userId || !newRole) {
throw data("Missing `user_id` or `new_role` in the form data.", {
if (!headplaneUserId || !newRole) {
throw data("Missing `headplane_user_id` or `new_role` in the form data.", {
status: 400,
});
}
const users = await api.getUsers(userId);
const user = users.find((user) => user.id === userId);
if (!user) {
throw data("Specified user not found", {
status: 400,
});
}
const subject = getOidcSubject(user);
if (!subject) {
throw data("Specified user is not an OIDC user or has no subject.", { status: 400 });
}
const result = await context.auth.reassignSubject(subject, newRole as Role);
const result = await context.auth.reassignUser(headplaneUserId, newRole as Role);
if (!result) {
throw data("Failed to reassign user role.", { status: 500 });
}
@@ -113,23 +97,12 @@ export async function userAction({ request, context }: Route.ActionArgs) {
throw data("Only the owner can transfer ownership.", { status: 403 });
}
const userId = formData.get("user_id")?.toString();
if (!userId) {
throw data("Missing `user_id` in the form data.", { status: 400 });
const headplaneUserId = formData.get("headplane_user_id")?.toString();
if (!headplaneUserId) {
throw data("Missing `headplane_user_id` in the form data.", { status: 400 });
}
const users = await api.getUsers(userId);
const user = users.find((user) => user.id === userId);
if (!user) {
throw data("Specified user not found", { status: 400 });
}
const targetSubject = getOidcSubject(user);
if (!targetSubject) {
throw data("Target user is not an OIDC user or has no subject.", { status: 400 });
}
const result = await context.auth.transferOwnership(principal.user.subject, targetSubject);
const result = await context.auth.transferOwnership(principal.user.id, headplaneUserId);
if (!result) {
throw data("Failed to transfer ownership.", { status: 500 });
}
@@ -137,26 +110,15 @@ export async function userAction({ request, context }: Route.ActionArgs) {
return { message: "Ownership transferred successfully" };
}
case "link_user": {
const userId = formData.get("user_id")?.toString();
const headplaneUserId = formData.get("headplane_user_id")?.toString();
const headscaleUserId = formData.get("headscale_user_id")?.toString();
if (!userId || !headscaleUserId) {
throw data("Missing `user_id` or `headscale_user_id` in the form data.", {
if (!headplaneUserId || !headscaleUserId) {
throw data("Missing `headplane_user_id` or `headscale_user_id` in the form data.", {
status: 400,
});
}
const users = await api.getUsers(userId);
const user = users.find((user) => user.id === userId);
if (!user) {
throw data("Specified user not found", { status: 400 });
}
const subject = getOidcSubject(user);
if (!subject) {
throw data("Specified user is not an OIDC user or has no subject.", { status: 400 });
}
const linked = await context.auth.linkHeadscaleUserBySubject(subject, headscaleUserId);
const linked = await context.auth.linkHeadscaleUser(headplaneUserId, headscaleUserId);
if (!linked) {
throw data("That Headscale user is already linked to another account.", { status: 409 });
}
+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;
}
+8 -10
View File
@@ -1,14 +1,12 @@
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();
const healthy = await context.headscale.health();
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",
},
});
}
+51 -51
View File
@@ -1,59 +1,59 @@
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();
const healthy = await context.headscale.health();
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.headscale.version.raw : "unknown",
internal_versions: {
node: versions.node,
v8: versions.v8,
uv: versions.uv,
zlib: versions.zlib,
openssl: versions.openssl,
libc: versions.libc,
},
};
return new Response(JSON.stringify(body), {
status: 200,
headers: {
'Content-Type': 'application/json',
},
});
return new Response(JSON.stringify(body), {
status: 200,
headers: {
"Content-Type": "application/json",
},
});
}
+1 -3
View File
@@ -4,9 +4,7 @@ import log from "~/utils/log";
import type { Route } from "./+types/live";
export async function loader({ request, context }: Route.LoaderArgs) {
const principal = await context.auth.require(request);
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const api = context.hsApi.getRuntimeClient(apiKey);
const { api } = await context.apiForRequest(request);
// Ensure resources are loaded before streaming
await Promise.all([
+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 (`headscale`)
- 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.
+67
View File
@@ -0,0 +1,67 @@
// 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);
}
if ((config.server.tls_cert_path || config.server.tls_key_path) && !config.server.cookie_secure) {
log.warn(
"server",
"TLS is enabled but `server.cookie_secure` is false; forcing it to true (browsers reject Secure-less cookies over HTTPS)",
);
config.server.cookie_secure = true;
}
const ctx = await createAppContext(config);
ctx.startServices();
export { config };
/**
* Disposes the per-process context. Invoked by the production
* supervisor on SIGTERM/SIGINT and by the dev Vite plugin on HMR
* reload.
*/
export async function dispose(): Promise<void> {
await ctx.dispose();
}
// TODO: `getLoadContext` is the right place to handle reverse proxy
// translation — better than doing it in the OIDC client because it
// applies to all requests, not just OIDC ones.
export default createRequestListener({
build,
mode: import.meta.env.MODE,
getLoadContext: () => ctx,
});
+36
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",
@@ -25,6 +42,12 @@ const serverConfig = type({
cookie_secure: "boolean = true",
cookie_domain: "string.lower?",
cookie_max_age: "number.integer = 86400",
// TLS termination. When both `tls_cert_path` and `tls_key_path`
// are provided, Headplane serves HTTPS on `server.port`. When
// either is set, `cookie_secure` is forced to `true`.
tls_cert_path: "string?",
tls_key_path: "string?",
});
const partialServerConfig = type({
@@ -38,6 +61,9 @@ const partialServerConfig = type({
cookie_secure: "boolean?",
cookie_domain: "string.lower?",
cookie_max_age: "number.integer?",
tls_cert_path: "string?",
tls_key_path: "string?",
});
const headscaleConfig = type({
@@ -98,12 +124,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 +151,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 { Headscale } from "~/server/headscale/api";
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(headscale: Headscale): Promise<void> | void;
abstract get name(): string;
}
+3 -3
View File
@@ -4,7 +4,7 @@ import { setTimeout } from "node:timers/promises";
import { type } from "arktype";
import { Client } from "undici";
import type { RuntimeApiClient } from "~/server/headscale/api/endpoints";
import type { Headscale } from "~/server/headscale/api";
import log from "~/utils/log";
import { Integration } from "./abstract";
@@ -255,7 +255,7 @@ export default class DockerIntegration extends Integration<typeof configSchema.f
return this.client !== undefined && this.containerId !== undefined;
}
async onConfigChange(client: RuntimeApiClient) {
async onConfigChange(headscale: Headscale) {
if (!this.client) {
return;
}
@@ -290,7 +290,7 @@ export default class DockerIntegration extends Integration<typeof configSchema.f
while (attempts <= this.maxAttempts) {
try {
log.debug("config", "Checking Headscale status (attempt %d)", attempts);
const status = await client.isHealthy();
const status = await headscale.health();
if (status === false) {
throw new Error("Headscale is not running");
}
+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!);
}
}
+5 -5
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 { Headscale } from "~/server/headscale/api";
import log from "~/utils/log";
import { Integration } from "./abstract";
@@ -154,12 +154,12 @@ export default class KubernetesIntegration extends Integration<typeof configSche
}
}
async onConfigChange(client: RuntimeApiClient) {
async onConfigChange(headscale: Headscale) {
if (!this.pid) {
return;
}
await signalAndWaitHealthy(client, {
await signalAndWaitHealthy(headscale, {
pid: this.pid,
signal: "SIGHUP",
});
+4 -5
View File
@@ -3,8 +3,7 @@ import { join } from "node:path";
import { kill } from "node:process";
import { setTimeout } from "node:timers/promises";
import type { RuntimeApiClient } from "~/server/headscale/api/endpoints";
import type { Headscale } from "~/server/headscale/api";
import log from "~/utils/log";
/**
@@ -76,12 +75,12 @@ export interface SignalHeadscaleOptions {
/**
* Sends a signal to the headscale process and waits for it to become healthy.
* @param client The RuntimeApiClient to check health
* @param headscale The Headscale instance to health-check
* @param options Options for signaling and waiting
* @returns True if headscale became healthy, false otherwise
*/
export async function signalAndWaitHealthy(
client: RuntimeApiClient,
headscale: Headscale,
options: SignalHeadscaleOptions,
): Promise<boolean> {
const { pid, signal = "SIGHUP", maxAttempts = 10, retryDelayMs = 1000 } = options;
@@ -97,7 +96,7 @@ export async function signalAndWaitHealthy(
await setTimeout(retryDelayMs);
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const healthy = await client.isHealthy();
const healthy = await headscale.health();
if (healthy) {
log.info("config", "Headscale is healthy after restart");
return true;
+4 -4
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 { Headscale } from "~/server/headscale/api";
import log from "~/utils/log";
import { Integration } from "./abstract";
@@ -51,12 +51,12 @@ export default class ProcIntegration extends Integration<typeof configSchema.ful
}
}
async onConfigChange(client: RuntimeApiClient) {
async onConfigChange(headscale: Headscale) {
if (!this.pid) {
return;
}
await signalAndWaitHealthy(client, {
await signalAndWaitHealthy(headscale, {
pid: this.pid,
signal: "SIGHUP",
});
+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;
};
}
+178
View File
@@ -0,0 +1,178 @@
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 { disabled, enabled, type Feature } from "./feature";
import { createHeadscale, type HeadscaleClient } from "./headscale/api";
import { loadHeadscaleConfig } from "./headscale/config-loader";
import { createLiveStore, nodesResource, usersResource } from "./headscale/live-store";
import { type AgentManager, createAgentManager } from "./hp-agent";
import { createOidcService, type OidcService } from "./oidc/provider";
import { createAuthService, type Principal } from "./web/auth";
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 headscale = await createHeadscale({
url: config.headscale.url,
certPath: config.headscale.tls_cert_path,
});
// Resolve the Headscale API key: headscale.api_key takes precedence,
// falling back to the deprecated oidc.headscale_api_key for compatibility.
const headscaleApiKey = config.headscale.api_key ?? config.oidc?.headscale_api_key;
const agents = await buildAgents(
config,
headscale.capabilities.preAuthKeysHaveStableIds,
headscaleApiKey ? headscale.client(headscaleApiKey) : undefined,
db,
);
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 = buildOidc(config, headscaleApiKey);
const hsLive = createLiveStore([nodesResource, usersResource]);
const hs = await loadHeadscaleConfig(
config.headscale.config_path,
config.headscale.config_strict,
config.headscale.dns_records_path,
);
const integration = await loadIntegration(config.integration);
// Disposers run in reverse-registration order on shutdown.
const disposers: Array<() => Promise<void> | void> = [
() => auth.stop(),
() => hsLive.dispose(),
() => headscale.dispose(),
];
if (agents.state === "enabled") {
disposers.push(() => agents.value.dispose());
}
async function apiForRequest(
request: Request,
): Promise<{ principal: Principal; api: HeadscaleClient }> {
const principal = await auth.require(request);
const apiKey = auth.getHeadscaleApiKey(principal);
return { principal, api: headscale.client(apiKey) };
}
function startServices() {
auth.start();
}
async function dispose() {
for (const d of [...disposers].reverse()) {
try {
await d();
} catch (error) {
log.warn("server", "Error during shutdown: %s", String(error));
}
}
}
return {
config,
db,
headscale,
headscaleApiKey,
agents,
auth,
oidc,
hsLive,
hs,
integration,
apiForRequest,
startServices,
dispose,
};
}
function buildOidc(
config: HeadplaneConfig,
headscaleApiKey: string | undefined,
): Feature<OidcService> {
if (!config.oidc) {
return disabled("OIDC is not configured");
}
if (config.oidc.enabled === false) {
return disabled("OIDC is disabled in the configuration");
}
if (!headscaleApiKey) {
return disabled("OIDC requires headscale.api_key to be configured");
}
return enabled(
createOidcService({
issuer: config.oidc.issuer,
clientId: config.oidc.client_id,
clientSecret: config.oidc.client_secret,
baseUrl: config.server.base_url ?? "",
authorizationEndpoint: config.oidc.authorization_endpoint,
tokenEndpoint: config.oidc.token_endpoint,
userinfoEndpoint: config.oidc.userinfo_endpoint,
endSessionEndpoint: config.oidc.end_session_endpoint,
tokenEndpointAuthMethod:
config.oidc.token_endpoint_auth_method === "client_secret_jwt"
? undefined
: config.oidc.token_endpoint_auth_method,
usePkce: config.oidc.use_pkce,
scope: config.oidc.scope,
subjectClaims: config.oidc.subject_claims,
allowWeakRsaKeys: config.oidc.allow_weak_rsa_keys,
extraParams: config.oidc.extra_params,
profilePictureSource: config.oidc.profile_picture_source,
postLogoutRedirectUri: config.oidc.post_logout_redirect_uri,
}),
);
}
async function buildAgents(
config: HeadplaneConfig,
supportsTagOnlyKeys: boolean,
apiClient: HeadscaleClient | undefined,
db: Awaited<ReturnType<typeof createDbClient>>,
): Promise<Feature<AgentManager>> {
const agentConfig = config.integration?.agent;
if (!agentConfig?.enabled) {
return disabled("Agent is not enabled in the configuration");
}
if (!apiClient) {
return disabled("Agent requires headscale.api_key to be configured");
}
if (!supportsTagOnlyKeys) {
return disabled("Agent requires Headscale 0.28 or newer");
}
const manager = await createAgentManager(
agentConfig,
config.headscale.url,
apiClient,
supportsTagOnlyKeys,
db,
);
if (!manager) {
return disabled("Agent failed to initialize (see logs)");
}
return enabled(manager);
}
-53
View File
@@ -1,53 +0,0 @@
import { eq, isNotNull } from "drizzle-orm";
import { nodesResource } from "~/server/headscale/live-store";
import log from "~/utils/log";
import type { Route } from "../../layout/+types/app";
import { ephemeralNodes } from "./schema";
export async function pruneEphemeralNodes({ context, request }: Route.LoaderArgs) {
const principal = await context.auth.require(request);
const ephemerals = await context.db
.select()
.from(ephemeralNodes)
.where(isNotNull(ephemeralNodes.node_key));
if (ephemerals.length === 0) {
log.debug("api", "No ephemeral nodes to prune");
return;
}
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const api = context.hsApi.getRuntimeClient(apiKey);
const nodes = await api.getNodes();
const toPrune = nodes.filter((node) => {
if (node.online) {
return false;
}
return ephemerals.some((ephemeral) => node.nodeKey === ephemeral.node_key);
});
if (toPrune.length === 0) {
log.debug("api", "No SSH nodes to prune");
return;
}
// Delete from the Headscale nodes list and then from the database
const promises = toPrune.map((node) => {
return async () => {
log.debug("api", `Pruning node ${node.name}`);
await api.deleteNode(node.id);
await context.db.delete(ephemeralNodes).where(eq(ephemeralNodes.node_key, node.nodeKey));
log.debug("api", `Node ${node.name} pruned successfully`);
};
});
await Promise.all(promises.map((p) => p()));
if (toPrune.length > 0) {
await context.hsLive.refresh(nodesResource, api);
}
}
+1 -8
View File
@@ -2,14 +2,6 @@ import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { HostInfo } from "~/types";
export const ephemeralNodes = sqliteTable("ephemeral_nodes", {
auth_key: text("auth_key").primaryKey(),
node_key: text("node_key"),
});
export type EphemeralNode = typeof ephemeralNodes.$inferSelect;
export type EphemeralNodeInsert = typeof ephemeralNodes.$inferInsert;
export const hostInfo = sqliteTable("host_info", {
host_id: text("host_id").primaryKey(),
payload: text("payload", { mode: "json" }).$type<HostInfo>(),
@@ -44,6 +36,7 @@ export const authSessions = sqliteTable("auth_sessions", {
user_id: text("user_id"),
api_key_hash: text("api_key_hash"),
api_key_display: text("api_key_display"),
oidc_id_token: text("oidc_id_token"),
expires_at: integer("expires_at", { mode: "timestamp" }).notNull(),
created_at: integer("created_at", { mode: "timestamp" }).$default(() => new Date()),
});
+20
View File
@@ -0,0 +1,20 @@
// MARK: Feature<T>
//
// A two-state tagged union used in place of `T | undefined` for
// optional features on the AppContext. Carries a human-readable
// reason when the feature is disabled so loaders can surface it (or
// choose to ignore it).
//
// This is deliberately *not* a Result/Either. It does not represent
// async readiness or retryable failure — it represents "is this
// feature wired up at all." Services that have their own runtime
// status (e.g. OIDC discovery) keep that on the service itself.
export type Feature<T> = { state: "enabled"; value: T } | { state: "disabled"; reason: string };
export const enabled = <T>(value: T): Feature<T> => ({ state: "enabled", value });
export const disabled = (reason: string): Feature<never> => ({
state: "disabled",
reason,
});
+47
View File
@@ -0,0 +1,47 @@
// MARK: Headscale Capabilities
//
// Behavioural facts about the connected Headscale server, derived
// once from `ServerVersion` at boot. Capabilities are named for *what
// changed* — not for the version number it changed in — so a reader
// who has never seen Headscale can tell what each flag controls
// without consulting a release note.
//
// Add a capability here when you find yourself reaching for a raw
// version comparison in endpoint or route code. Adding a capability
// is also the right answer when a new Headscale release changes wire
// format or removes an endpoint.
import { gte, type ServerVersion } from "./server-version";
export interface Capabilities {
/**
* Pre-auth keys have stable IDs. `GET /api/v1/preauthkey` (no
* user filter) returns every key in the system, and
* `POST /api/v1/preauthkey/expire` takes `{ id }` instead of
* `{ user, key }`. Tag-only pre-auth keys (no owning user) are
* supported. Introduced in 0.28.0.
*/
readonly preAuthKeysHaveStableIds: boolean;
/**
* Node tags are a flat `tags: string[]` field on the wire.
* Pre-0.28 returned `forcedTags` / `validTags` / `invalidTags`
* that the client had to union itself. Introduced in 0.28.0.
*/
readonly nodeTagsAreFlat: boolean;
/**
* A node's owning user is immutable after creation;
* `POST /api/v1/node/{id}/user` no longer reassigns. Effective in
* 0.28.0+.
*/
readonly nodeOwnerIsImmutable: boolean;
}
export function capabilitiesFor(version: ServerVersion): Capabilities {
return {
preAuthKeysHaveStableIds: gte(version, "0.28.0"),
nodeTagsAreFlat: gte(version, "0.28.0"),
nodeOwnerIsImmutable: gte(version, "0.28.0"),
};
}
@@ -1,23 +0,0 @@
import type { Key } from '~/types';
import { defineApiEndpoints } from '../factory';
export interface ApiKeyEndpoints {
/**
* Retrieves all API keys from the Headscale instance.
*
* @returns An array of `Key` objects representing the API keys.
*/
getApiKeys(): Promise<Key[]>;
}
export default defineApiEndpoints<ApiKeyEndpoints>((client, apiKey) => ({
getApiKeys: async () => {
const { apiKeys } = await client.apiFetch<{ apiKeys: Key[] }>(
'GET',
'v1/apikey',
apiKey,
);
return apiKeys;
},
}));
@@ -1,79 +0,0 @@
import {
composeEndpoints,
defineApiEndpoints,
type ExtractApiEndpoints,
type UnionToIntersection,
} from '../factory';
import type { HeadscaleApiInterface } from '../index';
import apiKeyEndpoints from './api-keys';
import nodeEndpoints from './nodes';
import policyEndpoints from './policy';
import preAuthKeyEndpoints from './pre-auth-keys';
import userEndpoints from './users';
interface HealthcheckEndpoint {
/**
* Checks if the Headscale instance is healthy.
*
* @returns A boolean indicating if the instance is healthy.
*/
isHealthy(): Promise<boolean>;
}
const healthcheckEndpoint = defineApiEndpoints<HealthcheckEndpoint>(
(client, apiKey) => ({
isHealthy: async () => {
try {
const res = await client.rawFetch('/health', {
method: 'GET',
headers: {
// This doesn't really matter
Authorization: `Bearer ${apiKey}`,
},
});
return res.statusCode === 200;
} catch {
return false;
}
},
}),
);
/**
* A constant list of all endpoint groups.
* Add new endpoint groups here.
*/
export const endpointSets = [
apiKeyEndpoints,
healthcheckEndpoint,
nodeEndpoints,
policyEndpoints,
preAuthKeyEndpoints,
userEndpoints,
] as const;
/**
* All of the available API methods when interacting with Headscale's API.
* We have wrapped each operation with nice methods and parameters to make it
* easier to do integration testing by spinning up an actual Headscale instance
* and calling these methods against it.
*
* We also have the benefit of supporting multiple Headscale versions by
* passing in different internal implementations based on the OpenAPI spec.
*/
export type RuntimeApiClient = UnionToIntersection<
ExtractApiEndpoints<(typeof endpointSets)[number]>
>;
/**
* Composes all endpoint groups into a single runtime API client.
*
* @param client - The client helpers for making API requests.
* @param apiKey - The API key for authentication.
* @returns A fully composed runtime API client.
*/
export default (
client: HeadscaleApiInterface['clientHelpers'],
apiKey: string,
) => composeEndpoints(endpointSets, client, apiKey);
-168
View File
@@ -1,168 +0,0 @@
import type { Machine } from "~/types";
import type { HeadscaleApiInterface } from "..";
import { defineApiEndpoints } from "../factory";
interface RawMachine extends Omit<Machine, "tags"> {
tags?: string[];
forcedTags?: string[];
validTags?: string[];
invalidTags?: string[];
}
/**
* Normalizes the tags of a RawMachine based on the Headscale version.
*
* @param client The Headscale API client helper.
* @param node The RawMachine object to normalize.
* @returns A Machine object with normalized tags.
*/
function normalizeTags(client: HeadscaleApiInterface["clientHelpers"], node: RawMachine): Machine {
if (client.isAtleast("0.28.0")) {
return { ...node, tags: node.tags ?? [] } as Machine;
}
const tags = Array.from(new Set([...(node.forcedTags ?? []), ...(node.validTags ?? [])]));
return { ...node, tags } as Machine;
}
export interface NodeEndpoints {
/**
* Retrieves all nodes (machines) from the Headscale instance.
*
* @returns An array of `Machine` objects representing the nodes.
*/
getNodes(): Promise<Machine[]>;
/**
* Retrieves a specific node (machine) by its ID.
*
* @param id The ID of the node to retrieve.
* @returns A `Machine` object representing the node.
*/
getNode(id: string): Promise<Machine>;
/**
* Deletes a specific node (machine) by its ID.
*
* @param id The ID of the node to delete.
*/
deleteNode(id: string): Promise<void>;
/**
* Registers a new node (machine) with the given user and key.
*
* @param user The user to associate with the node.
* @param key The registration key for the node.
* @returns A `Machine` object representing the newly registered node.
*/
registerNode(user: string, key: string): Promise<Machine>;
/**
* Approves routes for a specific node (machine) by its ID.
*
* @param id The ID of the node.
* @param routes An array of routes to approve for the node.
*/
approveNodeRoutes(id: string, routes: string[]): Promise<void>;
/**
* Expires a specific node (machine) by its ID.
*
* @param id The ID of the node to expire.
*/
expireNode(id: string): Promise<void>;
/**
* Renames a specific node (machine) by its ID.
*
* @param id The ID of the node to rename.
* @param newName The new name for the node.
*/
renameNode(id: string, newName: string): Promise<void>;
/**
* Sets tags for a specific node (machine) by its ID.
*
* @param id The ID of the node.
* @param tags An array of tags to set for the node.
*/
setNodeTags(id: string, tags: string[]): Promise<void>;
/**
* Sets the user for a specific node (machine) by its ID.
*
* @param id The ID of the node.
* @param user The user to set for the node.
*/
setNodeUser(id: string, user: string): Promise<void>;
}
export default defineApiEndpoints<NodeEndpoints>((client, apiKey) => ({
getNodes: async () => {
const { nodes } = await client.apiFetch<{ nodes: RawMachine[] }>("GET", "v1/node", apiKey);
return nodes.map((node) => normalizeTags(client, node));
},
getNode: async (nodeId) => {
const { node } = await client.apiFetch<{ node: RawMachine }>(
"GET",
`v1/node/${nodeId}`,
apiKey,
);
return normalizeTags(client, node);
},
deleteNode: async (nodeId) => {
await client.apiFetch<void>("DELETE", `v1/node/${nodeId}`, apiKey);
},
registerNode: async (user, key) => {
const qp = new URLSearchParams();
qp.append("user", user);
qp.append("key", key);
const { node } = await client.apiFetch<{ node: RawMachine }>(
"POST",
`v1/node/register?${qp.toString()}`,
apiKey,
{
user,
key,
},
);
return normalizeTags(client, node);
},
approveNodeRoutes: async (nodeId, routes) => {
await client.apiFetch<void>("POST", `v1/node/${nodeId}/approve_routes`, apiKey, { routes });
},
expireNode: async (nodeId) => {
await client.apiFetch<void>("POST", `v1/node/${nodeId}/expire`, apiKey);
},
renameNode: async (nodeId, newName) => {
await client.apiFetch<void>("POST", `v1/node/${nodeId}/rename/${newName}`, apiKey);
},
setNodeTags: async (nodeId, tags) => {
await client.apiFetch<void>("POST", `v1/node/${nodeId}/tags`, apiKey, {
tags,
});
},
setNodeUser: async (nodeId, user) => {
// Headscale 0.28.0 got rid of node reassignment to users
if (client.isAtleast("0.28.0")) {
return;
}
await client.apiFetch<void>("POST", `v1/node/${nodeId}/user`, apiKey, {
user,
});
},
}));
@@ -1,41 +0,0 @@
import { defineApiEndpoints } from '../factory';
export interface PolicyEndpoints {
/**
* Retrieves the current ACL policy from the Headscale instance.
*
* @returns The ACL policy as a string and the date it was last updated.
*/
getPolicy(): Promise<{ policy: string; updatedAt: Date | null }>;
/**
* Sets the ACL policy for the Headscale instance.
*
* @param policy The ACL policy as a string.
* @returns The updated ACL policy as a string and the date it was last updated.
*/
setPolicy(policy: string): Promise<{ policy: string; updatedAt: Date }>;
}
export default defineApiEndpoints<PolicyEndpoints>((client, apiKey) => ({
getPolicy: async () => {
const { policy, updatedAt } = await client.apiFetch<{
policy: string;
updatedAt: string;
}>('GET', 'v1/policy', apiKey);
return {
policy,
updatedAt: updatedAt !== null ? new Date(updatedAt) : null,
};
},
setPolicy: async (policy) => {
const { policy: newPolicy, updatedAt } = await client.apiFetch<{
policy: string;
updatedAt: string;
}>('PUT', 'v1/policy', apiKey, { policy });
return { policy: newPolicy, updatedAt: new Date(updatedAt) };
},
}));
@@ -1,85 +0,0 @@
import type { PreAuthKey } from "~/types";
import { defineApiEndpoints } from "../factory";
export interface PreAuthKeyEndpoints {
/**
* List all pre-auth keys. Requires Headscale 0.28+.
*/
getAllPreAuthKeys(): Promise<PreAuthKey[]>;
/**
* Retrieves all pre-authentication keys for a specific user.
*
* @param user The user to retrieve pre-authentication keys for.
* @returns An array of `PreAuthKey` objects representing the pre-authentication keys.
*/
getPreAuthKeys(user: string): Promise<PreAuthKey[]>;
/**
* Creates a new pre-authentication key.
* User can be null for tag-only keys (requires Headscale 0.28+).
*/
createPreAuthKey(
user: string | null,
ephemeral: boolean,
reusable: boolean,
expiration: Date | null,
aclTags: string[] | null,
): Promise<PreAuthKey>;
/**
* Expires a specific pre-authentication key for a user.
*
* @param user The user associated with the pre-authentication key.
* @param key The pre-authentication key to expire.
*/
expirePreAuthKey(user: string, key: string): Promise<void>;
}
export default defineApiEndpoints<PreAuthKeyEndpoints>((client, apiKey) => ({
getAllPreAuthKeys: async () => {
const { preAuthKeys } = await client.apiFetch<{
preAuthKeys: PreAuthKey[];
}>("GET", "v1/preauthkey", apiKey, {});
return preAuthKeys;
},
getPreAuthKeys: async (user) => {
const { preAuthKeys } = await client.apiFetch<{
preAuthKeys: PreAuthKey[];
}>("GET", "v1/preauthkey", apiKey, { user });
return preAuthKeys;
},
createPreAuthKey: async (user, ephemeral, reusable, expiration, aclTags) => {
const body: Record<string, unknown> = {
ephemeral,
reusable,
expiration: expiration ? expiration.toISOString() : null,
};
if (user) {
body.user = user;
}
if (aclTags && aclTags.length > 0) {
body.aclTags = aclTags;
}
const { preAuthKey } = await client.apiFetch<{
preAuthKey: PreAuthKey;
}>("POST", "v1/preauthkey", apiKey, body);
return preAuthKey;
},
expirePreAuthKey: async (user, key) => {
await client.apiFetch<void>("POST", "v1/preauthkey/expire", apiKey, {
user,
key,
});
},
}));
@@ -1,88 +0,0 @@
import type { User } from '~/types';
import { defineApiEndpoints } from '../factory';
export interface UserEndpoints {
/**
* Retrieves users from the Headscale instance, optionally filtering by ID, name, or email.
*
* @param id Optional ID of the user to retrieve.
* @param name Optional name of the user to retrieve.
* @param email Optional email of the user to retrieve.
* @returns An array of `User` objects representing the users.
*/
getUsers(id?: string, name?: string, email?: string): Promise<User[]>;
/**
* Creates a new user in the Headscale instance.
*
* @param username The username of the new user.
* @param email Optional email of the new user.
* @param displayName Optional display name of the new user.
* @param pictureUrl Optional picture URL of the new user.
* @returns A `User` object representing the newly created user.
*/
createUser(
username: string,
email?: string,
displayName?: string,
pictureUrl?: string,
): Promise<User>;
/**
* Deletes a specific user by its ID.
*
* @param id The ID of the user to delete.
*/
deleteUser(id: string): Promise<void>;
/**
* Renames a specific user by its ID.
*
* @param id The ID of the user to rename.
* @param newName The new name for the user.
*/
renameUser(id: string, newName: string): Promise<void>;
}
export default defineApiEndpoints<UserEndpoints>((client, apiKey) => ({
getUsers: async (id, name, email) => {
const moreThanOneFilter =
[id, name, email].filter((v) => v !== undefined).length > 1;
if (moreThanOneFilter) {
throw new Error('Only one of id, name, or email filters can be provided');
}
const { users } = await client.apiFetch<{ users: User[] }>(
'GET',
'v1/user',
apiKey,
{ id, name, email },
);
return users;
},
createUser: async (username, email, displayName, pictureUrl) => {
const { user } = await client.apiFetch<{ user: User }>(
'POST',
'v1/user',
apiKey,
{ name: username, email, displayName, pictureUrl },
);
return user;
},
deleteUser: async (id) => {
await client.apiFetch<void>('DELETE', `v1/user/${id}`, apiKey);
},
renameUser: async (oldId, newName) => {
await client.apiFetch<void>(
'POST',
`v1/user/${oldId}/rename/${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,
};
}
-45
View File
@@ -1,45 +0,0 @@
import type { HeadscaleApiInterface } from '../api';
/**
* Creates a strongly-typed group factory for a given endpoint interface.
*
* Example:
* export const apiKeyGroup = defineGroup<ApiKeyEndpoints>({...})
*/
export interface EndpointFactory<T extends object> {
__type?: T;
(client: HeadscaleApiInterface['clientHelpers'], apiKey: string): T;
}
export function defineApiEndpoints<T extends object>(
factories: (
client: HeadscaleApiInterface['clientHelpers'],
apiKey: string,
) => T,
): EndpointFactory<T> {
return factories;
}
export type ExtractApiEndpoints<F> = F extends EndpointFactory<infer T>
? T
: never;
export type UnionToIntersection<U> = (
U extends any
? (k: U) => void
: never
) extends (k: infer I) => void
? I
: never;
/**
* Compose multiple endpoint sets into a single typed runtime client
*/
export function composeEndpoints<T extends readonly EndpointFactory<any>[]>(
factories: T,
clientHelpers: any,
apiKey: string,
): UnionToIntersection<ExtractApiEndpoints<T[number]>> {
const instances = factories.map((f) => f(clientHelpers, apiKey));
return Object.assign({}, ...instances) as any;
}
-52
View File
@@ -1,52 +0,0 @@
import { createHash } from 'node:crypto';
import { dereference } from '@readme/openapi-parser';
import { OpenAPIV2 } from 'openapi-types';
/**
* A map of operation IDs to their hashes.
*/
export interface DocumentHash {
[operationId: string]: string;
}
/**
* Given an OpenAPI v2 document, generate a map of operations with hashes.
* This gives us deterministic identifers to determine the version of Headscale
* that is being used at runtime.
*
* @param doc The OpenAPI v2 document to hash.
* @returns A map of operation IDs to their hashes.
*/
export async function hashOpenApiDocument(
doc: OpenAPIV2.Document,
): Promise<DocumentHash> {
const spec = await dereference(doc);
const hashes: DocumentHash = {};
const seen = new Set<string>();
for (const [path, item] of Object.entries(spec.paths)) {
for (const [method, operation] of Object.entries(item)) {
if (typeof operation !== 'object') {
continue;
}
const { parameters, responses } = operation as OpenAPIV2.OperationObject;
const raw = JSON.stringify(
{
path,
method: method.toUpperCase(),
parameters,
responses,
},
Object.keys({ path, method, parameters, responses }).sort(),
);
const hash = createHash('md5').update(raw).digest('hex').slice(0, 16);
const final = seen.has(hash) ? `${hash}_${seen.size}` : hash;
seen.add(final);
hashes[`${method.toUpperCase()} ${path}`] = final;
}
}
return hashes;
}
+141 -306
View File
@@ -1,328 +1,163 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { dereference } from "@readme/openapi-parser";
import type { OpenAPIV2 } from "openapi-types";
import { data } from "react-router";
import { Agent, type Dispatcher, request } from "undici";
// MARK: Headscale API
//
// The public entry point for talking to a Headscale server. At boot
// we try `GET /version` (unauthenticated, present since Headscale
// 0.27.0 — the minimum version Headplane supports) to derive a
// typed `Capabilities` object. Boot outcomes:
//
// - success: parse the response, derive capabilities, done.
// - 404: Headscale is reachable but predates 0.27.0 and is no
// longer supported. Log an error and keep retrying so an
// upgrade is picked up without a Headplane restart.
// - any other failure (network, 5xx, parse): Headplane still
// boots with `version = unknown` (capabilities-permissive) and
// a background retry. This handles docker-compose start-order
// races without making the whole process unhappy.
//
// Capabilities are always derived from `version`; once detection
// finishes there's no further state to track.
import log from "~/utils/log";
import endpointSets, { RuntimeApiClient } from "./endpoints";
import { undiciToFriendlyError } from "./error";
import { HeadscaleAPIError, isApiError } from "./error-client";
import { detectApiVersion, isAtLeast, type Version } from "./version";
import { type Capabilities, capabilitiesFor } from "./capabilities";
import { isDataWithApiError } from "./error-client";
import { type ApiKeyApi, makeApiKeyApi } from "./resources/api-keys";
import { makeNodeApi, type NodeApi } from "./resources/nodes";
import { makePolicyApi, type PolicyApi } from "./resources/policy";
import { makePreAuthKeyApi, type PreAuthKeyApi } from "./resources/pre-auth-keys";
import { makeUserApi, type UserApi } from "./resources/users";
import { formatServerVersion, parseServerVersion, type ServerVersion } from "./server-version";
import { createTransport } from "./transport";
/**
* A low-level composed interface for interacting with the Headscale API.
* This interface provides direct access to the underlying Undici agent
* and methods for making API requests.
*
* It is also responsible for handling OpenAPI spec polling and hashing to
* determine the implementations of API methods when requested for use.
*/
export interface HeadscaleApiInterface {
/**
* The underlying Undici agent used for making requests.
*/
undiciAgent: Agent;
const MIN_SUPPORTED_VERSION = "0.27.0";
/**
* The base URL of the Headscale API.
*/
baseUrl: string;
/**
* The OpenAPI hashes retrieved from the Headscale instance at runtime.
* This is used to determine which implementations of API methods to use.
*/
openapiHashes: Record<string, string> | null;
/**
* The detected API version of the connected Headscale instance.
*/
apiVersion: Version;
/**
* Retrieves a runtime API client for the given API key.
*
* @param apiKey The API key to use for authentication.
* @returns A `RuntimeApiClient` instance for interacting with the API.
*/
getRuntimeClient(apiKey: string): RuntimeApiClient;
/**
* A set of helper methods made available to API method implementations.
* The idea is to make interacting with the API easier by providing
* common functionality that can be reused across multiple methods.
*/
clientHelpers: {
/**
* Checks if the connected Headscale instance's API version
* is at least the specified version.
*
* @param version The version to check against.
* @returns `true` if the API version is at least the specified version, `false` otherwise.
*/
isAtleast(version: Version): boolean;
/**
* Makes a raw fetch request to the Headscale API via the Undici agent.
* This method is used internally by API method implementations
* to make requests to the Headscale API.
*
* @param path The API path to request.
* @param options Optional request options.
* @returns A promise that resolves to the response data.
*/
rawFetch(
path: string,
options?: Partial<Dispatcher.RequestOptions>,
): Promise<Dispatcher.ResponseData>;
/**
* Makes a typed API fetch request to the Headscale API.
* This method is used internally by API method implementations
* to make requests to the Headscale API and parse the response.
*
* @param method The HTTP method to use.
* @param apiPath The API path to request.
* @param apiKey The API key to use for authentication.
* @param bodyOrQuery Optional body or query parameters.
* @returns A promise that resolves to the typed response data.
*/
apiFetch<T>(
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH",
apiPath: `v1/${string}`,
apiKey: string,
bodyOrQuery?: Record<string, unknown>,
): Promise<T>;
};
export interface Headscale {
readonly version: ServerVersion;
readonly capabilities: Capabilities;
/** True if the Headscale server's `/health` endpoint returns 200. */
health(): Promise<boolean>;
/** Build an API client bound to a specific Headscale API key. */
client(apiKey: string): HeadscaleClient;
/** Stop background work and close the underlying HTTP agent. */
dispose(): Promise<void>;
}
/**
* Creates a new Headscale API client interface.
*
* @param baseUrl The base URL of the Headscale API.
* @param certPath Optional path to a custom TLS certificate for secure connections.
* @returns A promise that resolves to a `HeadscaleApiClient` instance.
*/
export async function createHeadscaleInterface(
baseUrl: string,
certPath?: string,
): Promise<HeadscaleApiInterface> {
const undiciAgent = await createUndiciAgent(certPath);
let openapiHashes: Record<string, string> | null = null;
let apiVersion: Version;
const rawFetch = async (
url: string,
options?: Partial<Dispatcher.RequestOptions>,
): Promise<Dispatcher.ResponseData> => {
const method = options?.method ?? "GET";
log.debug("api", "%s %s", method, url);
try {
const res = await request(new URL(url, baseUrl), {
dispatcher: undiciAgent,
headers: {
...options?.headers,
Accept: "application/json",
"User-Agent": `Headplane/${__VERSION__}`,
},
body: options?.body,
method,
});
return res;
} catch (error) {
const errorBody = undiciToFriendlyError(error, `${method} ${url}`);
throw data(errorBody, {
status: 502,
statusText: "Bad Gateway",
});
}
};
const apiFetch = async <T>(
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH",
apiPath: `v1/${string}`,
apiKey: string,
bodyOrQuery?: Record<string, unknown>,
): Promise<T> => {
let url = `/api/${apiPath}`;
const options: Partial<Dispatcher.RequestOptions> = {
method: method,
headers: {
Authorization: `Bearer ${apiKey}`,
},
};
if (bodyOrQuery) {
if (method === "GET" || method === "DELETE") {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(bodyOrQuery)) {
if (value !== undefined) {
params.append(key, String(value));
}
}
if ([...params.keys()].length > 0) {
url += `?${params.toString()}`;
}
} else {
options.body = JSON.stringify(bodyOrQuery);
options.headers = {
...options.headers,
"Content-Type": "application/json",
};
}
}
const res = await rawFetch(url, options);
if (res.statusCode >= 400) {
log.debug("api", "%s %s failed with status %d", method, apiPath, res.statusCode);
const rawData = await res.body.text();
const jsonData = (() => {
try {
return JSON.parse(rawData) as Record<string, unknown>;
} catch {
return null;
}
})();
throw data(
{
requestUrl: `${method} ${apiPath}`,
statusCode: res.statusCode,
rawData,
data: jsonData,
} satisfies HeadscaleAPIError,
{ status: 502, statusText: "Bad Gateway" },
);
}
return res.body.json() as Promise<T>;
};
export interface HeadscaleClient {
nodes: NodeApi;
users: UserApi;
policy: PolicyApi;
preAuthKeys: PreAuthKeyApi;
apiKeys: ApiKeyApi;
}
export interface CreateHeadscaleOptions {
url: string;
certPath?: string;
/**
* Polls the OpenAPI spec endpoint and generates operation hashes.
* This is used to determine which implementations of API methods to use.
*
* @returns A promise that resolves to the OpenAPI operation hashes.
* How often to retry `/version` while Headscale is unreachable.
* Defaults to 30 seconds. Exposed for tests.
*/
async function fetchAndHashOpenapi(): Promise<Record<string, string> | null> {
try {
const res = await rawFetch("/swagger/v1/openapiv2.json");
if (res.statusCode !== 200) {
log.error("api", "Failed to fetch OpenAPI spec: %d", res.statusCode);
return null;
}
retryIntervalMs?: number;
}
const body = await res.body.json();
const spec = await dereference(body as OpenAPIV2.Document);
const hashes = generateSpecHashes(spec);
log.debug("api", "OpenAPI hashes updated (%d endpoints)", Object.keys(hashes).length);
return hashes;
} catch (error) {
if (isApiError(error)) {
log.debug("api", "Failed to fetch OpenAPI spec: %d", error.statusCode);
}
const DEFAULT_RETRY_INTERVAL_MS = 30_000;
return null;
export async function createHeadscale(opts: CreateHeadscaleOptions): Promise<Headscale> {
const transport = await createTransport({ url: opts.url, certPath: opts.certPath });
const retryIntervalMs = opts.retryIntervalMs ?? DEFAULT_RETRY_INTERVAL_MS;
let version: ServerVersion = parseServerVersion("unreachable");
let capabilities: Capabilities = capabilitiesFor(version);
let detected = false;
let retryTimer: ReturnType<typeof setTimeout> | undefined;
let disposed = false;
function settle(parsed: ServerVersion) {
version = parsed;
capabilities = capabilitiesFor(parsed);
detected = true;
if (parsed.unknown) {
log.warn(
"api",
"Could not parse Headscale version %s, assuming newest known capabilities",
parsed.raw,
);
} else {
log.info("api", "Connected to Headscale %s", formatServerVersion(parsed));
}
}
const isAtleast = (version: Version): boolean => {
return isAtLeast(apiVersion, version);
};
openapiHashes = await fetchAndHashOpenapi();
apiVersion = detectApiVersion(openapiHashes);
setInterval(async () => {
const hashes = await fetchAndHashOpenapi();
if (hashes) {
openapiHashes = hashes;
apiVersion = detectApiVersion(openapiHashes);
async function detectOnce(): Promise<boolean> {
try {
const { version: raw } = await transport.getPublic<{ version: string }>("/version");
settle(parseServerVersion(raw));
return true;
} catch (error) {
// 404 means Headscale is reachable but predates 0.27.0 (where
// /version was introduced). That server is below the supported
// floor, so we don't settle — leave capabilities permissive and
// keep retrying in case the operator upgrades in place.
if (isDataWithApiError(error) && error.data.statusCode === 404) {
log.error(
"api",
"Headscale /version returned 404; Headplane requires Headscale %s or newer",
MIN_SUPPORTED_VERSION,
);
return false;
}
log.debug("api", "Headscale /version probe failed: %s", String(error));
return false;
}
}, 60_000); // every 60 seconds
}
function scheduleRetry() {
if (disposed || detected) return;
retryTimer = setTimeout(async () => {
retryTimer = undefined;
if (disposed) return;
if (await detectOnce()) return;
scheduleRetry();
}, retryIntervalMs);
// Don't keep the event loop alive on this timer alone — Headplane
// should still shut down cleanly while we're waiting to retry.
retryTimer.unref?.();
}
if (!(await detectOnce())) {
log.warn(
"api",
"Headscale unreachable at boot; defaulting to newest-known capabilities and retrying every %dms",
retryIntervalMs,
);
scheduleRetry();
}
return {
undiciAgent,
baseUrl,
openapiHashes,
apiVersion,
getRuntimeClient: (apiKey: string) => {
return endpointSets(
{
rawFetch,
apiFetch,
isAtleast,
},
apiKey,
);
// Getters so callers always observe the latest detected values
// without having to know about the retry loop.
get version() {
return version;
},
clientHelpers: {
rawFetch,
apiFetch,
isAtleast,
get capabilities() {
return capabilities;
},
health: () => transport.health(),
client(apiKey) {
return {
nodes: makeNodeApi(transport, capabilities, apiKey),
users: makeUserApi(transport, capabilities, apiKey),
policy: makePolicyApi(transport, capabilities, apiKey),
preAuthKeys: makePreAuthKeyApi(transport, capabilities, apiKey),
apiKeys: makeApiKeyApi(transport, capabilities, apiKey),
};
},
async dispose() {
disposed = true;
if (retryTimer) {
clearTimeout(retryTimer);
retryTimer = undefined;
}
await transport.dispose();
},
};
}
/**
* Creates a new Undici agent for making HTTP requests.
*
* @param certPath Optional path to a custom TLS certificate for secure connections.
* @returns A promise that resolves to an `Agent` instance.
*/
async function createUndiciAgent(certPath?: string): Promise<Agent> {
if (!certPath) {
return new Agent();
}
try {
log.debug("config", "Loading certificate from %s", certPath);
const data = await readFile(certPath, "utf8");
log.info("config", "Using certificate from %s", certPath);
return new Agent({ connect: { ca: data.trim() } });
} catch (error) {
log.error("config", "Failed to load Headscale TLS cert: %s", error);
log.debug("config", "Error Details: %o", error);
return new Agent();
}
}
function generateSpecHashes(spec: OpenAPIV2.Document) {
const hashes: Record<string, string> = {};
const seen = new Set<string>();
for (const [path, item] of Object.entries(spec.paths)) {
for (const [method, operation] of Object.entries(item)) {
if (typeof operation !== "object") {
continue;
}
const { parameters, responses } = operation as OpenAPIV2.OperationObject;
const raw = JSON.stringify(
{
path,
method: method.toUpperCase(),
parameters,
responses,
},
Object.keys({ path, method, parameters, responses }).sort(),
);
const hash = createHash("md5").update(raw).digest("hex").slice(0, 16);
const final = seen.has(hash) ? `${hash}_${seen.size}` : hash;
seen.add(final);
hashes[`${method.toUpperCase()} ${path}`] = final;
}
}
return hashes;
}

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