Compare commits

...

88 Commits

Author SHA1 Message Date
Aarnav Tale b3c0c1c691 fix(agent): always spawn agent with pre-auth key and auto-approve
- Generate a fresh pre-auth key for every agent startup
- Preserve existing tailscale state to avoid creating a new host
- Auto-approve pending auth requests via /api/v1/auth/approve
- Show approval link in settings UI as fallback

Closes HP-558
2026-07-13 11:00:43 -04:00
Sandro 3d9b9b0f7e Fix typo url in code to docs (#586) 2026-07-12 18:20:07 -04:00
Sandro f52c4161cc Fix wasm ssh in nix build (#588) 2026-07-12 18:18:48 -04:00
Tommy Nevtelen fb73181cba fix: treat Go pseudo-versions as unknown server versions (#590)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 18:18:21 -04:00
Aarnav Tale 17e7b35478 chore: v0.7.0 2026-07-04 11:10:57 -04:00
Aarnav Tale 51a4613295 chore: fix unit tests 2026-07-04 00:55:42 -04:00
Aarnav Tale 1345b7ca13 fix(ui): fix assigning ACL tags to non-user nodes 2026-07-04 00:50:57 -04:00
Aarnav Tale 948cfad58c fix: handle registration keys on headscale 0.29+
Closes HP-555.
2026-07-04 00:07:53 -04:00
Aarnav Tale 990e1e9ff5 chore(ssh): warn about broken SSH on certain versions 2026-07-01 11:23:35 -04:00
Aarnav Tale 18263e4878 fix(ui): show display names in lists
Closes HP-549.
2026-06-22 20:23:41 -04:00
Aarnav Tale 12cee9763e fix(ui): stop split DNS from crashing the page when undefined
Closes HP-548.
2026-06-22 20:23:40 -04:00
github-actions[bot] 4d0c0cacca chore: update nix pnpm deps hash 2026-06-22 20:44:09 +00:00
Aarnav Tale 83671ff3b0 chore: add audit fix 2026-06-22 16:42:10 -04:00
Aarnav Tale 476a0419f1 chore: update dev stuff 2026-06-22 16:40:29 -04:00
Aarnav Tale 0439175e73 feat(auth): support role sync on re-login 2026-06-22 16:40:29 -04:00
Aarnav Tale a3cd8444a3 chore(auth): add more informative logs 2026-06-22 16:40:29 -04:00
Aarnav Tale 10055541cc feat(config): simplify required headscale config 2026-06-22 16:40:29 -04:00
Aarnav Tale dc32427cff feat(config): use minimal headscale config 2026-06-22 16:40:28 -04:00
github-actions[bot] 5aebc6d932 chore: update nix pnpm deps hash 2026-06-20 22:14:47 +00:00
Aarnav Tale e29221e5f7 feat: add support for headscale 0.29+ 2026-06-20 18:03:14 -04:00
Aarnav Tale 846c030bc1 chore: update to react router v8 2026-06-20 13:33:24 -04:00
Aarnav Tale 5d6eef5843 feat: update to the v8 middleware api 2026-06-20 12:38:09 -04:00
Aarnav Tale 3f9dcd5eb4 feat: update build for react router v8 2026-06-20 12:09:42 -04:00
Aarnav Tale 0c4d175eb7 feat(auth): support deriving roles from the IDP
Closes HP-352.
2026-06-20 11:53:09 -04:00
Aarnav Tale 96f2721272 feat(auth): support reverse-proxy driven proxy auth
Closes HP-353.
2026-06-20 11:41:44 -04:00
eccgecko c7822e3ec2 fix(wasm): make whole vendored tailscale tree writable before patching (#567) 2026-06-18 10:53:21 -04:00
github-actions[bot] 4179e3e604 chore: update nix pnpm deps hash 2026-06-17 20:06:57 +00:00
Aarnav Tale f00d8ab87e chore: changelog 2026-06-17 16:05:21 -04:00
Aarnav Tale 3252482e0b feat: switch logging to pino
Closes HP-279
2026-06-17 15:58:48 -04:00
Aarnav Tale 57c8046f99 feat(ui): add support for showing existing tag options 2026-06-17 11:35:38 -04:00
Aarnav Tale c8b1a30f34 fix(wasm): account for custom DERP ports when checking the probe
Closes HP-540
2026-06-17 11:30:34 -04:00
Aarnav Tale 8017436bb6 fix(ui): increase ssh pre-auth key expiry time and present errors
Closes HP-546
2026-06-17 11:27:07 -04:00
Aarnav Tale dea19f9330 fix(ui): validate machine names before rename submission
Closes HP-545
2026-06-17 11:15:30 -04:00
Aarnav Tale 21806caa05 fix(oidc): correctly handle client_secret_basic fallback 2026-06-17 11:13:46 -04:00
Aarnav Tale 2f3a440de5 fix: auto read dns.extra_records_path instead of making it required
Closes HP-538
2026-06-17 11:11:20 -04:00
Aarnav Tale e74e0d4542 fix: don't require postgres pass when password_file is supplied
Closes HP-528
2026-06-17 11:09:12 -04:00
Aarnav Tale db39b5f2bd chore: add pullfrog.yml workflow 2026-06-17 10:26:20 -04:00
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
198 changed files with 10976 additions and 6863 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
+57
View File
@@ -0,0 +1,57 @@
# PULLFROG ACTION — DO NOT EDIT EXCEPT WHERE INDICATED
name: Pullfrog
run-name: ${{ inputs.name || github.workflow }}
on:
workflow_dispatch:
inputs:
prompt:
type: string
description: Agent prompt
name:
type: string
description: Run name
permissions:
contents: read
jobs:
pullfrog:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Run agent
uses: pullfrog/pullfrog@v0
with:
prompt: ${{ inputs.prompt }}
env:
# add at least one provider API key
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
GOOGLE_GENERATIVE_AI_API_KEY:
${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
# for Amazon Bedrock (https://docs.pullfrog.com/bedrock)
# AWS_BEARER_TOKEN_BEDROCK: ${{ secrets.AWS_BEARER_TOKEN_BEDROCK }}
# AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
# AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
# AWS_REGION: us-east-1
# BEDROCK_MODEL_ID: <bedrock-model-id>
# for Google Vertex AI (https://docs.pullfrog.com/vertex)
# VERTEX_SERVICE_ACCOUNT_JSON: ${{ secrets.VERTEX_SERVICE_ACCOUNT_JSON }}
# GOOGLE_CLOUD_PROJECT: my-project
# VERTEX_LOCATION: global
# VERTEX_MODEL_ID: <vertex-model-id>
+1
View File
@@ -1 +1,2 @@
side-effects-cache = false
public-hoist-pattern[]=vue
+75 -43
View File
@@ -1,58 +1,90 @@
# Next
- Fixed the Headplane agent falling back to an interactive Tailscale login. The agent now starts with a pre-auth-key, preserves its existing state across restarts, and auto-approves itself when Headscale requires manual approval (closes [#582](https://github.com/tale/headplane/issues/582)).
# 0.7.0
- Switched to structured JSON logging (closes [#279](https://github.com/tale/headplane/issues/279)).
- Added suggestions to pick existing tags to the machine tag dialog (closes [#560](https://github.com/tale/headplane/issues/560)).
- Headplane correctly handles `dns.extra_records_path` from the Headscale configuration (closes [#543](https://github.com/tale/headplane/issues/543)).
- Fixed Headscale PostgreSQL config validation so `pass` is not required when `password_file` is supplied (closes [#528](https://github.com/tale/headplane/issues/528)).
- Fixed Browser SSH's WASM DERP probe to account for custom DERP ports (closes [#552](https://github.com/tale/headplane/issues/552)).
- Fixed Browser SSH pre-auth key handling by increasing the temporary key expiry window and showing key creation errors in the UI (closes [#565](https://github.com/tale/headplane/issues/565)).
- Fixed machine rename submission by validating names before sending the rename request (closes [#564](https://github.com/tale/headplane/issues/564)).
- Fixed OIDC token exchange fallback when retrying with `client_secret_basic` (closes [#493](https://github.com/tale/headplane/issues/493)).
- Added support for proxy authentication via `server.proxy_auth` (closes [#353](https://github.com/tale/headplane/issues/353)).
- Added automatic role assignment for new OIDC users via `oidc.default_role` and IdP-provided role claims via `oidc.role_claim` (closes [#352](https://github.com/tale/headplane/issues/352)).
- Fixed the DNS page crashing when Headscale has no Split DNS nameservers configured (closes [#570](https://github.com/tale/headplane/issues/570)).
- User lists now show Headscale display names while preserving usernames as secondary text (closes [#571](https://github.com/tale/headplane/issues/571)).
- Fixed the Register Machine Key dialog so it accepts registration URLs and full `hskey-authreq-...` registration keys (closes [#579](https://github.com/tale/headplane/issues/579)).
- Fixed assigning ACL tags to tag-only (no-user) nodes from the UI. The "Add" and "Remove" tag buttons in the tag dialog lacked `type="button"`, so clicking them submitted the form before the local state update was applied and Headscale received the unchanged tag list. Tag modifications now reach Headscale as intended (closes [#574](https://github.com/tale/headplane/issues/574)).
---
# 0.7.0-beta.4 (May 31, 2026)
> This is a beta release. Please report any issues you encounter.
- **Headplane now requires Headscale 0.27.0 or newer.** Support for 0.26.x has been dropped. If `/version` returns 404 (the endpoint was added in 0.27.0), Headplane logs an error and keeps retrying so an in-place Headscale upgrade is picked up without a restart.
- **Replaced the OpenAPI hash detection with `/version`.** Capabilities are now derived from the version reported by `/version` instead of fingerprinting the OpenAPI schema. This dramatically simplifies version detection and works with every supported release out of the box.
- **Made Headscale boot resilient.** Headplane now boots even when Headscale is unreachable; capabilities default permissively and a background retry settles them once Headscale responds. No more cold-start ordering problems with docker-compose.
- **Added optional in-process TLS termination.** Setting `server.tls_cert_path` and `server.tls_key_path` makes Headplane serve HTTPS/1.1 on `server.port` directly — no reverse proxy required. `server.cookie_secure` is auto-forced to `true` (with a warning) whenever TLS is enabled, since browsers refuse `Secure`-less cookies over HTTPS. HTTP/2 and HTTP/3 are intentionally not supported in-process; terminate those at a reverse proxy if you need them (closes [#403](https://github.com/tale/headplane/issues/403)).
- **Made the bundled Docker healthcheck zero-config across HTTP and HTTPS.** Headplane writes its loopback URL (scheme, port, and basename included) to `/tmp/headplane-listen` on startup, and `hp_healthcheck` reads that file and probes the URL verbatim. Enabling TLS or changing `server.port` no longer requires any healthcheck-specific configuration. Native installs are unaffected — the listen file is only written when `HEADPLANE_LISTEN_FILE` is set, which the Dockerfile does automatically.
- Added Rename and Delete actions for unlinked Headscale users on the Users page so admins can manage Headscale users that have no Headplane account (closes [#525](https://github.com/tale/headplane/issues/525)).
- Documented [Custom Certificate Authorities](/configuration/tls#custom-certificate-authorities) for trusting private or self-signed CAs across every outbound TLS connection (OIDC, Headscale, Docker, etc.) via Node's `NODE_EXTRA_CA_CERTS`. This replaces the previous workaround of rebuilding the Docker image to extend the system trust store (closes [#313](https://github.com/tale/headplane/issues/313)).
- Fixed user-management actions (link, change role, transfer ownership) using the wrong ID type for unlinked Headplane users. Form fields are now explicitly `headplane_user_id` vs. `headscale_user_id`, and the auth layer no longer round-trips through Headscale to recover the OIDC subject.
- Fixed the "Register Machine Key" dialog passing the Headscale numeric user id instead of the username. Headscale's `RegisterNodeRequest.user` proto field is a `string` looked up via `GetUserByName` (no numeric fallback), so registration was failing whenever the selected owner's display name differed from their numeric id (closes [#532](https://github.com/tale/headplane/issues/532)).
- Fixed pre-auth key expiration on Headscale 0.27.x. The pre-0.28 expire endpoint takes a `uint64 user` field which the API layer reads from `key.user?.id`, but the caller was wrapping the id as `{ name: user }`, causing the request to send an empty user field. Headplane now correctly passes the numeric Headscale user id.
- Fixed dialog panels growing beyond the viewport; dialog content is now constrained and scrollable (via [#556](https://github.com/tale/headplane/pull/556)).
- Fixed focus rings on inputs and buttons inside dialogs being clipped by the scrollable content container.
- Fixed tooltips on the last row of the machines table being clipped by the viewport; tooltips now anchor above the trigger with collision padding (closes [#508](https://github.com/tale/headplane/issues/508)).
- Corrected the Docker healthcheck example in the docs to use the required `CMD` prefix so reverse proxies don't see the container as unhealthy (closes [#535](https://github.com/tale/headplane/issues/535)).
---
# 0.7.0-beta.3 (May 14, 2026)
> This is a beta release. Please report any issues you encounter.
- Fixed GHSA-vgj6-hcf2-fqf6, a path traversal / RBAC bypass in Headscale node and user rename API calls.
---
# 0.6.3 (May 14, 2026)
- Fixed GHSA-vgj6-hcf2-fqf6, a path traversal / RBAC bypass in Headscale node and user rename API calls.
---
# 0.7.0-beta.2 (April 9, 2026)
> This is a beta release. Please report any issues you encounter.
- **Rebuilt the Browser SSH feature**
- Should now work with custom DERP ports and properly handle sessions.
- Switched to using `libghostty` for a proper, modern terminal experience (closes [#515](https://github.com/tale/headplane/issues/515)).
- Added more resilient error handling and state handling when initiating connections.
- **Migrated all UI components from react-aria/react-stately to @base-ui-components/react.**
- Removed `react-aria`, `react-stately`, and `tailwindcss-react-aria-components` as dependencies.
- **Replaced `openid-client` with a clean-room OIDC implementation.**
- Removed the `openid-client` dependency entirely.
- Fixed `client_secret_basic` auth method not working with Google SSO and other providers (closes [#493](https://github.com/tale/headplane/issues/493)).
- Fixed OIDC connector initialization failures on beta.1 (closes [#516](https://github.com/tale/headplane/issues/516)).
- **Rearchitected the Headplane Agent** with a new sync model (closes [#350](https://github.com/tale/headplane/issues/350), closes [#455](https://github.com/tale/headplane/issues/455)).
- The Go binary connects to the Tailnet and fetches all peer hostinfo as JSON.
- The Node.js manager auto-generates ephemeral tag-only pre-auth keys (requires Headscale 0.28+).
- Deprecated `integration.agent.pre_authkey` and `integration.agent.cache_path` config fields.
- Added `integration.agent.executable_path` config field.
- **Consolidated the Headscale API key** under `headscale.api_key` (and `headscale.api_key_path`).
- Deprecated `oidc.headscale_api_key` — it is still read as a fallback but will be removed in a future release.
- Both the agent and OIDC now use the same key from `headscale.api_key`.
- **Reworked the authentication system** with a new `AuthService` that consolidates session management and role enforcement (via [#489](https://github.com/tale/headplane/pull/489)).
- Added an agent status page at `/settings/agent` showing sync status, node count, errors, and a "Sync Now" button.
- Added additional machine list filters for user, tag, status, and route (via [#507](https://github.com/tale/headplane/pull/507), closes [#506](https://github.com/tale/headplane/issues/506)).
- **Rebuilt the user model to enable "account linking" between Headplane and Headscale.** OIDC users are automatically linked to their Headscale counterparts based on subject and email. Users who cannot be automatically linked can claim an unlinked Headscale user during onboarding. See the [SSO docs](/features/sso) for details (via [#489](https://github.com/tale/headplane/pull/489)).
- **Rebuilt Browser SSH** with a new terminal powered by [Ghostty WASM](https://restty.dev), improved session handling, and support for custom DERP ports. See the [Browser SSH docs](/features/ssh) for details (closes [#515](https://github.com/tale/headplane/issues/515), closes [#386](https://github.com/tale/headplane/issues/386)).
- **Rearchitected the Headplane Agent** with a periodic sync model and extensive caching. The agent now auto-generates ephemeral pre-auth keys (requires Headscale 0.28+). See the [Agent docs](/features/agent) for details (closes [#350](https://github.com/tale/headplane/issues/350), closes [#455](https://github.com/tale/headplane/issues/455)).
- **Replaced `openid-client` with a new OIDC implementation.** Fixes `client_secret_basic` not working with Google SSO and other providers (closes [#493](https://github.com/tale/headplane/issues/493), closes [#516](https://github.com/tale/headplane/issues/516)).
- **Migrated all UI components from react-aria to [Base UI](https://base-ui.com).**
- **Consolidated the Headscale API key** under `headscale.api_key` (and `headscale.api_key_path`). Deprecated `oidc.headscale_api_key` — it is still read as a fallback but will be removed in a future release.
- Added machine list filters for user, tag, status, and route (via [#507](https://github.com/tale/headplane/pull/507), closes [#506](https://github.com/tale/headplane/issues/506)).
- Added self-service pre-auth key creation for auditor role users (via [#478](https://github.com/tale/headplane/pull/478), closes [#453](https://github.com/tale/headplane/issues/453)).
- Store OIDC profile pictures in the database to prevent cookie header overload (closes [#326](https://github.com/tale/headplane/issues/326), via [#510](https://github.com/tale/headplane/pull/510)).
- Fixed pre-auth key expiration on Headscale 0.28+ (closes [#519](https://github.com/tale/headplane/issues/519)).
- Fixed OIDC subject matching for providers that use special characters in user IDs (e.g. Auth0 `github|12345`) (closes [#428](https://github.com/tale/headplane/issues/428)).
- Fixed `headscale.api_key` not being used consistently across all code paths.
- Fixed intermittent SSR crash on the Access Control page caused by client-only CodeMirror imports.
- Added an agent status page at `/settings/agent` showing sync status, node count, and errors.
- Added local endpoint and address information to the machine detail page.
- Improved the ACL editor appearance and fixed a CodeMirror version mismatch.
- Store OIDC profile pictures in the database to prevent cookie overflow (via [#510](https://github.com/tale/headplane/pull/510), closes [#326](https://github.com/tale/headplane/issues/326)).
- Detect unsupported Docker API versions early with a clear error message (via [#497](https://github.com/tale/headplane/pull/497)).
- Fixed "No expiry" badge not displaying for nodes with zero-time expiry values (via [#527](https://github.com/tale/headplane/pull/527), closes [#526](https://github.com/tale/headplane/issues/526)).
- Fixed first user not being assigned the owner role on OIDC login (via [#480](https://github.com/tale/headplane/pull/480), closes [#266](https://github.com/tale/headplane/issues/266)).
- Fixed login errors throwing an unexpected server error instead of showing form validation (via [#475](https://github.com/tale/headplane/pull/475), closes [#474](https://github.com/tale/headplane/issues/474)).
- Fixed login errors throwing a server error instead of showing form validation (via [#475](https://github.com/tale/headplane/pull/475), closes [#474](https://github.com/tale/headplane/issues/474)).
- Fixed pre-auth key expiration on Headscale 0.28+ (closes [#519](https://github.com/tale/headplane/issues/519)).
- Fixed OIDC subject matching for providers with special characters in user IDs, e.g. Auth0 (closes [#428](https://github.com/tale/headplane/issues/428)).
- Fixed `headscale.api_key` not being used consistently across all code paths.
- Fixed agent HostInfo not refreshing periodically using `cache_ttl` (via [#477](https://github.com/tale/headplane/pull/477), closes [#427](https://github.com/tale/headplane/issues/427)).
- Fixed agent working directory being wiped on restart.
- Fixed a race condition where the SSE controller could be used after being closed.
- **Rewrote the WebSSH WASM module** to match Tailscale's proven `tsconnect` init sequence.
- Switched the terminal renderer from xterm.js to [restty](https://restty.dev) (Ghostty WASM).
- Bundled self-hosted JetBrains Mono Nerd Font with Nerd Fonts symbol fallback — no CDN dependency.
- Fixed SSH sessions failing with EOF: the SSH channel multiplexer was not receiving server traffic.
- Fixed terminal resize sending swapped rows/cols, causing garbled output on window resize.
- Fixed `log.Fatal()` calls in the WASM bridge killing the entire runtime on recoverable errors.
- Fixed `Close()` returning `true` on error and `false` on success.
- Fixed stale closure bug in the NodeKey tracking callback.
- Removed unnecessary `LoginDefault` and `LocalBackendStartKeyOSNeutral` control flags.
- Added cancellation support for in-flight SSH connections on close.
- Fixed WebSSH dropping DERP port information on non-standard ports (e.g. `:8443`), which caused connections to fail (closes [#515](https://github.com/tale/headplane/issues/515)).
- Fixed WebSSH WASM prefix paths for correct asset loading (closes [#386](https://github.com/tale/headplane/issues/386)).
- Fixed Nix WASM build applying DERP patch to wrong vendor directory.
- Fixed Dockerfile WASM copy paths.
- Fixed CodeMirror version mismatch override in the ACL editor.
- Fixed cookie secret generation using incorrect byte length (via [#501](https://github.com/tale/headplane/pull/501)).
- Fixed OIDC configuration error troubleshooting link (via [#518](https://github.com/tale/headplane/pull/518), closes [#517](https://github.com/tale/headplane/issues/517)).
- Fixed deprecated Nix package attributes (via [#521](https://github.com/tale/headplane/pull/521)).
- Detect unsupported Docker API versions early with a clear error message (via [#497](https://github.com/tale/headplane/pull/497)).
- Updated NixOS module options: removed deprecated agent fields, added `headscale.api_key_path` and `integration.agent.executable_path`.
---
+7
View File
@@ -57,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" ]
@@ -73,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>,
);
});
+7 -5
View File
@@ -1,19 +1,21 @@
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 { EntryContext, RouterContextProvider } from "react-router";
import { ServerRouter } from "react-router";
import log from "~/utils/log";
export const streamTimeout = 5_000;
export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
_loadContext: AppLoadContext,
_loadContext: RouterContextProvider,
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
@@ -52,7 +54,7 @@ export default function handleRequest(
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
log.error("server", "Streaming render error: %o", error);
}
},
},
+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;
+45 -32
View File
@@ -2,8 +2,17 @@ import { Outlet, redirect, type ShouldRevalidateFunction } from "react-router";
import { ErrorBanner } from "~/components/error-banner";
import StatusBanner from "~/components/status-banner";
import {
appConfigContext,
authContext,
headscaleConfigContext,
headscaleContext,
headscaleLiveStoreContext,
requestApiContext,
} from "~/server/context";
import { isDataUnauthorizedError } from "~/server/headscale/api/error-client";
import { usersResource } from "~/server/headscale/live-store";
import { isUserPrincipal } from "~/server/web/auth";
import { Capabilities } from "~/server/web/roles";
import log from "~/utils/log";
@@ -30,36 +39,40 @@ export const shouldRevalidate: ShouldRevalidateFunction = ({
};
export async function loader({ request, context }: Route.LoaderArgs) {
const auth = context.get(authContext);
const config = context.get(appConfigContext);
const getRequestApi = context.get(requestApiContext);
const headscale = context.get(headscaleContext);
const headscaleConfig = context.get(headscaleConfigContext);
const headscaleLiveStore = context.get(headscaleLiveStoreContext);
try {
const principal = await context.auth.require(request);
const { principal, api } = await getRequestApi(request);
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
const user =
principal.kind === "oidc"
? {
email: principal.profile.email,
name: principal.profile.name,
picture: principal.profile.picture,
subject: principal.user.subject,
username: principal.profile.username,
}
: { name: principal.displayName, subject: "api_key" };
const user = isUserPrincipal(principal)
? {
email: principal.profile.email,
name: principal.profile.name,
picture: principal.profile.picture,
subject: principal.user.subject,
username: principal.profile.username,
}
: { name: principal.displayName, subject: "api_key" };
// MARK: The session should stay valid if Headscale isn't healthy
const isHealthy = await api.isHealthy();
const isHealthy = await headscale.health();
if (isHealthy) {
try {
await api.getApiKeys();
await api.apiKeys.list();
} catch (error) {
if (isDataUnauthorizedError(error)) {
const displayName =
principal.kind === "oidc" ? principal.profile.name : principal.displayName;
const displayName = isUserPrincipal(principal)
? principal.profile.name
: principal.displayName;
log.warn("auth", "Logging out %s due to expired API key", displayName);
return redirect("/login", {
headers: {
"Set-Cookie": await context.auth.destroySession(request),
"Set-Cookie": await auth.destroySession(request),
},
});
}
@@ -67,11 +80,11 @@ export async function loader({ request, context }: Route.LoaderArgs) {
// Self-heal: if the linked Headscale user was deleted, clear the
// stale link so the user gets prompted to re-link.
if (principal.kind === "oidc" && principal.user.headscaleUserId) {
if (isUserPrincipal(principal) && principal.user.headscaleUserId) {
try {
const usersSnap = await context.hsLive.get(usersResource, api);
const usersSnap = await headscaleLiveStore.get(usersResource, api);
if (!usersSnap.data.some((u) => u.id === principal.user.headscaleUserId)) {
await context.auth.unlinkHeadscaleUser(principal.user.id);
await auth.unlinkHeadscaleUser(principal.user.id);
}
} catch {
// API call failed, skip validation
@@ -81,23 +94,23 @@ export async function loader({ request, context }: Route.LoaderArgs) {
return {
access: {
dns: context.auth.can(principal, Capabilities.read_network),
machines: context.auth.can(principal, Capabilities.read_machines),
policy: context.auth.can(principal, Capabilities.read_policy),
settings: context.auth.can(principal, Capabilities.read_feature),
ui: context.auth.can(principal, Capabilities.ui_access),
users: context.auth.can(principal, Capabilities.read_users),
dns: auth.can(principal, Capabilities.read_network),
machines: auth.can(principal, Capabilities.read_machines),
policy: auth.can(principal, Capabilities.read_policy),
settings: auth.can(principal, Capabilities.read_feature),
ui: auth.can(principal, Capabilities.ui_access),
users: auth.can(principal, Capabilities.read_users),
},
baseUrl: context.config.headscale.public_url ?? context.config.headscale.url,
configAvailable: context.hs.readable(),
isDebug: context.config.debug,
baseUrl: config.headscale.public_url ?? config.headscale.url,
configAvailable: headscaleConfig.readable(),
isDebug: config.debug,
isHealthy,
user,
};
} catch {
return redirect("/login", {
headers: {
"Set-Cookie": await context.auth.destroySession(request),
"Set-Cookie": await auth.destroySession(request),
},
});
}
+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>
+4 -1
View File
@@ -5,7 +5,10 @@ export default [
route("/healthz", "routes/util/healthz.ts"),
// API Routes
...prefix("/api", [route("/info", "routes/util/info.ts")]),
...prefix("/api", [
route("/info", "routes/util/info.ts"),
route("/color-scheme", "routes/util/color-scheme.ts"),
]),
...prefix("/events", [route("/live", "routes/util/live.ts")]),
// Authentication Routes
+28 -67
View File
@@ -1,5 +1,6 @@
import { data } from "react-router";
import { authContext, requestApiContext } from "~/server/context";
import { isDataWithApiError } from "~/server/headscale/api/error-client";
import { Capabilities } from "~/server/web/roles";
@@ -9,8 +10,11 @@ import type { Route } from "./+types/overview";
// If it isn't, it'll gracefully error anyways, since this means some
// fishy client manipulation is happening.
export async function aclAction({ request, context }: Route.ActionArgs) {
const principal = await context.auth.require(request);
const check = context.auth.can(principal, Capabilities.write_policy);
const auth = context.get(authContext);
const getRequestApi = context.get(requestApiContext);
const principal = await auth.require(request);
const check = auth.can(principal, Capabilities.write_policy);
if (!check) {
throw data("You do not have permission to write to the ACL policy", {
status: 403,
@@ -26,10 +30,9 @@ export async function aclAction({ request, context }: Route.ActionArgs) {
});
}
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
const { api } = await getRequestApi(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 +58,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,
);
}
}
+10 -7
View File
@@ -1,5 +1,6 @@
import { data } from "react-router";
import { authContext, requestApiContext } from "~/server/context";
import { isDataWithApiError } from "~/server/headscale/api/error-client";
import { Capabilities } from "~/server/web/roles";
@@ -11,10 +12,13 @@ import type { Route } from "./+types/overview";
// 2. Does the user have permission to write to the policy?
// 3. Is the Headscale policy in file or database mode?
// If database, we can read/write easily via the API.
// If in file mode, we can only write if context.config is available.
// If in file mode, we can only write if the Headscale config is available.
export async function aclLoader({ request, context }: Route.LoaderArgs) {
const principal = await context.auth.require(request);
const check = context.auth.can(principal, Capabilities.read_policy);
const auth = context.get(authContext);
const getRequestApi = context.get(requestApiContext);
const principal = await auth.require(request);
const check = auth.can(principal, Capabilities.read_policy);
if (!check) {
throw data("You do not have permission to read the ACL policy.", {
status: 403,
@@ -23,16 +27,15 @@ export async function aclLoader({ request, context }: Route.LoaderArgs) {
const flags = {
// Can the user write to the ACL policy
access: context.auth.can(principal, Capabilities.write_policy),
access: auth.can(principal, Capabilities.write_policy),
writable: false,
policy: "",
};
// Try to load the ACL policy from the API.
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
const { api } = await getRequestApi(request);
try {
const { policy, updatedAt } = await api.getPolicy();
const { policy, updatedAt } = await api.policy.get();
flags.writable = updatedAt !== null;
flags.policy = policy;
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)];
+2 -2
View File
@@ -108,12 +108,12 @@ export default function Page({ loaderData: { access, writable, policy } }: Route
</TabsTab>
</TabsList>
<TabsPanel value="edit">
<Suspense fallback={<Fallback acl={codePolicy} />}>
<Suspense fallback={<Fallback />}>
<LazyEditor isDisabled={disabled} onChange={setCodePolicy} value={codePolicy} />
</Suspense>
</TabsPanel>
<TabsPanel value="diff">
<Suspense fallback={<Fallback acl={codePolicy} />}>
<Suspense fallback={<Fallback />}>
<LazyDiffer left={policy} right={codePolicy} />
</Suspense>
</TabsPanel>
+9 -3
View File
@@ -1,11 +1,15 @@
import { redirect } from "react-router";
import { authContext, headscaleContext } from "~/server/context";
import { isDataWithApiError } from "~/server/headscale/api/error-client";
import log from "~/utils/log";
import type { Route } from "./+types/page";
export async function loginAction({ request, context }: Route.LoaderArgs) {
const auth = context.get(authContext);
const headscale = context.get(headscaleContext);
const formData = await request.formData();
const apiKey = formData.has("api_key") ? String(formData.get("api_key")) : undefined;
@@ -33,9 +37,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 = 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
@@ -68,7 +74,7 @@ export async function loginAction({ request, context }: Route.LoaderArgs) {
return redirect("/machines", {
headers: {
"Set-Cookie": await context.auth.createApiKeySession(
"Set-Cookie": await auth.createApiKeySession(
apiKey,
`${lookup.prefix}...`,
expiry.getTime() - Date.now(),
+39 -12
View File
@@ -7,7 +7,10 @@ import Card from "~/components/card";
import Code from "~/components/code";
import Input from "~/components/input";
import Link from "~/components/link";
import { appConfigContext, authContext, oidcContext } from "~/server/context";
import type { OidcError, OidcService } from "~/server/oidc/provider";
import { useLiveData } from "~/utils/live-data";
import log from "~/utils/log";
import type { Route } from "./+types/page";
import { loginAction } from "./action";
@@ -15,24 +18,41 @@ import { OidcConfigErrorNotice, OidcDiscoveryFailedNotice } from "./config-error
import Logout from "./logout";
import { OidcErrorNotice } from "./oidc-error";
export async function loader({ request, context }: Route.LoaderArgs) {
export async function loader({ request, context, url }: Route.LoaderArgs) {
const auth = context.get(authContext);
const config = context.get(appConfigContext);
const oidc = context.get(oidcContext);
try {
await context.auth.require(request);
await auth.require(request);
return redirect("/machines");
} catch {}
const qp = new URL(request.url).searchParams;
const qp = url.searchParams;
const urlState = qp.get("s") ?? undefined;
const oidcService = context.oidc?.service;
const oidcStatus = oidcService
? await oidcService.discover().then(
(r) => (r.ok ? oidcService.status() : oidcService.status()),
() => oidcService.status(),
)
: undefined;
const oidcService = oidc.state === "enabled" ? oidc.value : undefined;
let oidcStatus: ReturnType<OidcService["status"]> | undefined;
if (oidcService) {
try {
const result = await oidcService.discover();
if (!result.ok) {
logLoginOidcError("OIDC discovery failed", result.error);
}
} catch (error) {
log.error("auth", "OIDC discovery failed unexpectedly: %s", String(error));
log.debug("auth", "OIDC discovery error details: %o", error);
}
if (context.oidc?.disableApiKeyLogin && oidcStatus?.state === "ready" && urlState !== "logout") {
oidcStatus = oidcService.status();
}
if (
oidcService &&
config.oidc?.disable_api_key_login &&
oidcStatus?.state === "ready" &&
urlState !== "logout"
) {
return redirect("/oidc/start");
}
@@ -40,7 +60,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
const oidcErrorCodes = oidcStatus?.state === "error" ? [oidcStatus.error.code] : [];
return {
isCookieSecureEnabled: context.config.server.cookie_secure,
isCookieSecureEnabled: config.server.cookie_secure,
isOidcConnectorEnabled,
oidcErrorCodes,
urlState,
@@ -49,6 +69,13 @@ export async function loader({ request, context }: Route.LoaderArgs) {
export const action = loginAction;
function logLoginOidcError(context: string, error: OidcError): void {
log.error("auth", "%s [%s]: %s", context, error.code, error.message);
if (error.hint) {
log.error("auth", "Hint: %s", error.hint);
}
}
export default function Page({ loaderData, actionData }: Route.ComponentProps) {
const { isCookieSecureEnabled, isOidcConnectorEnabled, oidcErrorCodes, urlState } = loaderData;
+31 -6
View File
@@ -1,25 +1,50 @@
import { type ActionFunctionArgs, redirect } from "react-router";
import type { LoadContext } from "~/server";
import { appConfigContext, authContext, oidcContext } 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) {
const auth = context.get(authContext);
const config = context.get(appConfigContext);
const oidc = context.get(oidcContext);
let principal: Awaited<ReturnType<typeof auth.require>> | undefined;
try {
await context.auth.require(request);
principal = await 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 = config.oidc?.disable_api_key_login ? "/login?s=logout" : "/login";
// For OIDC sessions, redirect to the provider's RP-initiated logout
// endpoint when explicitly enabled, so the upstream IdP session is also
// ended. Disabled by default because the post_logout_redirect_uri must be
// pre-registered on the IdP — turning this on without registering it would
// strand users on the IdP's error page.
if (principal?.kind === "oidc" && oidc.state === "enabled" && config.oidc?.use_end_session) {
const service = oidc.value;
const status = service.status();
if (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: {
"Set-Cookie": await context.auth.destroySession(request),
"Set-Cookie": await auth.destroySession(request),
},
});
}
+58 -24
View File
@@ -1,23 +1,38 @@
import { data, redirect } from "react-router";
import {
appConfigContext,
authContext,
headscaleApiKeyContext,
headscaleContext,
oidcContext,
} from "~/server/context";
import { logOidcError } from "~/server/oidc/provider";
import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity";
import { Roles } from "~/server/web/roles";
import log from "~/utils/log";
import { createOidcStateCookie } from "~/utils/oidc-state";
import type { Route } from "./+types/oidc-callback";
export async function loader({ request, context }: Route.LoaderArgs) {
const service = context.oidc?.service;
if (!service) {
throw data("OIDC is not enabled or misconfigured", { status: 501 });
}
export async function loader({ request, context, url }: Route.LoaderArgs) {
const auth = context.get(authContext);
const config = context.get(appConfigContext);
const headscale = context.get(headscaleContext);
const headscaleApiKey = context.get(headscaleApiKeyContext);
const oidc = context.get(oidcContext);
if (oidc.state !== "enabled") {
throw data(`OIDC is unavailable: ${oidc.reason}`, { status: 501 });
}
const service = oidc.value;
const url = new URL(request.url);
if (url.searchParams.toString().length === 0) {
log.warn("auth", "Called OIDC callback without query parameters");
return redirect("/login?s=error_no_query");
}
const cookie = createOidcStateCookie(context.config);
const cookie = createOidcStateCookie(config);
const oidcCookieState = await cookie.parse(request.headers.get("Cookie"));
if (oidcCookieState == null) {
@@ -40,39 +55,58 @@ export async function loader({ request, context }: Route.LoaderArgs) {
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);
}
logOidcError("OIDC callback failed", result.error);
return redirect("/login?s=error_auth_failed");
}
const identity = result.value;
const claimedRole =
identity.role && identity.role !== "owner" && identity.role in Roles
? identity.role
: undefined;
const userId = await context.auth.findOrCreateUser(identity.subject, {
name: identity.name,
email: identity.email,
picture: identity.picture,
});
const userId = await auth.findOrCreateUser(
identity.subject,
{
name: identity.name,
email: identity.email,
picture: identity.picture,
},
{
initialRole: claimedRole ?? config.oidc?.default_role,
syncRole: claimedRole,
},
);
try {
const hsApi = context.hsApi.getRuntimeClient(context.headscaleApiKey!);
const hsUsers = await hsApi.getUsers();
// Looks up the Headscale user that matches this OIDC identity. We use
// the configured admin API key here — not a per-request one — because
// there is no per-request key yet (the session is being created).
const hsApi = headscale.client(headscaleApiKey!);
const hsUsers = await hsApi.users.list();
const hsUser = findHeadscaleUserBySubject(hsUsers, identity.subject, identity.email);
if (hsUser) {
await context.auth.linkHeadscaleUser(userId, hsUser.id);
await 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 = 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,
}),
"Set-Cookie": await auth.createOidcSession(
userId,
{
name: identity.name,
email: identity.email,
username: identity.username,
},
{ idToken },
),
},
});
}
+12 -5
View File
@@ -1,27 +1,34 @@
import { data, redirect } from "react-router";
import { appConfigContext, authContext, oidcContext } from "~/server/context";
import { logOidcError } from "~/server/oidc/provider";
import { createOidcStateCookie } from "~/utils/oidc-state";
import type { Route } from "./+types/oidc-start";
export async function loader({ request, context }: Route.LoaderArgs) {
const auth = context.get(authContext);
const config = context.get(appConfigContext);
const oidc = context.get(oidcContext);
try {
await context.auth.require(request);
await auth.require(request);
return redirect("/");
} catch {}
const service = context.oidc?.service;
if (!service) {
throw data("OIDC is not enabled or misconfigured", { status: 501 });
if (oidc.state !== "enabled") {
throw data(`OIDC is unavailable: ${oidc.reason}`, { status: 501 });
}
const service = oidc.value;
const result = await service.startFlow();
if (!result.ok) {
logOidcError("OIDC start failed", result.error);
return redirect(`/login?s=${result.error.code}`);
}
const { url, flowState } = result.value;
const cookie = createOidcStateCookie(context.config);
const cookie = createOidcStateCookie(config);
return redirect(url, {
status: 302,
+45 -40
View File
@@ -1,24 +1,32 @@
import { data } from "react-router";
import {
authContext,
headscaleConfigContext,
headscaleContext,
integrationContext,
} from "~/server/context";
import { Capabilities } from "~/server/web/roles";
import type { Route } from "./+types/overview";
export async function dnsAction({ request, context }: Route.ActionArgs) {
const principal = await context.auth.require(request);
const check = context.auth.can(principal, Capabilities.write_network);
const auth = context.get(authContext);
const headscale = context.get(headscaleContext);
const headscaleConfig = context.get(headscaleConfigContext);
const integration = context.get(integrationContext);
const principal = await auth.require(request);
const check = auth.can(principal, Capabilities.write_network);
if (!check) {
return data({ success: false }, 403);
}
if (!context.hs.writable()) {
if (!headscaleConfig.writable()) {
return data({ success: false }, 403);
}
// We only need it for health checks which don't require auth
const api = context.hsApi.getRuntimeClient("fake-api-key");
const formData = await request.formData();
const action = formData.get("action_id")?.toString();
if (!action) {
@@ -32,14 +40,14 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
return data({ success: false }, 400);
}
await context.hs.patch([
await headscaleConfig.patch([
{
path: "dns.base_domain",
value: newName,
},
]);
await context.integration?.onConfigChange(api);
await integration?.onConfigChange(headscale);
return { message: "Tailnet renamed successfully" };
}
case "toggle_magic": {
@@ -48,18 +56,18 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
return data({ success: false }, 400);
}
await context.hs.patch([
await headscaleConfig.patch([
{
path: "dns.magic_dns",
value: newState === "enabled",
},
]);
await context.integration?.onConfigChange(api);
await integration?.onConfigChange(headscale);
return { message: "Magic DNS state updated successfully" };
}
case "remove_ns": {
const config = context.hs.c!;
const config = headscaleConfig.getDNSConfig();
const ns = formData.get("ns")?.toString();
const splitName = formData.get("split_name")?.toString();
@@ -68,19 +76,19 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
}
if (splitName === "global") {
const servers = config.dns.nameservers.global.filter((i) => i !== ns);
const servers = config.nameservers.filter((i) => i !== ns);
await context.hs.patch([
await headscaleConfig.patch([
{
path: "dns.nameservers.global",
value: servers,
},
]);
} else {
const splits = config.dns.nameservers.split;
const splits = config.splitDns;
const servers = splits[splitName].filter((i) => i !== ns);
await context.hs.patch([
await headscaleConfig.patch([
{
path: `dns.nameservers.split."${splitName}"`,
value: servers.length > 0 ? servers : null,
@@ -88,11 +96,11 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
]);
}
await context.integration?.onConfigChange(api);
await integration?.onConfigChange(headscale);
return { message: "Nameserver removed successfully" };
}
case "add_ns": {
const config = context.hs.c!;
const config = headscaleConfig.getDNSConfig();
const ns = formData.get("ns")?.toString();
const splitName = formData.get("split_name")?.toString();
@@ -101,21 +109,19 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
}
if (splitName === "global") {
const servers = config.dns.nameservers.global;
servers.push(ns);
const servers = [...config.nameservers, ns];
await context.hs.patch([
await headscaleConfig.patch([
{
path: "dns.nameservers.global",
value: servers,
},
]);
} else {
const splits = config.dns.nameservers.split;
const servers = splits[splitName] ?? [];
servers.push(ns);
const splits = config.splitDns;
const servers = [...(splits[splitName] ?? []), ns];
await context.hs.patch([
await headscaleConfig.patch([
{
path: `dns.nameservers.split."${splitName}"`,
value: servers,
@@ -123,45 +129,44 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
]);
}
await context.integration?.onConfigChange(api);
await integration?.onConfigChange(headscale);
return { message: "Nameserver added successfully" };
}
case "remove_domain": {
const config = context.hs.c!;
const config = headscaleConfig.getDNSConfig();
const domain = formData.get("domain")?.toString();
if (!domain) {
return data({ success: false }, 400);
}
const domains = config.dns.search_domains.filter((i) => i !== domain);
await context.hs.patch([
const domains = config.searchDomains.filter((i) => i !== domain);
await headscaleConfig.patch([
{
path: "dns.search_domains",
value: domains,
},
]);
await context.integration?.onConfigChange(api);
await integration?.onConfigChange(headscale);
return { message: "Domain removed successfully" };
}
case "add_domain": {
const config = context.hs.c!;
const config = headscaleConfig.getDNSConfig();
const domain = formData.get("domain")?.toString();
if (!domain) {
return data({ success: false }, 400);
}
const domains = config.dns.search_domains;
domains.push(domain);
const domains = [...config.searchDomains, domain];
await context.hs.patch([
await headscaleConfig.patch([
{
path: "dns.search_domains",
value: domains,
},
]);
await context.integration?.onConfigChange(api);
await integration?.onConfigChange(headscale);
return { message: "Domain added successfully" };
}
case "remove_record": {
@@ -173,7 +178,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
}
// Value is not needed for removal
const restart = await context.hs.removeDNS({
const restart = await headscaleConfig.removeDNS({
name: recordName,
type: recordType,
value: "",
@@ -183,7 +188,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
return;
}
await context.integration?.onConfigChange(api);
await integration?.onConfigChange(headscale);
return { message: "DNS record removed successfully" };
}
case "add_record": {
@@ -195,7 +200,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
return data({ success: false }, 400);
}
const restart = await context.hs.addDNS({
const restart = await headscaleConfig.addDNS({
name: recordName,
type: recordType,
value: recordValue,
@@ -205,7 +210,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
return;
}
await context.integration?.onConfigChange(api);
await integration?.onConfigChange(headscale);
return { message: "DNS record added successfully" };
}
case "override_dns": {
@@ -215,14 +220,14 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
}
const overrideValue = override === "true";
await context.hs.patch([
await headscaleConfig.patch([
{
path: "dns.override_local_dns",
value: overrideValue,
},
]);
await context.integration?.onConfigChange(api);
await integration?.onConfigChange(headscale);
return { message: "DNS override updated successfully" };
}
default:
+13 -19
View File
@@ -1,12 +1,13 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router";
import type { ActionFunctionArgs } from "react-router";
import { useLoaderData } from "react-router";
import Code from "~/components/code";
import Notice from "~/components/notice";
import PageError from "~/components/page-error";
import type { LoadContext } from "~/server";
import { authContext, headscaleConfigContext } from "~/server/context";
import { Capabilities } from "~/server/web/roles";
import type { Route } from "./+types/overview";
import ManageDomains from "./components/manage-domains";
import ManageNS from "./components/manage-ns";
import ManageRecords from "./components/manage-records";
@@ -15,13 +16,16 @@ 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>) {
if (!context.hs.readable()) {
export async function loader({ request, context }: Route.LoaderArgs) {
const auth = context.get(authContext);
const headscaleConfig = context.get(headscaleConfigContext);
if (!headscaleConfig.readable()) {
throw new Error("No configuration is available");
}
const principal = await context.auth.require(request);
const check = context.auth.can(principal, Capabilities.read_network);
const principal = await auth.require(request);
const check = auth.can(principal, Capabilities.read_network);
if (!check) {
// Not authorized to view this page
throw new Error(
@@ -29,24 +33,14 @@ export async function loader({ request, context }: LoaderFunctionArgs<LoadContex
);
}
const writablePermission = context.auth.can(principal, Capabilities.write_network);
const writablePermission = auth.can(principal, Capabilities.write_network);
const config = context.hs.c!;
const dns = {
prefixes: config.prefixes,
magicDns: config.dns.magic_dns,
baseDomain: config.dns.base_domain,
nameservers: config.dns.nameservers.global,
splitDns: config.dns.nameservers.split,
searchDomains: config.dns.search_domains,
overrideDns: config.dns.override_local_dns,
extraRecords: context.hs.d,
};
const dns = headscaleConfig.getDNSConfig();
return {
...dns,
access: writablePermission,
writable: context.hs.writable(),
writable: headscaleConfig.writable(),
};
}
+29 -21
View File
@@ -10,7 +10,14 @@ import Card from "~/components/card";
import CodeBlock from "~/components/code-block";
import Link from "~/components/link";
import LinkAccount from "~/layout/link-account";
import {
authContext,
headscaleConfigContext,
headscaleLiveStoreContext,
requestApiContext,
} from "~/server/context";
import { usersResource } from "~/server/headscale/live-store";
import { isUserPrincipal } from "~/server/web/auth";
import { Capabilities } from "~/server/web/roles";
import cn from "~/utils/cn";
import { getUserDisplayName } from "~/utils/user";
@@ -18,24 +25,24 @@ import { getUserDisplayName } from "~/utils/user";
import type { Route } from "./+types/home";
export async function loader({ request, context }: Route.LoaderArgs) {
const principal = await context.auth.require(request);
const auth = context.get(authContext);
const getRequestApi = context.get(requestApiContext);
const headscaleConfig = context.get(headscaleConfigContext);
const headscaleLiveStore = context.get(headscaleLiveStoreContext);
// If the OIDC user has no linked Headscale user, check for
// Unclaimed users they can pick from before anything else.
const principal = await auth.require(request);
// If the signed-in Headplane user has no linked Headscale user,
// check for unclaimed users they can pick from before anything else.
let unlinked = false;
if (
typeof context.oidc === "object" &&
principal.kind === "oidc" &&
!principal.user.headscaleUserId
) {
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
if (isUserPrincipal(principal) && !principal.user.headscaleUserId) {
const { api } = await getRequestApi(request);
let headscaleUsers: { id: string; name: string }[] = [];
try {
const [usersSnap, claimed] = await Promise.all([
context.hsLive.get(usersResource, api),
context.auth.claimedHeadscaleUserIds(),
headscaleLiveStore.get(usersResource, api),
auth.claimedHeadscaleUserIds(),
]);
const apiUsers = usersSnap.data;
@@ -54,23 +61,22 @@ export async function loader({ request, context }: Route.LoaderArgs) {
// Only warn if Headscale isn't using OIDC — if it is, the user
// Just needs to connect a device and Headscale will auto-create
// Their account, at which point auto-link will pick it up.
if (!context.hs.c?.oidc) {
if (!headscaleConfig.hasOIDCConfig()) {
unlinked = true;
}
}
if (context.auth.can(principal, Capabilities.ui_access)) {
if (auth.can(principal, Capabilities.ui_access)) {
return redirect("/machines");
}
// No UI access — show the download/connect page
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
const { api } = await getRequestApi(request);
let linkedUserName: string | undefined;
if (principal.kind === "oidc" && principal.user.headscaleUserId) {
if (isUserPrincipal(principal) && principal.user.headscaleUserId) {
try {
const usersSnap = await context.hsLive.get(usersResource, api);
const usersSnap = await headscaleLiveStore.get(usersResource, api);
const hsUser = usersSnap.data.find((u) => u.id === principal.user.headscaleUserId);
linkedUserName = hsUser?.name;
} catch {
@@ -82,8 +88,10 @@ export async function loader({ request, context }: Route.LoaderArgs) {
}
export async function action({ request, context }: Route.ActionArgs) {
const principal = await context.auth.require(request);
if (principal.kind !== "oidc") {
const auth = context.get(authContext);
const principal = await auth.require(request);
if (!isUserPrincipal(principal)) {
return redirect("/");
}
@@ -91,7 +99,7 @@ export async function action({ request, context }: Route.ActionArgs) {
const headscaleUserId = formData.get("headscale_user_id")?.toString();
if (headscaleUserId) {
await context.auth.linkHeadscaleUser(principal.user.id, headscaleUserId);
await auth.linkHeadscaleUser(principal.user.id, headscaleUserId);
}
return redirect("/");
@@ -13,7 +13,7 @@ import { TailscaleSSHTag } from "~/components/tags/TailscaleSSH";
import type { User } from "~/types";
import cn from "~/utils/cn";
import * as hinfo from "~/utils/host-info";
import type { PopulatedNode } from "~/utils/node-info";
import { isNoExpiry, type PopulatedNode } from "~/utils/node-info";
import { formatTimeDelta } from "~/utils/time";
import toast from "~/utils/toast";
import { getUserDisplayName } from "~/utils/user";
@@ -156,7 +156,7 @@ export function uiTagsForNode(node: PopulatedNode, isAgent?: boolean) {
uiTags.push("expired");
}
if (node.expiry === null) {
if (!node.expired && isNoExpiry(node.expiry)) {
uiTags.push("no-expiry");
}
+17 -4
View File
@@ -12,10 +12,11 @@ import Text from "~/components/text";
import Title from "~/components/title";
import { useForm } from "~/hooks/use-form";
import type { User } from "~/types";
import { normalizeRegistrationKey } from "~/utils/register-key";
import { getUserDisplayName } from "~/utils/user";
const registerSchema = type({
register_key: "string == 24",
register_key: "string > 0",
user: "string > 0",
});
@@ -28,7 +29,16 @@ export interface NewMachineProps {
export default function NewMachine(data: NewMachineProps) {
const [pushDialog, setPushDialog] = useState(false);
const form = useForm({ schema: registerSchema });
const form = useForm({
schema: registerSchema,
validate: (values) =>
normalizeRegistrationKey(String(values.register_key ?? ""))
? undefined
: {
register_key:
"Paste the registration URL or full hskey-authreq-... key from tailscale up.",
},
});
const navigate = useNavigate();
return (
@@ -43,7 +53,8 @@ export default function NewMachine(data: NewMachineProps) {
{...form.field("register_key")}
required
label="Machine Key"
placeholder="AbCd..."
placeholder="hskey-authreq-XXXXXXXXXXXXXXXXXXXXXXXX"
description="Paste the registration URL or full key shown by tailscale up."
/>
<Select
required
@@ -52,7 +63,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),
}))}
/>
+13 -1
View File
@@ -12,6 +12,17 @@ const renameSchema = type({
name: "string > 0",
});
const dnsLabelPattern = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
function validateMachineName(values: Record<string, unknown>) {
const name = String(values.name ?? "").toLowerCase();
if (!dnsLabelPattern.test(name)) {
return {
name: "Use a valid DNS label: lowercase letters, numbers, and hyphens only. It must start and end with a letter or number.",
};
}
}
interface RenameProps {
machine: Machine;
isOpen: boolean;
@@ -23,12 +34,13 @@ export default function Rename({ machine, magic, isOpen, setIsOpen }: RenameProp
const form = useForm({
schema: renameSchema,
defaultValues: { name: machine.givenName },
validate: validateMachineName,
});
const name = form.values.name as string;
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<DialogPanel>
<DialogPanel isDisabled={!form.canSubmit}>
<Title>Edit machine name for {machine.givenName}</Title>
<Text className="mb-6">
This name is shown in the admin panel, in Tailscale clients, and used when generating
+21
View File
@@ -24,6 +24,10 @@ export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsP
const submittingRef = useRef(false);
const [tags, setTags] = useState([...machine.tags]);
const [tag, setTag] = useState("tag:");
const tagOptions = useMemo(
() => (existingTags ?? []).filter((existingTag) => !tags.includes(existingTag)),
[existingTags, tags],
);
const tagIsInvalid = useMemo(
() => tag.length === 0 || !tag.startsWith("tag:") || tags.includes(tag),
[tag, tags],
@@ -98,6 +102,7 @@ export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsP
onClick={() => {
setTags(tags.filter((tag) => tag !== item));
}}
type="button"
>
<X className="p-1" />
</Button>
@@ -124,10 +129,26 @@ export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsP
setTags([...tags, tag]);
setTag("tag:");
}}
type="button"
>
<Plus className="p-1" size={30} />
</Button>
</div>
{tagOptions.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{tagOptions.map((option) => (
<Button
className="px-2 py-1 font-mono text-xs"
key={option}
onClick={() => setTags([...tags, option])}
type="button"
variant="ghost"
>
{option}
</Button>
))}
</div>
) : null}
<p className="mt-2 text-sm opacity-50">
Not seeing the tags you expect? Tags need to be defined in your access control policy
before they can be assigned to machines.
+57 -21
View File
@@ -1,16 +1,21 @@
import { data, redirect } from "react-router";
import { authContext, headscaleLiveStoreContext, requestApiContext } from "~/server/context";
import { isDataWithApiError } from "~/server/headscale/api/error-client";
import { nodesResource } from "~/server/headscale/live-store";
import { Capabilities } from "~/server/web/roles";
import { normalizeRegistrationKey } from "~/utils/register-key";
import type { Route } from "./+types/machine";
export async function machineAction({ request, context }: Route.ActionArgs) {
const principal = await context.auth.require(request);
const auth = context.get(authContext);
const getRequestApi = context.get(requestApiContext);
const headscaleLiveStore = context.get(headscaleLiveStoreContext);
const { principal, api } = await getRequestApi(request);
const formData = await request.formData();
const api = context.hsApi.getRuntimeClient(context.auth.getHeadscaleApiKey(principal));
const action = formData.get("action_id")?.toString();
if (!action) {
@@ -21,19 +26,26 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
// Fast track register since it doesn't require an existing machine
if (action === "register") {
if (!context.auth.can(principal, Capabilities.write_machines)) {
if (!auth.can(principal, Capabilities.write_machines)) {
throw data("You do not have permission to manage machines", {
status: 403,
});
}
const registrationKey = formData.get("register_key")?.toString();
if (!registrationKey) {
const registrationKeyInput = formData.get("register_key")?.toString();
if (!registrationKeyInput) {
throw data("Missing `register_key` in the form data.", {
status: 400,
});
}
const registrationKey = normalizeRegistrationKey(registrationKeyInput);
if (!registrationKey) {
throw data("Invalid `register_key` in the form data.", {
status: 400,
});
}
const user = formData.get("user")?.toString();
if (!user) {
throw data("Missing `user` in the form data.", {
@@ -41,8 +53,8 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
});
}
const node = await api.registerNode(user, registrationKey);
await context.hsLive.refresh(nodesResource, api);
const node = await api.nodes.register(user, registrationKey);
await headscaleLiveStore.refresh(nodesResource, api);
return redirect(`/machines/${node.id}`);
}
@@ -54,14 +66,14 @@ 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,
});
}
if (!context.auth.canManageNode(principal, node)) {
if (!auth.canManageNode(principal, node)) {
throw data("You do not have permission to act on this machine", {
status: 403,
});
@@ -77,20 +89,27 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
}
const name = String(formData.get("name"));
await api.renameNode(nodeId, name);
await context.hsLive.refresh(nodesResource, api);
if (!/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(name.toLowerCase())) {
throw data(
"Machine names must be valid DNS labels: lowercase letters, numbers, and hyphens only, and must start and end with a letter or number.",
{ status: 400 },
);
}
await api.nodes.rename(nodeId, name);
await headscaleLiveStore.refresh(nodesResource, api);
return { message: "Machine renamed" };
}
case "delete": {
await api.deleteNode(nodeId);
await context.hsLive.refresh(nodesResource, api);
await api.nodes.delete(nodeId);
await headscaleLiveStore.refresh(nodesResource, api);
return redirect("/machines");
}
case "expire": {
await api.expireNode(nodeId);
await context.hsLive.refresh(nodesResource, api);
await api.nodes.expire(nodeId);
await headscaleLiveStore.refresh(nodesResource, api);
return { message: "Machine expired" };
}
@@ -103,12 +122,12 @@ 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 !== ""),
);
await context.hsLive.refresh(nodesResource, api);
await headscaleLiveStore.refresh(nodesResource, api);
return { success: true as const, message: "Tags updated" };
} catch (error) {
if (isDataWithApiError(error) && error.data.statusCode === 400) {
@@ -116,6 +135,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
{
success: false as const,
error:
extractApiErrorMessage(error.data) ??
"One or more tags are not defined in your ACL policy. Please add them to your policy before assigning them to a machine.",
},
{ status: 400 },
@@ -172,8 +192,8 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
}
}
await api.approveNodeRoutes(nodeId, newApproved);
await context.hsLive.refresh(nodesResource, api);
await api.nodes.approveRoutes(nodeId, newApproved);
await headscaleLiveStore.refresh(nodesResource, api);
return { message: "Routes updated" };
}
@@ -185,8 +205,13 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
});
}
await api.setNodeUser(nodeId, user);
await context.hsLive.refresh(nodesResource, api);
if (!api.nodes.reassignUser) {
throw data("Reassigning a node owner is no longer supported on this Headscale version.", {
status: 400,
});
}
await api.nodes.reassignUser(nodeId, user);
await headscaleLiveStore.refresh(nodesResource, api);
return { message: "Machine reassigned" };
}
@@ -196,3 +221,14 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
});
}
}
function extractApiErrorMessage(error: { data?: unknown; rawData: string }) {
if (error.data != null && typeof error.data === "object" && "message" in error.data) {
const message = (error.data as { message?: unknown }).message;
if (typeof message === "string" && message.length > 0) {
return message;
}
}
return error.rawData.length > 0 ? error.rawData : undefined;
}
+41 -21
View File
@@ -9,10 +9,17 @@ import Chip from "~/components/chip";
import Link from "~/components/link";
import StatusCircle from "~/components/status-circle";
import Tooltip from "~/components/tooltip";
import {
agentsContext,
headscaleConfigContext,
headscaleContext,
headscaleLiveStoreContext,
requestApiContext,
} from "~/server/context";
import { nodesResource, usersResource } from "~/server/headscale/live-store";
import cn from "~/utils/cn";
import { getOSInfo, getTSVersion } from "~/utils/host-info";
import { mapNodes, sortNodeTags } from "~/utils/node-info";
import { isNoExpiry, mapNodes, sortAssignableTags } from "~/utils/node-info";
import { getUserDisplayName } from "~/utils/user";
import type { Route } from "./+types/machine";
@@ -22,7 +29,12 @@ 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);
const agentsFeature = context.get(agentsContext);
const getRequestApi = context.get(requestApiContext);
const headscale = context.get(headscaleContext);
const headscaleConfig = context.get(headscaleConfigContext);
const headscaleLiveStore = context.get(headscaleLiveStoreContext);
if (!params.id) {
throw new Error("No machine ID provided");
}
@@ -31,17 +43,12 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
throw data(null, { status: 204 });
}
let magic: string | undefined;
if (context.hs.readable()) {
if (context.hs.c?.dns.magic_dns) {
magic = context.hs.c.dns.base_domain;
}
}
const magic = headscaleConfig.getMagicDNSBaseDomain();
const api = context.hsApi.getRuntimeClient(context.auth.getHeadscaleApiKey(principal));
const { api } = await getRequestApi(request);
const [nodesSnap, usersSnap] = await Promise.all([
context.hsLive.get(nodesResource, api),
context.hsLive.get(usersResource, api),
headscaleLiveStore.get(nodesResource, api),
headscaleLiveStore.get(usersResource, api),
]);
const nodes = nodesSnap.data;
const users = usersSnap.data;
@@ -50,24 +57,30 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
throw data(null, { status: 404 });
}
const lookup = await context.agents?.lookup([node.nodeKey]);
const [enhancedNode] = mapNodes([node], lookup);
const agents = agentsFeature.state === "enabled" ? agentsFeature.value : undefined;
const [lookup, policyResult] = await Promise.allSettled([
agents?.lookup([node.nodeKey]),
api.policy.get(),
]);
const stats = lookup.status === "fulfilled" ? lookup.value : undefined;
const [enhancedNode] = mapNodes([node], stats);
const tags = [...node.tags].toSorted();
const supportsNodeOwnerChange = !context.hsApi.clientHelpers.isAtleast("0.28.0");
const agentSync = context.agents?.lastSync();
const supportsNodeOwnerChange = !headscale.capabilities.nodeOwnerIsImmutable;
const agentSync = agents?.lastSync();
const policy = policyResult.status === "fulfilled" ? policyResult.value.policy : undefined;
return {
agent: agentSync
? {
syncedAt: agentSync.syncedAt?.toISOString() ?? null,
nodeCount: agentSync.nodeCount,
nodeKey: context.agents?.agentNodeKey(),
nodeKey: agents?.agentNodeKey(),
}
: undefined,
existingTags: sortNodeTags(nodes),
existingTags: sortAssignableTags(nodes, policy),
magic,
node: enhancedNode,
stats: lookup?.[enhancedNode.nodeKey],
stats: stats?.[enhancedNode.nodeKey],
supportsNodeOwnerChange: supportsNodeOwnerChange,
tags,
users,
@@ -281,14 +294,16 @@ export default function Page({
/>
<Attribute
name="Key expiry"
value={node.expiry !== null ? new Date(node.expiry).toLocaleString() : "Never"}
value={!isNoExpiry(node.expiry) ? new Date(node.expiry!).toLocaleString() : "Never"}
/>
{magic ? (
<Attribute isCopyable name="Domain" value={`${node.givenName}.${magic}`} />
) : undefined}
</div>
<div className="flex flex-col gap-1">
<p className="text-sm font-semibold uppercase opacity-75">Addresses</p>
<p className="text-sm font-semibold text-mist-600 uppercase dark:text-mist-300">
Addresses
</p>
<Attribute
isCopyable
name="Tailscale IPv4"
@@ -315,9 +330,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."
+42 -22
View File
@@ -7,10 +7,20 @@ import Input from "~/components/input";
import Link from "~/components/link";
import PageError from "~/components/page-error";
import Tooltip from "~/components/tooltip";
import {
agentsContext,
appConfigContext,
authContext,
headscaleConfigContext,
headscaleContext,
headscaleLiveStoreContext,
requestApiContext,
} from "~/server/context";
import { nodesResource, usersResource } from "~/server/headscale/live-store";
import { isUserPrincipal } from "~/server/web/auth";
import { Capabilities } from "~/server/web/roles";
import cn from "~/utils/cn";
import { mapNodes, sortNodeTags, type PopulatedNode } from "~/utils/node-info";
import { mapNodes, sortAssignableTags, type PopulatedNode } from "~/utils/node-info";
import type { Route } from "./+types/overview";
import { MachineFilters } from "./components/machine-filters";
@@ -20,51 +30,61 @@ import { useMachineFilterParams } from "./hooks/use-machine-filter-params";
import { machineAction } from "./machine-actions";
export async function loader({ request, context }: Route.LoaderArgs) {
const principal = await context.auth.require(request);
const agentsFeature = context.get(agentsContext);
const auth = context.get(authContext);
const config = context.get(appConfigContext);
const getRequestApi = context.get(requestApiContext);
const headscale = context.get(headscaleContext);
const headscaleConfig = context.get(headscaleConfigContext);
const headscaleLiveStore = context.get(headscaleLiveStoreContext);
if (!context.auth.can(principal, Capabilities.read_machines)) {
const principal = await auth.require(request);
if (!auth.can(principal, Capabilities.read_machines)) {
throw new Error(
"You do not have permission to view this page. Please contact your administrator.",
);
}
const writablePermission = context.auth.can(principal, Capabilities.write_machines);
const writablePermission = auth.can(principal, Capabilities.write_machines);
const api = context.hsApi.getRuntimeClient(context.auth.getHeadscaleApiKey(principal));
const { api } = await getRequestApi(request);
const [nodesSnap, usersSnap] = await Promise.all([
context.hsLive.get(nodesResource, api),
context.hsLive.get(usersResource, api),
headscaleLiveStore.get(nodesResource, api),
headscaleLiveStore.get(usersResource, api),
]);
const nodes = nodesSnap.data;
const users = usersSnap.data;
let magic: string | undefined;
if (context.hs.readable()) {
if (context.hs.c?.dns.magic_dns) {
magic = context.hs.c.dns.base_domain;
}
}
const magic = headscaleConfig.getMagicDNSBaseDomain();
const stats = await context.agents?.lookup(nodes.map((node) => node.nodeKey));
const agents = agentsFeature.state === "enabled" ? agentsFeature.value : undefined;
const [statsResult, policyResult] = await Promise.allSettled([
agents?.lookup(nodes.map((node) => node.nodeKey)),
api.policy.get(),
]);
const stats = statsResult.status === "fulfilled" ? statsResult.value : undefined;
const policy = policyResult.status === "fulfilled" ? policyResult.value.policy : undefined;
const populatedNodes = mapNodes(nodes, stats);
const supportsNodeOwnerChange = !context.hsApi.clientHelpers.isAtleast("0.28.0");
const agentSync = context.agents?.lastSync();
const supportsNodeOwnerChange = !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,
headscaleUserId: isUserPrincipal(principal) ? principal.user.headscaleUserId : undefined,
existingTags: sortAssignableTags(nodes, policy),
magic,
nodes,
populatedNodes,
preAuth: context.auth.can(principal, Capabilities.generate_authkeys),
publicServer: context.config.headscale.public_url,
server: context.config.headscale.url,
preAuth: auth.can(principal, Capabilities.generate_authkeys),
publicServer: config.headscale.public_url,
server: config.headscale.url,
supportsNodeOwnerChange: supportsNodeOwnerChange,
users,
writable: writablePermission,
@@ -422,7 +442,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
) : (
filteredAndSortedNodes.map((node) => (
<MachineRow
existingTags={sortNodeTags(loaderData.nodes)}
existingTags={loaderData.existingTags}
isAgent={
loaderData.agent !== undefined
? node.nodeKey === loaderData.agent.nodeKey
+40 -14
View File
@@ -6,36 +6,48 @@ import Notice from "~/components/notice";
import StatusCircle from "~/components/status-circle";
import Text from "~/components/text";
import Title from "~/components/title";
import { agentsContext, authContext } from "~/server/context";
import { formatTimeDelta } from "~/utils/time";
import type { Route } from "./+types/agent";
export async function loader({ request, context }: Route.LoaderArgs) {
const principal = await context.auth.require(request);
const agents = context.get(agentsContext);
const auth = context.get(authContext);
if (!context.agents) {
return { enabled: false as const };
await auth.require(request);
if (agents.state !== "enabled") {
return { enabled: false as const, reason: agents.reason };
}
const sync = context.agents.lastSync();
const sync = agents.value.lastSync();
return {
enabled: true as const,
syncedAt: sync.syncedAt?.toISOString() ?? null,
nodeCount: sync.nodeCount,
error: sync.error,
authUrl: sync.authUrl,
};
}
export async function action({ request, context }: Route.ActionArgs) {
await context.auth.require(request);
const agents = context.get(agentsContext);
const auth = context.get(authContext);
if (!context.agents) {
return { success: false, error: "Agent is not enabled" };
await auth.require(request);
if (agents.state !== "enabled") {
return { success: false, error: agents.reason };
}
await context.agents.triggerSync();
const sync = context.agents.lastSync();
return { success: !sync.error, error: sync.error };
await agents.value.triggerSync();
const sync = agents.value.lastSync();
return {
success: !sync.error,
error: sync.error,
authUrl: sync.authUrl,
};
}
export default function Page({ loaderData }: Route.ComponentProps) {
@@ -47,8 +59,8 @@ export default function Page({ loaderData }: Route.ComponentProps) {
<div className="flex max-w-(--breakpoint-lg) flex-col gap-8">
<Title>Headplane Agent</Title>
<Notice title="Agent Not Enabled">
The Headplane Agent is not enabled. To learn how to set up the agent, visit the{" "}
<Link external styled to="https://headplane.dev/docs/agent">
{loaderData.reason}. To learn how to set up the agent, visit the{" "}
<Link external styled to="https://headplane.net/features/agent">
documentation
</Link>
</Notice>
@@ -56,6 +68,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
);
}
const isPending = !loaderData.syncedAt && loaderData.authUrl;
const hasError = Boolean(loaderData.error);
return (
@@ -69,8 +82,10 @@ export default function Page({ loaderData }: Route.ComponentProps) {
</div>
<div className="flex items-center gap-3">
<StatusCircle isOnline={!hasError} className="h-5 w-5" />
<span className="text-lg font-medium">{hasError ? "Error" : "Healthy"}</span>
<StatusCircle isOnline={!hasError && !isPending} className="h-5 w-5" />
<span className="text-lg font-medium">
{hasError ? "Error" : isPending ? "Waiting for approval" : "Healthy"}
</span>
</div>
<div className="flex flex-col gap-2">
@@ -88,6 +103,17 @@ export default function Page({ loaderData }: Route.ComponentProps) {
</Text>
</div>
{isPending ? (
<Notice variant="warning" title="Agent Needs Approval">
The agent is waiting for its Tailnet registration to be approved. Headplane will attempt
to auto-approve it, but if that fails, you can complete approval by visiting{" "}
<Link external styled to={loaderData.authUrl!}>
this link
</Link>
.
</Notice>
) : undefined}
{loaderData.error ? (
<Notice variant="error" title="Sync Error">
{loaderData.error}
+28 -14
View File
@@ -1,5 +1,7 @@
import { data } from "react-router";
import { authContext, requestApiContext } from "~/server/context";
import { isUserPrincipal } from "~/server/web/auth";
import { getOidcSubject } from "~/server/web/headscale-identity";
import { Capabilities } from "~/server/web/roles";
import type { PreAuthKey } from "~/types";
@@ -7,12 +9,13 @@ 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);
const api = context.hsApi.getRuntimeClient(apiKey);
const auth = context.get(authContext);
const getRequestApi = context.get(requestApiContext);
const canGenerateAny = context.auth.can(principal, Capabilities.generate_authkeys);
const canGenerateOwn = context.auth.can(principal, Capabilities.generate_own_authkeys);
const { principal, api } = await getRequestApi(request);
const canGenerateAny = auth.can(principal, Capabilities.generate_authkeys);
const canGenerateOwn = auth.can(principal, Capabilities.generate_own_authkeys);
if (!canGenerateAny && !canGenerateOwn) {
throw data("You do not have permission to manage pre-auth keys", {
@@ -22,12 +25,15 @@ 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 });
}
const targetSubject = getOidcSubject(targetUser);
if (principal.kind !== "oidc" || targetSubject !== principal.user.subject) {
const ownsTarget =
isUserPrincipal(principal) &&
(principal.user.headscaleUserId === userId || targetSubject === principal.user.subject);
if (!ownsTarget) {
throw data("You do not have permission to manage this user's pre-auth keys", {
status: 403,
});
@@ -86,13 +92,13 @@ 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 });
}
@@ -114,7 +120,15 @@ export async function authKeysAction({ request, context }: Route.ActionArgs) {
}
await checkSelfServiceOwnership(user);
await api.expirePreAuthKey(user, { id: keyId, key } as unknown as PreAuthKey);
// `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");
}
@@ -18,10 +18,22 @@ interface AddAuthKeyProps {
users: User[];
url: string;
selfServiceOnly: boolean;
currentHeadscaleUserId?: string;
currentSubject?: string;
}
function findCurrentUser(users: User[], subject: string | undefined): User | undefined {
function findCurrentUser(
users: User[],
headscaleUserId: string | undefined,
subject: string | undefined,
): User | undefined {
if (headscaleUserId) {
const linked = users.find((u) => u.id === headscaleUserId);
if (linked) {
return linked;
}
}
if (!subject) {
return undefined;
}
@@ -38,6 +50,7 @@ export default function AddAuthKey({
users,
url,
selfServiceOnly,
currentHeadscaleUserId,
currentSubject,
}: AddAuthKeyProps) {
const fetcher = useFetcher();
@@ -46,7 +59,9 @@ export default function AddAuthKey({
const [reusable, setReusable] = useState(false);
const [ephemeral, setEphemeral] = useState(false);
const [tagOnly, setTagOnly] = useState(false);
const currentUser = selfServiceOnly ? findCurrentUser(users, currentSubject) : null;
const currentUser = selfServiceOnly
? findCurrentUser(users, currentHeadscaleUserId, currentSubject)
: null;
const availableUsers = selfServiceOnly && currentUser ? [currentUser] : users;
const [userId, setUserId] = useState<string | null>(availableUsers[0]?.id);
const [tags, setTags] = useState("");
+37 -14
View File
@@ -6,7 +6,14 @@ import Link from "~/components/link";
import Notice from "~/components/notice";
import Select from "~/components/select";
import TableList from "~/components/table-list";
import {
appConfigContext,
authContext,
headscaleLiveStoreContext,
requestApiContext,
} from "~/server/context";
import { usersResource } from "~/server/headscale/live-store";
import { isUserPrincipal } from "~/server/web/auth";
import { Capabilities } from "~/server/web/roles";
import type { PreAuthKey } from "~/types";
import type { User } from "~/types/User";
@@ -19,11 +26,14 @@ 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);
const api = context.hsApi.getRuntimeClient(apiKey);
const auth = context.get(authContext);
const config = context.get(appConfigContext);
const getRequestApi = context.get(requestApiContext);
const headscaleLiveStore = context.get(headscaleLiveStoreContext);
const usersSnap = await context.hsLive.get(usersResource, api);
const { principal, api } = await getRequestApi(request);
const usersSnap = await headscaleLiveStore.get(usersResource, api);
const users = usersSnap.data;
let keys: { user: User | null; preAuthKeys: PreAuthKey[] }[];
@@ -31,10 +41,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 +79,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);
@@ -85,16 +97,17 @@ export async function loader({ request, context }: Route.LoaderArgs) {
.map(({ user, error }) => ({ error, user }));
}
const canGenerateAny = context.auth.can(principal, Capabilities.generate_authkeys);
const canGenerateOwn = context.auth.can(principal, Capabilities.generate_own_authkeys);
const canGenerateAny = auth.can(principal, Capabilities.generate_authkeys);
const canGenerateOwn = auth.can(principal, Capabilities.generate_own_authkeys);
return {
access: canGenerateAny || canGenerateOwn,
currentSubject: principal.kind === "oidc" ? principal.user.subject : undefined,
currentHeadscaleUserId: isUserPrincipal(principal) ? principal.user.headscaleUserId : undefined,
currentSubject: isUserPrincipal(principal) ? principal.user.subject : undefined,
keys,
missing,
selfServiceOnly: !canGenerateAny && canGenerateOwn,
url: context.config.headscale.public_url ?? context.config.headscale.url,
url: config.headscale.public_url ?? config.headscale.url,
users,
};
}
@@ -103,7 +116,16 @@ export const action = authKeysAction;
type Status = "all" | "active" | "expired" | "reusable" | "ephemeral";
export default function Page({
loaderData: { keys, missing, users, url, access, selfServiceOnly, currentSubject },
loaderData: {
keys,
missing,
users,
url,
access,
selfServiceOnly,
currentHeadscaleUserId,
currentSubject,
},
}: Route.ComponentProps) {
const [selectedUser, setSelectedUser] = useState("__headplane_all");
const [status, setStatus] = useState<Status>("active");
@@ -199,6 +221,7 @@ export default function Page({
</Link>
</p>
<AddAuthKey
currentHeadscaleUserId={currentHeadscaleUserId}
currentSubject={currentSubject}
selfServiceOnly={selfServiceOnly}
url={url}
+6 -2
View File
@@ -2,13 +2,17 @@ import { ArrowRight } from "lucide-react";
import Link from "~/components/link";
import PageError from "~/components/page-error";
import { headscaleConfigContext, oidcContext } from "~/server/context";
import type { Route } from "./+types/overview";
export async function loader({ context }: Route.LoaderArgs) {
const headscaleConfig = context.get(headscaleConfigContext);
const oidc = context.get(oidcContext);
return {
config: context.hs.writable(),
isOidcEnabled: context.oidc?.service.status().state === "ready",
config: headscaleConfig.writable(),
isOidcEnabled: oidc.state === "enabled" && oidc.value.status().state === "ready",
};
}
+36 -23
View File
@@ -1,12 +1,23 @@
import { data } from "react-router";
import {
authContext,
headscaleConfigContext,
headscaleContext,
integrationContext,
} from "~/server/context";
import { Capabilities } from "~/server/web/roles";
import type { Route } from "./+types/overview";
export async function restrictionAction({ request, context }: Route.ActionArgs) {
const principal = await context.auth.require(request);
const check = context.auth.can(principal, Capabilities.configure_iam);
const auth = context.get(authContext);
const headscale = context.get(headscaleContext);
const headscaleConfig = context.get(headscaleConfigContext);
const integration = context.get(integrationContext);
const principal = await auth.require(request);
const check = auth.can(principal, Capabilities.configure_iam);
if (!check) {
throw data("You do not have permission to modify IAM settings.", {
@@ -14,7 +25,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
});
}
if (!context.hs.writable()) {
if (!headscaleConfig.writable()) {
throw data("The Headscale configuration file is not editable.", {
status: 403,
});
@@ -28,8 +39,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();
@@ -39,16 +48,18 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
});
}
const domains = [...new Set([...(context.hs.c?.oidc?.allowed_domains ?? []), domain])];
const domains = [
...new Set([...(headscaleConfig.getOIDCConfig()?.allowedDomains ?? []), domain]),
];
await context.hs.patch([
await headscaleConfig.patch([
{
path: "oidc.allowed_domains",
value: domains,
},
]);
context.integration?.onConfigChange(api);
integration?.onConfigChange(headscale);
return data("Domain added successfully.");
}
@@ -60,7 +71,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
});
}
const storedDomains = context.hs.c?.oidc?.allowed_domains ?? [];
const storedDomains = headscaleConfig.getOIDCConfig()?.allowedDomains ?? [];
if (!storedDomains.includes(domain)) {
// Domain not found in the list
throw data(`Domain "${domain}" not found in allowed domains.`, {
@@ -70,13 +81,13 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
// Filter out the domain to remove it from the list
const domains = storedDomains.filter((d: string) => d !== domain);
await context.hs.patch([
await headscaleConfig.patch([
{
path: "oidc.allowed_domains",
value: domains,
},
]);
context.integration?.onConfigChange(api);
integration?.onConfigChange(headscale);
return data("Domain removed successfully.");
}
@@ -88,16 +99,18 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
});
}
const groups = [...new Set([...(context.hs.c?.oidc?.allowed_groups ?? []), group])];
const groups = [
...new Set([...(headscaleConfig.getOIDCConfig()?.allowedGroups ?? []), group]),
];
await context.hs.patch([
await headscaleConfig.patch([
{
path: "oidc.allowed_groups",
value: groups,
},
]);
context.integration?.onConfigChange(api);
integration?.onConfigChange(headscale);
return data("Group added successfully.");
}
@@ -109,7 +122,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
});
}
const storedGroups = context.hs.c?.oidc?.allowed_groups ?? [];
const storedGroups = headscaleConfig.getOIDCConfig()?.allowedGroups ?? [];
if (!storedGroups.includes(group)) {
// Group not found in the list
throw data(`Group "${group}" not found in allowed groups.`, {
@@ -119,14 +132,14 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
// Filter out the group to remove it from the list
const groups = storedGroups.filter((d: string) => d !== group);
await context.hs.patch([
await headscaleConfig.patch([
{
path: "oidc.allowed_groups",
value: groups,
},
]);
context.integration?.onConfigChange(api);
integration?.onConfigChange(headscale);
return data("Group removed successfully.");
}
@@ -138,16 +151,16 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
});
}
const users = [...new Set([...(context.hs.c?.oidc?.allowed_users ?? []), user])];
const users = [...new Set([...(headscaleConfig.getOIDCConfig()?.allowedUsers ?? []), user])];
await context.hs.patch([
await headscaleConfig.patch([
{
path: "oidc.allowed_users",
value: users,
},
]);
context.integration?.onConfigChange(api);
integration?.onConfigChange(headscale);
return data("User added successfully.");
}
@@ -159,7 +172,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
});
}
const storedUsers = context.hs.c?.oidc?.allowed_users ?? [];
const storedUsers = headscaleConfig.getOIDCConfig()?.allowedUsers ?? [];
if (!storedUsers.includes(user)) {
// User not found in the list
throw data(`User "${user}" not found in allowed users.`, {
@@ -169,14 +182,14 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
// Filter out the user to remove it from the list
const users = storedUsers.filter((d: string) => d !== user);
await context.hs.patch([
await headscaleConfig.patch([
{
path: "oidc.allowed_users",
value: users,
},
]);
context.integration?.onConfigChange(api);
integration?.onConfigChange(headscale);
return data("User removed successfully.");
}
+13 -8
View File
@@ -2,6 +2,7 @@ import { data } from "react-router";
import Link from "~/components/link";
import Notice from "~/components/notice";
import { authContext, headscaleConfigContext } from "~/server/context";
import { Capabilities } from "~/server/web/roles";
import type { Route } from "./+types/overview";
@@ -12,28 +13,32 @@ import AddUser from "./dialogs/add-user";
import RestrictionTable from "./table";
export async function loader({ request, context }: Route.LoaderArgs) {
const principal = await context.auth.require(request);
const check = context.auth.can(principal, Capabilities.read_users);
const auth = context.get(authContext);
const headscaleConfig = context.get(headscaleConfigContext);
const principal = await auth.require(request);
const check = auth.can(principal, Capabilities.read_users);
if (!check) {
throw data("You do not have permission to view IAM settings.", {
status: 403,
});
}
if (!context.hs.c?.oidc) {
const oidc = headscaleConfig.getOIDCConfig();
if (!oidc) {
throw data("OIDC is not configured on this Headscale instance.", {
status: 501,
});
}
return {
access: context.auth.can(principal, Capabilities.configure_iam),
access: auth.can(principal, Capabilities.configure_iam),
settings: {
domains: [...new Set(context.hs.c.oidc.allowed_domains)],
groups: [...new Set(context.hs.c.oidc.allowed_groups)],
users: [...new Set(context.hs.c.oidc.allowed_users)],
domains: [...new Set(oidc.allowedDomains)],
groups: [...new Set(oidc.allowedGroups)],
users: [...new Set(oidc.allowedUsers)],
},
writable: context.hs.writable(),
writable: headscaleConfig.writable(),
};
}
+112 -50
View File
@@ -1,11 +1,17 @@
import { Loader2, WifiOff } from "lucide-react";
import { useEffect, useState } from "react";
import { data, isRouteErrorResponse, type ShouldRevalidateFunction } from "react-router";
import { ExternalScriptsHandle } from "remix-utils/external-scripts";
import Button from "~/components/button";
import Card from "~/components/card";
import Code from "~/components/code";
import StatusBanner from "~/components/status-banner";
import {
agentsContext,
appConfigContext,
headscaleContext,
requestApiContext,
} from "~/server/context";
import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity";
import type { Route } from "./+types/page";
@@ -17,13 +23,20 @@ import { loadHeadplaneWASM } from "./wasm.client";
const WASM_MODULE_URL = `${__PREFIX__}/hp_ssh.wasm`;
const WASM_HELPER_URL = `${__PREFIX__}/wasm_exec.js`;
const SSH_PREAUTH_KEY_TTL_MS = 10 * 60 * 1000;
export const shouldRevalidate: ShouldRevalidateFunction = () => {
return false;
};
export async function loader({ request, params, context }: Route.LoaderArgs) {
const origin = new URL(request.url).origin;
export async function loader({ request, params, context, url }: Route.LoaderArgs) {
const agents = context.get(agentsContext);
const config = context.get(appConfigContext);
const headscale = context.get(headscaleContext);
const getRequestApi = context.get(requestApiContext);
const compatibilityWarning = getBrowserSSHCompatibilityWarning(headscale.version);
const origin = url.origin;
const assets = [WASM_HELPER_URL, WASM_MODULE_URL];
const missing: string[] = [];
@@ -38,52 +51,57 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
throw data(sshErrors.wasm_missing, 405);
}
if (context.agents == null) {
if (agents.state !== "enabled") {
throw data(sshErrors.agent_required, 400);
}
const principal = await context.auth.require(request);
const { principal, api } = await getRequestApi(request);
if (principal.kind === "api_key") {
throw data(sshErrors.oidc_required, 403);
}
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
const hostname = params.id;
const username = new URL(request.url).searchParams.get("user") || undefined;
const username = url.searchParams.get("user") || undefined;
const nodes = await api.getNodes();
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 };
return { hostname, username, offline: true, node: undefined, compatibilityWarning };
}
if (!username) {
return { hostname, username: undefined, offline: false, node: undefined };
return {
hostname,
username: undefined,
offline: false,
node: undefined,
compatibilityWarning,
};
}
// The user must exist within Headscale to generate a pre-auth key
const users = await api.getUsers();
const hsUser = findHeadscaleUserBySubject(users, principal.user.subject, principal.profile.email);
const users = await api.users.list();
const hsUser = principal.user.headscaleUserId
? users.find((u) => u.id === principal.user.headscaleUserId)
: findHeadscaleUserBySubject(users, principal.user.subject, principal.profile.email);
if (!hsUser) {
throw data(sshErrors.user_not_linked, 404);
}
const preAuthKey = await api.createPreAuthKey(
hsUser.id,
true,
false,
new Date(Date.now() + 60 * 1000), // 1 minute expiry
null,
);
const preAuthKey = await api.preAuthKeys.create({
user: hsUser.id,
ephemeral: true,
reusable: false,
expiration: new Date(Date.now() + SSH_PREAUTH_KEY_TTL_MS),
aclTags: null,
});
const controlURL = context.config.headscale.public_url ?? context.config.headscale.url;
const controlURL = config.headscale.public_url ?? config.headscale.url;
return {
hostname,
username,
@@ -94,9 +112,24 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
preAuthKey: preAuthKey.key,
ephemeralHostname: generateHostname(username),
},
compatibilityWarning,
};
}
function getBrowserSSHCompatibilityWarning(version: {
unknown: boolean;
major: number;
minor: number;
patch: number;
raw: string;
}) {
if (version.unknown) return null;
if (version.major === 0 && version.minor === 29 && version.patch < 2) {
return { version: version.raw };
}
return null;
}
function generateHostname(username: string) {
const hex = crypto.randomUUID().replace(/-/g, "").slice(0, 8);
return `ssh-${hex}-${username}`;
@@ -112,43 +145,67 @@ export const links: Route.LinksFunction = () => [
},
];
export const handle: ExternalScriptsHandle = {
scripts: [
{
src: WASM_HELPER_URL,
crossOrigin: "anonymous",
preload: true,
},
],
};
export default function Page({ loaderData }: Route.ComponentProps) {
const { hostname, username, offline, node } = loaderData;
const { hostname, username, offline, node, compatibilityWarning } = 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>
<>
<BrowserSSHCompatibilityBanner warning={compatibilityWarning} />
<div className="flex h-screen w-screen items-center justify-center bg-black">
<Card className="w-screen" variant="flat">
<div className="flex items-center justify-between gap-4">
<Card.Title>Node Offline</Card.Title>
<WifiOff className="mb-2 h-6 w-6 text-red-500" />
</div>
<Card.Text>
<Code>{hostname}</Code> is not currently connected to the Tailnet.
</Card.Text>
<Button className="mt-8 w-full" onClick={() => window.location.reload()}>
Retry Connection
</Button>
</Card>
</div>
</>
);
}
if (!username || !node) {
return <UserPrompt hostname={hostname} />;
return (
<>
<BrowserSSHCompatibilityBanner warning={compatibilityWarning} />
<UserPrompt hostname={hostname} />
</>
);
}
return <SSHConsole hostname={hostname} username={username} node={node} />;
return (
<>
<BrowserSSHCompatibilityBanner warning={compatibilityWarning} />
<SSHConsole hostname={hostname} username={username} node={node} />
</>
);
}
function BrowserSSHCompatibilityBanner({
warning,
}: {
warning: { version: string } | null | undefined;
}) {
if (!warning) return null;
return (
<div className="fixed inset-x-4 top-4 z-[60] mx-auto max-w-2xl">
<StatusBanner
variant="warning"
title={`Browser SSH is broken on Headscale ${warning.version}`}
>
Headscale 0.29 beta releases through 0.29.1 reject Tailscale's browser/WASM{" "}
<Code>/ts2021</Code> WebSocket request with <Code>405 Method Not Allowed</Code>. Upgrade
Headscale to 0.29.2 or newer, or use Headscale 0.28.x.
</StatusBanner>
</div>
);
}
function SSHConsole({
@@ -187,7 +244,12 @@ function SSHConsole({
setSsh(instance);
}
},
onError: (msg) => console.error("[ssh] IPN error:", msg),
onError: (msg) => {
console.error("[ssh] IPN error:", msg);
if (!cancelled) {
setStatus(`Failed to join Tailnet: ${msg}`);
}
},
});
console.log("[ssh] IPN instance created", instance);
+19 -1
View File
@@ -1,4 +1,5 @@
const WASM_MODULE_URL = `${__PREFIX__}/hp_ssh.wasm`;
const WASM_HELPER_URL = `${__PREFIX__}/wasm_exec.js`;
declare global {
type HeadplaneSSHFactory = (config: HeadplaneSSHConfig) => HeadplaneSSH;
@@ -44,12 +45,29 @@ export interface TunnelSession {
let resolvedFactory: Promise<HeadplaneSSHFactory> | null = null;
function loadGoHelper(): Promise<void> {
if (typeof globalThis.Go !== "undefined") {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = WASM_HELPER_URL;
script.crossOrigin = "anonymous";
script.onload = () => resolve();
script.onerror = () => reject(new Error("Failed to load Go WASM helper"));
document.head.appendChild(script);
});
}
/**
* One-shot function that loads the Go WASM binary and returns the SSH factory.
* It expects the Go WASM helper to be loaded, and will error if called before.
* Automatically loads the Go JS helper if it hasn't been loaded yet.
*/
export async function loadHeadplaneWASM(): Promise<HeadplaneSSHFactory> {
if (!resolvedFactory) {
await loadGoHelper();
const go = new Go();
const result = await WebAssembly.instantiateStreaming(fetch(WASM_MODULE_URL), go.importObject);
@@ -27,6 +27,11 @@ export default function HeadplaneUserRow({
);
const displayName = user.linkedHeadscaleUser?.displayName || user.name || user.email || user.sub;
const displayUsername =
user.linkedHeadscaleUser?.displayName &&
user.linkedHeadscaleUser.displayName !== user.linkedHeadscaleUser.name
? user.linkedHeadscaleUser.name
: undefined;
const displayEmail = user.linkedHeadscaleUser?.email ?? user.email;
return (
@@ -40,6 +45,7 @@ export default function HeadplaneUserRow({
)}
<div className="ml-4">
<p className="leading-snug font-semibold">{displayName}</p>
{displayUsername && <p className="text-sm opacity-50">{displayUsername}</p>}
{displayEmail && <p className="text-sm opacity-50">{displayEmail}</p>}
{!user.headscaleUserId && (
<p className="text-xs text-amber-600 dark:text-amber-400">Not linked</p>
@@ -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,33 +4,35 @@ 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()),
0,
);
const displayName = user.displayName || user.name;
const displayUsername =
user.displayName && user.displayName !== user.name ? user.name : undefined;
return (
<tr className="group hover:bg-mist-50 dark:hover:bg-mist-950" key={user.id}>
<td className="py-2 pl-0.5">
<div className="flex items-center">
{user.profilePicUrl ? (
<img
alt={user.name || user.displayName}
className="h-10 w-10 rounded-full"
src={user.profilePicUrl}
/>
<img alt={displayName} className="h-10 w-10 rounded-full" src={user.profilePicUrl} />
) : (
<CircleUser className="h-10 w-10" />
)}
<div className="ml-4">
<p className="leading-snug font-semibold">{user.name || user.displayName}</p>
<p className="leading-snug font-semibold">{displayName}</p>
{displayUsername && <p className="text-sm opacity-50">{displayUsername}</p>}
{user.email && <p className="text-sm opacity-50">{user.email}</p>}
</div>
</div>
@@ -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>
);
+27 -19
View File
@@ -1,7 +1,15 @@
import { createHash } from "node:crypto";
import PageError from "~/components/page-error";
import {
appConfigContext,
authContext,
headscaleConfigContext,
headscaleLiveStoreContext,
requestApiContext,
} from "~/server/context";
import { nodesResource, usersResource } from "~/server/headscale/live-store";
import { isUserPrincipal } from "~/server/web/auth";
import { Capabilities, Roles } from "~/server/web/roles";
import type { Role } from "~/server/web/roles";
import type { Machine, User } from "~/types";
@@ -35,18 +43,24 @@ export interface UnlinkedHeadscaleUser extends User {
}
export async function loader({ request, context }: Route.LoaderArgs) {
const principal = await context.auth.require(request);
const check = await context.auth.can(principal, Capabilities.read_users);
const auth = context.get(authContext);
const config = context.get(appConfigContext);
const getRequestApi = context.get(requestApiContext);
const headscaleConfig = context.get(headscaleConfigContext);
const headscaleLiveStore = context.get(headscaleLiveStoreContext);
const principal = await auth.require(request);
const check = await auth.can(principal, Capabilities.read_users);
if (!check) {
throw new Error(
"You do not have permission to view this page. Please contact your administrator.",
);
}
const writablePermission = await context.auth.can(principal, Capabilities.write_users);
const writablePermission = await auth.can(principal, Capabilities.write_users);
// Primary data: Headplane users from the database (always available)
const hpUsers = await context.auth.listUsers();
const hpUsers = await auth.listUsers();
// Secondary data: Headscale API (may fail)
let apiUsers: User[] = [];
@@ -54,11 +68,10 @@ export async function loader({ request, context }: Route.LoaderArgs) {
let apiError: string | undefined;
try {
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
const { api } = await getRequestApi(request);
const [nodesSnap, usersSnap] = await Promise.all([
context.hsLive.get(nodesResource, api),
context.hsLive.get(usersResource, api),
headscaleLiveStore.get(nodesResource, api),
headscaleLiveStore.get(usersResource, api),
]);
nodes = nodesSnap.data;
apiUsers = usersSnap.data;
@@ -68,7 +81,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
"Could not connect to the Headscale API. Headscale user data and machine information are unavailable.";
}
const useGravatar = context.config.oidc?.profile_picture_source === "gravatar";
const useGravatar = config.oidc?.profile_picture_source === "gravatar";
function resolveProfilePic(email?: string, profilePicUrl?: string): string | undefined {
if (!useGravatar) return profilePicUrl;
@@ -125,20 +138,15 @@ export async function loader({ request, context }: Route.LoaderArgs) {
claimed: claimedIds.has(u.id),
}));
let magic: string | undefined;
if (context.hs.readable()) {
if (context.hs.c?.dns.magic_dns) {
magic = context.hs.c.dns.base_domain;
}
}
const magic = headscaleConfig.getMagicDNSBaseDomain();
const isOwner = principal.kind === "oidc" && principal.user.role === "owner";
const isOwner = isUserPrincipal(principal) && principal.user.role === "owner";
return {
writable: writablePermission,
currentUserId: principal.kind === "oidc" ? principal.user.id : undefined,
currentUserId: isUserPrincipal(principal) ? principal.user.id : undefined,
isOwner,
oidc: context.config.oidc ? { issuer: context.config.oidc.issuer } : undefined,
oidc: config.oidc ? { issuer: config.oidc.issuer } : undefined,
magic,
apiError,
headplaneUsers,
@@ -235,7 +243,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>
+36 -68
View File
@@ -1,15 +1,20 @@
import { data } from "react-router";
import { authContext, headscaleLiveStoreContext, requestApiContext } from "~/server/context";
import { usersResource } from "~/server/headscale/live-store";
import { getOidcSubject } from "~/server/web/headscale-identity";
import { isUserPrincipal } from "~/server/web/auth";
import { Capabilities } from "~/server/web/roles";
import type { Role } from "~/server/web/roles";
import type { Route } from "./+types/overview";
export async function userAction({ request, context }: Route.ActionArgs) {
const principal = await context.auth.require(request);
const check = await context.auth.can(principal, Capabilities.write_users);
const auth = context.get(authContext);
const getRequestApi = context.get(requestApiContext);
const headscaleLiveStore = context.get(headscaleLiveStoreContext);
const principal = await auth.require(request);
const check = await auth.can(principal, Capabilities.write_users);
if (!check) {
throw data("You do not have permission to update users", {
status: 403,
@@ -24,8 +29,7 @@ export async function userAction({ request, context }: Route.ActionArgs) {
});
}
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
const { api } = await getRequestApi(request);
switch (action) {
case "create_user": {
const name = formData.get("username")?.toString();
@@ -38,33 +42,33 @@ export async function userAction({ request, context }: Route.ActionArgs) {
});
}
await api.createUser(name, email, displayName);
await context.hsLive.refresh(usersResource, api);
await api.users.create({ name, email, displayName });
await headscaleLiveStore.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 context.hsLive.refresh(usersResource, api);
await api.users.delete(headscaleUserId);
await headscaleLiveStore.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 +78,20 @@ export async function userAction({ request, context }: Route.ActionArgs) {
});
}
await api.renameUser(userId, newName);
await context.hsLive.refresh(usersResource, api);
await api.users.rename(headscaleUserId, newName);
await headscaleLiveStore.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 auth.reassignUser(headplaneUserId, newRole as Role);
if (!result) {
throw data("Failed to reassign user role.", { status: 500 });
}
@@ -109,27 +99,16 @@ export async function userAction({ request, context }: Route.ActionArgs) {
return { message: "User reassigned successfully" };
}
case "transfer_ownership": {
if (principal.kind !== "oidc" || principal.user.role !== "owner") {
if (!isUserPrincipal(principal) || principal.user.role !== "owner") {
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 auth.transferOwnership(principal.user.id, headplaneUserId);
if (!result) {
throw data("Failed to transfer ownership.", { status: 500 });
}
@@ -137,26 +116,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 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;
}
+12 -10
View File
@@ -1,14 +1,16 @@
import type { Route } from './+types/healthz';
import { headscaleContext } from "~/server/context";
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 headscale = context.get(headscaleContext);
return new Response(JSON.stringify({ status: healthy ? 'OK' : 'ERROR' }), {
status: healthy ? 200 : 500,
headers: {
'Content-Type': 'application/json',
},
});
const healthy = await headscale.health();
return new Response(JSON.stringify({ status: healthy ? "OK" : "ERROR" }), {
status: healthy ? 200 : 500,
headers: {
"Content-Type": "application/json",
},
});
}
+56 -51
View File
@@ -1,59 +1,64 @@
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 { appConfigContext, headscaleContext } from "~/server/context";
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,
);
}
const config = context.get(appConfigContext);
const headscale = context.get(headscaleContext);
const bearer = request.headers.get('Authorization') ?? '';
if (!bearer.startsWith('Bearer ')) {
throw data(
{
status: 'Unauthorized',
},
401,
);
}
if (config.server.info_secret == null) {
throw data(
{
status: "Forbidden",
},
403,
);
}
const token = bearer.slice('Bearer '.length).trim();
if (token !== context.config.server.info_secret) {
throw data(
{
status: 'Forbidden',
},
403,
);
}
const bearer = request.headers.get("Authorization") ?? "";
if (!bearer.startsWith("Bearer ")) {
throw data(
{
status: "Unauthorized",
},
401,
);
}
// Use a fake API key for healthcheck
const api = context.hsApi.getRuntimeClient('fake-api-key');
const healthy = await api.isHealthy();
const token = bearer.slice("Bearer ".length).trim();
if (token !== config.server.info_secret) {
throw data(
{
status: "Forbidden",
},
403,
);
}
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 healthy = await headscale.health();
return new Response(JSON.stringify(body), {
status: 200,
headers: {
'Content-Type': 'application/json',
},
});
const body = {
status: healthy ? "healthy" : "unhealthy",
headplane_version: __VERSION__,
headscale_canonical_version: healthy ? 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",
},
});
}
+9 -7
View File
@@ -1,17 +1,19 @@
import { headscaleLiveStoreContext, requestApiContext } from "~/server/context";
import { nodesResource, usersResource } from "~/server/headscale/live-store";
import log from "~/utils/log";
import type { Route } from "./+types/live";
export async function loader({ request, context }: Route.LoaderArgs) {
const principal = await context.auth.require(request);
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
const getRequestApi = context.get(requestApiContext);
const headscaleLiveStore = context.get(headscaleLiveStoreContext);
const { api } = await getRequestApi(request);
// Ensure resources are loaded before streaming
await Promise.all([
context.hsLive.get(nodesResource, api),
context.hsLive.get(usersResource, api),
headscaleLiveStore.get(nodesResource, api),
headscaleLiveStore.get(usersResource, api),
]);
const stream = new ReadableStream({
@@ -27,11 +29,11 @@ export async function loader({ request, context }: Route.LoaderArgs) {
}
};
const versions = context.hsLive.getVersions();
const versions = headscaleLiveStore.getVersions();
log.debug("sse", "Client connected, sending hello with versions: %o", versions);
send("hello", versions);
const unsubscribe = context.hsLive.subscribe((resource, version) => {
const unsubscribe = headscaleLiveStore.subscribe((resource, version) => {
log.debug("sse", "Sending change event: %s v%s", resource, version);
send("changed", { resource, version });
});
+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");
}
+114
View File
@@ -0,0 +1,114 @@
# `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 RouterContextProvider data
├── 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 application context (via [`context.ts`](./context.ts))
→ seeds React Router's `RouterContextProvider` with the named service contexts
→ exports the React Router `RequestListener` as `default`, plus the resolved
`config` as a named export.
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 owns process-lifetime services, but route handlers consume
those services through named React Router contexts such as `authContext`,
`headscaleContext`, and `headscaleConfigContext`:
When a route needs a service, import the matching context from
`~/server/context`:
```ts
import { authContext } from "~/server/context";
export async function loader({ context, request }: Route.LoaderArgs) {
const auth = context.get(authContext);
const principal = await auth.require(request);
}
```
## 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. Expose it through a named React Router context
and seed that context in [`app.ts`](./app.ts)'s `getLoadContext`.
3. If it's purely a helper (pure functions, type definitions), import
it directly from the module that needs it.
+103
View File
@@ -0,0 +1,103 @@
// 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 { RouterContextProvider } from "react-router";
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 {
agentsContext,
appConfigContext,
authContext,
createAppContext,
dbContext,
headscaleApiKeyContext,
headscaleConfigContext,
headscaleContext,
headscaleLiveStoreContext,
integrationContext,
oidcContext,
requestApiContext,
} 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.
function getLoadContext(request: Request, client: ClientAddress) {
ctx.auth.registerRequestClientAddress(request, client.address);
const routerContext = new RouterContextProvider();
routerContext.set(agentsContext, ctx.agents);
routerContext.set(appConfigContext, ctx.config);
routerContext.set(authContext, ctx.auth);
routerContext.set(dbContext, ctx.db);
routerContext.set(headscaleContext, ctx.headscale);
routerContext.set(headscaleApiKeyContext, ctx.headscaleApiKey);
routerContext.set(headscaleConfigContext, ctx.hs);
routerContext.set(headscaleLiveStoreContext, ctx.hsLive);
routerContext.set(integrationContext, ctx.integration);
routerContext.set(oidcContext, ctx.oidc);
routerContext.set(requestApiContext, ctx.apiForRequest);
return routerContext;
}
interface ClientAddress {
address?: string;
}
export default createRequestListener({
build,
mode: import.meta.env.MODE,
getLoadContext,
});
+64
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,23 @@ const serverConfig = type({
cookie_secure: "boolean = true",
cookie_domain: "string.lower?",
cookie_max_age: "number.integer = 86400",
// TLS termination. When both `tls_cert_path` and `tls_key_path`
// are provided, Headplane serves HTTPS on `server.port`. When
// either is set, `cookie_secure` is forced to `true`.
tls_cert_path: "string?",
tls_key_path: "string?",
"proxy_auth?": {
enabled: "boolean",
allowed_cidrs: "string[]?",
trusted_proxy_cidrs: "string[]?",
ip_header: "string?",
user_header: "string?",
email_header: "string?",
name_header: "string?",
picture_header: "string?",
},
});
const partialServerConfig = type({
@@ -38,6 +72,20 @@ const partialServerConfig = type({
cookie_secure: "boolean?",
cookie_domain: "string.lower?",
cookie_max_age: "number.integer?",
tls_cert_path: "string?",
tls_key_path: "string?",
"proxy_auth?": {
enabled: "boolean?",
allowed_cidrs: "string[]?",
trusted_proxy_cidrs: "string[]?",
ip_header: "string?",
user_header: "string?",
email_header: "string?",
name_header: "string?",
picture_header: "string?",
},
});
const headscaleConfig = type({
@@ -66,6 +114,8 @@ const partialHeadscaleConfig = type({
tls_cert_path: "string.lower?",
});
const assignableRole = '"admin" | "network_admin" | "it_admin" | "auditor" | "viewer" | "member"';
const oidcConfig = type({
enabled: "boolean = true",
issuer: "string.url",
@@ -98,12 +148,19 @@ const oidcConfig = type({
.optional(),
disable_api_key_login: "boolean = false",
scope: 'string = "openid email profile"',
subject_claims: type("string[]").pipe(normalizeStringArray).optional(),
default_role: `${assignableRole} = "member"`,
role_claim: "string?",
allow_weak_rsa_keys: "boolean = false",
profile_picture_source: '"oidc" | "gravatar" = "oidc"',
extra_params: "Record<string, string>?",
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 +177,19 @@ const partialOidcConfig = type({
redirect_uri: "string.url?",
disable_api_key_login: "boolean?",
scope: "string?",
subject_claims: type("string[]").pipe(normalizeStringArray).optional(),
default_role: `${assignableRole}?`,
role_claim: "string?",
allow_weak_rsa_keys: "boolean?",
extra_params: "Record<string, string>?",
profile_picture_source: '"oidc" | "gravatar"?',
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;
};
}
+199
View File
@@ -0,0 +1,199 @@
import { join } from "node:path";
import { createContext } from "react-router";
import log from "~/utils/log";
import type { HeadplaneConfig } from "./config/config-schema";
import { loadIntegration } from "./config/integration";
import { createDbClient } from "./db/client.server";
import { disabled, enabled, type Feature } from "./feature";
import { createHeadscale, type HeadscaleClient } from "./headscale/api";
import { 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>>;
export const agentsContext = createContext<AppContext["agents"]>();
export const appConfigContext = createContext<AppContext["config"]>();
export const authContext = createContext<AppContext["auth"]>();
export const dbContext = createContext<AppContext["db"]>();
export const headscaleContext = createContext<AppContext["headscale"]>();
export const headscaleApiKeyContext = createContext<AppContext["headscaleApiKey"]>();
export const headscaleConfigContext = createContext<AppContext["hs"]>();
export const headscaleLiveStoreContext = createContext<AppContext["hsLive"]>();
export const integrationContext = createContext<AppContext["integration"]>();
export const oidcContext = createContext<AppContext["oidc"]>();
export const requestApiContext = createContext<AppContext["apiForRequest"]>();
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,
proxyAuth: config.server.proxy_auth
? {
enabled: config.server.proxy_auth.enabled,
allowedCidrs: config.server.proxy_auth.allowed_cidrs,
trustedProxyCidrs: config.server.proxy_auth.trusted_proxy_cidrs,
ipHeader: config.server.proxy_auth.ip_header,
userHeader: config.server.proxy_auth.user_header,
emailHeader: config.server.proxy_auth.email_header,
nameHeader: config.server.proxy_auth.name_header,
pictureHeader: config.server.proxy_auth.picture_header,
}
: undefined,
db,
cookie: {
name: "_hp_auth",
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.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,
roleClaim: config.oidc.role_claim,
allowWeakRsaKeys: config.oidc.allow_weak_rsa_keys,
extraParams: config.oidc.extra_params,
profilePictureSource: config.oidc.profile_picture_source,
postLogoutRedirectUri: config.oidc.post_logout_redirect_uri,
}),
);
}
async function buildAgents(
config: HeadplaneConfig,
supportsTagOnlyKeys: boolean,
apiClient: HeadscaleClient | undefined,
db: Awaited<ReturnType<typeof createDbClient>>,
): Promise<Feature<AgentManager>> {
const agentConfig = config.integration?.agent;
if (!agentConfig?.enabled) {
return disabled("Agent is not enabled in the configuration");
}
if (!apiClient) {
return disabled("Agent requires headscale.api_key to be configured");
}
if (!supportsTagOnlyKeys) {
return disabled("Agent requires Headscale 0.28 or newer");
}
const manager = await createAgentManager(
agentConfig,
config.headscale.url,
apiClient,
supportsTagOnlyKeys,
db,
);
if (!manager) {
return disabled("Agent failed to initialize (see logs)");
}
return enabled(manager);
}
+2 -1
View File
@@ -32,10 +32,11 @@ export type HeadplaneUserInsert = typeof users.$inferInsert;
export const authSessions = sqliteTable("auth_sessions", {
id: text("id").primaryKey(),
kind: text("kind").notNull(), // 'oidc' | 'api_key'
kind: text("kind").notNull(), // 'oidc' | 'api_key' (proxy auth is request-scoped)
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,
});
+56
View File
@@ -0,0 +1,56 @@
// MARK: Headscale Capabilities
//
// Behavioural facts about the connected Headscale server, derived
// once from `ServerVersion` at boot. Capabilities are named for *what
// changed* — not for the version number it changed in — so a reader
// who has never seen Headscale can tell what each flag controls
// without consulting a release note.
//
// Add a capability here when you find yourself reaching for a raw
// version comparison in endpoint or route code. Adding a capability
// is also the right answer when a new Headscale release changes wire
// format or removes an endpoint.
import { gte, type ServerVersion } from "./server-version";
export interface Capabilities {
/**
* Pre-auth keys have stable IDs. `GET /api/v1/preauthkey` (no
* user filter) returns every key in the system, and
* `POST /api/v1/preauthkey/expire` takes `{ id }` instead of
* `{ user, key }`. Tag-only pre-auth keys (no owning user) are
* supported. Introduced in 0.28.0.
*/
readonly preAuthKeysHaveStableIds: boolean;
/**
* Node tags are a flat `tags: string[]` field on the wire.
* Pre-0.28 returned `forcedTags` / `validTags` / `invalidTags`
* that the client had to union itself. Introduced in 0.28.0.
*/
readonly nodeTagsAreFlat: boolean;
/**
* A node's owning user is immutable after creation;
* `POST /api/v1/node/{id}/user` no longer reassigns. Effective in
* 0.28.0+.
*/
readonly nodeOwnerIsImmutable: boolean;
/**
* The `POST /api/v1/node/register` endpoint expects the full
* `hskey-authreq-<id>` AuthID as the `key` parameter. Pre-0.29
* Headscale expected the raw 24-character registration ID without
* the `hskey-authreq-` prefix. Introduced in 0.29.0.
*/
readonly registerKeyIncludesAuthReqPrefix: boolean;
}
export function capabilitiesFor(version: ServerVersion): Capabilities {
return {
preAuthKeysHaveStableIds: gte(version, "0.28.0"),
nodeTagsAreFlat: gte(version, "0.28.0"),
nodeOwnerIsImmutable: gte(version, "0.28.0"),
registerKeyIncludesAuthReqPrefix: gte(version, "0.29.0"),
};
}
@@ -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,93 +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: PreAuthKey): Promise<void>;
}
export default defineApiEndpoints<PreAuthKeyEndpoints>((client, apiKey) => ({
getAllPreAuthKeys: async () => {
const { preAuthKeys } = await client.apiFetch<{
preAuthKeys: PreAuthKey[];
}>("GET", "v1/preauthkey", apiKey, {});
return preAuthKeys;
},
getPreAuthKeys: async (user) => {
const { preAuthKeys } = await client.apiFetch<{
preAuthKeys: PreAuthKey[];
}>("GET", "v1/preauthkey", apiKey, { user });
return preAuthKeys;
},
createPreAuthKey: async (user, ephemeral, reusable, expiration, aclTags) => {
const body: Record<string, unknown> = {
ephemeral,
reusable,
expiration: expiration ? expiration.toISOString() : null,
};
if (user) {
body.user = user;
}
if (aclTags && aclTags.length > 0) {
body.aclTags = aclTags;
}
const { preAuthKey } = await client.apiFetch<{
preAuthKey: PreAuthKey;
}>("POST", "v1/preauthkey", apiKey, body);
return preAuthKey;
},
expirePreAuthKey: async (user, key) => {
if (client.isAtleast("0.28.0")) {
await client.apiFetch<void>("POST", "v1/preauthkey/expire", apiKey, {
id: key.id,
});
return;
}
await client.apiFetch<void>("POST", "v1/preauthkey/expire", apiKey, {
user,
key: key.key,
});
},
}));
@@ -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;
}
+144 -306
View File
@@ -1,328 +1,166 @@
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 { type AuthApi, makeAuthApi } from "./resources/auth";
import { makeNodeApi, type NodeApi } from "./resources/nodes";
import { makePolicyApi, type PolicyApi } from "./resources/policy";
import { makePreAuthKeyApi, type PreAuthKeyApi } from "./resources/pre-auth-keys";
import { makeUserApi, type UserApi } from "./resources/users";
import { formatServerVersion, parseServerVersion, type ServerVersion } from "./server-version";
import { createTransport } from "./transport";
/**
* 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;
auth: AuthApi;
}
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),
auth: makeAuthApi(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;
}
@@ -0,0 +1,25 @@
import type { Key } from "~/types";
import type { Capabilities } from "../capabilities";
import type { Transport } from "../transport";
export interface ApiKeyApi {
list(): Promise<Key[]>;
}
export function makeApiKeyApi(
transport: Transport,
_capabilities: Capabilities,
apiKey: string,
): ApiKeyApi {
return {
list: async () => {
const { apiKeys } = await transport.request<{ apiKeys: Key[] }>({
method: "GET",
path: "v1/apikey",
apiKey,
});
return apiKeys;
},
};
}
@@ -0,0 +1,27 @@
import type { Capabilities } from "../capabilities";
import type { Transport } from "../transport";
export interface AuthApi {
/**
* Approve a pending Headscale authentication request.
* Used by the Headplane agent to auto-approve its own registration.
*/
approve(authId: string): Promise<void>;
}
export function makeAuthApi(
transport: Transport,
_capabilities: Capabilities,
apiKey: string,
): AuthApi {
return {
approve: async (authId) => {
await transport.request({
method: "POST",
path: "v1/auth/approve",
apiKey,
body: { authId },
});
},
};
}
+121
View File
@@ -0,0 +1,121 @@
import type { Machine } from "~/types";
import type { Capabilities } from "../capabilities";
import type { Transport } from "../transport";
interface RawMachine extends Omit<Machine, "tags"> {
tags?: string[];
forcedTags?: string[];
validTags?: string[];
invalidTags?: string[];
}
export interface NodeApi {
list(): Promise<Machine[]>;
get(id: string): Promise<Machine>;
delete(id: string): Promise<void>;
register(user: string, key: string): Promise<Machine>;
approveRoutes(id: string, routes: string[]): Promise<void>;
expire(id: string): Promise<void>;
rename(id: string, newName: string): Promise<void>;
setTags(id: string, tags: string[]): Promise<void>;
/**
* Reassign a node to a different user. Only present when
* `capabilities.nodeOwnerIsImmutable` is false (Headscale < 0.28).
*/
reassignUser?: (id: string, user: string) => Promise<void>;
}
export function makeNodeApi(
transport: Transport,
capabilities: Capabilities,
apiKey: string,
): NodeApi {
function normalize(raw: RawMachine): Machine {
if (capabilities.nodeTagsAreFlat) {
return { ...raw, tags: raw.tags ?? [] } as Machine;
}
const tags = Array.from(new Set([...(raw.forcedTags ?? []), ...(raw.validTags ?? [])]));
return { ...raw, tags } as Machine;
}
const api: NodeApi = {
list: async () => {
const { nodes } = await transport.request<{ nodes: RawMachine[] }>({
method: "GET",
path: "v1/node",
apiKey,
});
return nodes.map(normalize);
},
get: async (id) => {
const { node } = await transport.request<{ node: RawMachine }>({
method: "GET",
path: `v1/node/${id}`,
apiKey,
});
return normalize(node);
},
delete: async (id) => {
await transport.request({ method: "DELETE", path: `v1/node/${id}`, apiKey });
},
register: async (user, key) => {
// Headscale's node-register endpoint expects the registration
// params as both query string and body — preserved as-is.
// Pre-0.29 expects the raw 24-char registration ID; 0.29+ expects
// the full `hskey-authreq-<id>` AuthID.
const registerKey = capabilities.registerKeyIncludesAuthReqPrefix
? key
: key.replace(/^hskey-authreq-/, "");
const qp = new URLSearchParams();
qp.append("user", user);
qp.append("key", registerKey);
const { node } = await transport.request<{ node: RawMachine }>({
method: "POST",
path: `v1/node/register?${qp.toString()}`,
apiKey,
body: { user, key: registerKey },
});
return normalize(node);
},
approveRoutes: async (id, routes) => {
await transport.request({
method: "POST",
path: `v1/node/${id}/approve_routes`,
apiKey,
body: { routes },
});
},
expire: async (id) => {
await transport.request({ method: "POST", path: `v1/node/${id}/expire`, apiKey });
},
rename: async (id, newName) => {
await transport.request({
method: "POST",
path: `v1/node/${id}/rename/${encodeURIComponent(newName)}`,
apiKey,
});
},
setTags: async (id, tags) => {
await transport.request({
method: "POST",
path: `v1/node/${id}/tags`,
apiKey,
body: { tags },
});
},
};
if (!capabilities.nodeOwnerIsImmutable) {
api.reassignUser = async (id, user) => {
await transport.request({
method: "POST",
path: `v1/node/${id}/user`,
apiKey,
body: { user },
});
};
}
return api;
}
@@ -0,0 +1,33 @@
import type { Capabilities } from "../capabilities";
import type { Transport } from "../transport";
export interface PolicyApi {
get(): Promise<{ policy: string; updatedAt: Date | null }>;
set(policy: string): Promise<{ policy: string; updatedAt: Date }>;
}
export function makePolicyApi(
transport: Transport,
_capabilities: Capabilities,
apiKey: string,
): PolicyApi {
return {
get: async () => {
const { policy, updatedAt } = await transport.request<{
policy: string;
updatedAt: string;
}>({ method: "GET", path: "v1/policy", apiKey });
return {
policy,
updatedAt: updatedAt !== null ? new Date(updatedAt) : null,
};
},
set: async (policy) => {
const { policy: newPolicy, updatedAt } = await transport.request<{
policy: string;
updatedAt: string;
}>({ method: "PUT", path: "v1/policy", apiKey, body: { policy } });
return { policy: newPolicy, updatedAt: new Date(updatedAt) };
},
};
}
@@ -0,0 +1,90 @@
import type { PreAuthKey } from "~/types";
import type { Capabilities } from "../capabilities";
import type { Transport } from "../transport";
export interface CreatePreAuthKeyOptions {
/** Owning user ID, or `null` for tag-only keys (0.28+). */
user: string | null;
ephemeral: boolean;
reusable: boolean;
expiration: Date | null;
aclTags: string[] | null;
}
export interface PreAuthKeyApi {
/**
* List every pre-auth key on the server. Only present when
* `capabilities.preAuthKeysHaveStableIds` is true (Headscale 0.28+).
* Pre-0.28 callers must use {@link listForUser}.
*/
listAll?: () => Promise<PreAuthKey[]>;
listForUser(userId: string): Promise<PreAuthKey[]>;
create(opts: CreatePreAuthKeyOptions): Promise<PreAuthKey>;
expire(key: PreAuthKey): Promise<void>;
}
export function makePreAuthKeyApi(
transport: Transport,
capabilities: Capabilities,
apiKey: string,
): PreAuthKeyApi {
const api: PreAuthKeyApi = {
listForUser: async (userId) => {
const { preAuthKeys } = await transport.request<{
preAuthKeys: PreAuthKey[];
}>({ method: "GET", path: "v1/preauthkey", apiKey, query: { user: userId } });
return preAuthKeys;
},
create: async ({ user, ephemeral, reusable, expiration, aclTags }) => {
const body: Record<string, unknown> = {
ephemeral,
reusable,
expiration: expiration ? expiration.toISOString() : null,
};
if (user) body.user = user;
if (aclTags && aclTags.length > 0) body.aclTags = aclTags;
const { preAuthKey } = await transport.request<{
preAuthKey: PreAuthKey;
}>({ method: "POST", path: "v1/preauthkey", apiKey, body });
return preAuthKey;
},
expire: async (key) => {
if (capabilities.preAuthKeysHaveStableIds) {
await transport.request({
method: "POST",
path: "v1/preauthkey/expire",
apiKey,
body: { id: key.id },
});
return;
}
// Pre-0.28: expire takes the owning user's ID (a uint64 — Headscale
// rejects names with `proto: invalid value for uint64 field user`)
// plus the key string.
await transport.request({
method: "POST",
path: "v1/preauthkey/expire",
apiKey,
body: { user: key.user?.id ?? "", key: key.key },
});
},
};
if (capabilities.preAuthKeysHaveStableIds) {
api.listAll = async () => {
const { preAuthKeys } = await transport.request<{
preAuthKeys: PreAuthKey[];
}>({ method: "GET", path: "v1/preauthkey", apiKey });
return preAuthKeys;
};
}
return api;
}
@@ -0,0 +1,66 @@
import type { User } from "~/types";
import type { Capabilities } from "../capabilities";
import type { Transport } from "../transport";
export interface CreateUserOptions {
name: string;
email?: string;
displayName?: string;
pictureUrl?: string;
}
export interface ListUsersFilter {
id?: string;
name?: string;
email?: string;
}
export interface UserApi {
list(filter?: ListUsersFilter): Promise<User[]>;
create(opts: CreateUserOptions): Promise<User>;
delete(id: string): Promise<void>;
rename(id: string, newName: string): Promise<void>;
}
export function makeUserApi(
transport: Transport,
_capabilities: Capabilities,
apiKey: string,
): UserApi {
return {
list: async (filter) => {
const { id, name, email } = filter ?? {};
const moreThanOneFilter = [id, name, email].filter((v) => v !== undefined).length > 1;
if (moreThanOneFilter) {
throw new Error("Only one of id, name, or email filters can be provided");
}
const { users } = await transport.request<{ users: User[] }>({
method: "GET",
path: "v1/user",
apiKey,
query: { id, name, email },
});
return users;
},
create: async ({ name, email, displayName, pictureUrl }) => {
const { user } = await transport.request<{ user: User }>({
method: "POST",
path: "v1/user",
apiKey,
body: { name, email, displayName, pictureUrl },
});
return user;
},
delete: async (id) => {
await transport.request({ method: "DELETE", path: `v1/user/${id}`, apiKey });
},
rename: async (id, newName) => {
await transport.request({
method: "POST",
path: `v1/user/${id}/rename/${encodeURIComponent(newName)}`,
apiKey,
});
},
};
}
+125
View File
@@ -0,0 +1,125 @@
// MARK: ServerVersion
//
// Parses the response from Headscale's `GET /version` endpoint into a
// structured value that capability checks can reason about. The
// endpoint exists in every Headscale release we support (0.27.0+) and
// returns a plain semver-like string such as `v0.28.0`, `v0.28.0-beta.1`,
// or `dev` for untagged builds.
//
// Comparisons (`gte`) are lenient about prerelease tags by design:
// `0.28.0-beta.1` is treated as `0.28.0` for capability gating. The
// behavioural changes that capabilities gate on always land in the
// first prerelease of a minor version, so a strict semver
// interpretation would lock prerelease users out of features that
// their server actually has.
const SEMVER_RE = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$/;
// Go pseudo-version prerelease segment: `<14-digit timestamp>-<12-hex sha>`,
// e.g. the prerelease part of `v0.0.0-20260703052708-048308511c72`. Untagged
// Headscale builds (per-commit `main-*` / `development` Docker images) report
// this instead of `dev`, and it parses as semver 0.0.0 — which would strip
// every capability from a server that actually runs the newest code.
const GO_PSEUDO_VERSION_PRERELEASE_RE = /^\d{14}-[0-9a-f]{12}$/;
export interface ServerVersion {
readonly major: number;
readonly minor: number;
readonly patch: number;
readonly prerelease: string | undefined;
readonly build: string | undefined;
/** The raw string as reported by Headscale (e.g. `v0.28.0-beta.1`, `dev`). */
readonly raw: string;
/**
* True when the server reported a version we couldn't parse —
* typically `dev` for an untagged Headscale build. Capability checks
* treat unknown versions as having every known capability so we
* exercise the modern code paths against unfamiliar servers rather
* than silently falling back to compatibility shims.
*/
readonly unknown: boolean;
}
export function parseServerVersion(raw: string): ServerVersion {
const match = SEMVER_RE.exec(raw.trim());
if (!match) {
return {
major: 0,
minor: 0,
patch: 0,
prerelease: undefined,
build: undefined,
raw,
unknown: true,
};
}
const [, maj, min, pat, pre, build] = match;
// A Go pseudo-version (v0.0.0-<timestamp>-<sha>) is an untagged dev build,
// not an ancient release: treat it like `dev` so capability checks assume
// the modern code paths instead of gating everything off.
if (
Number(maj) === 0 &&
Number(min) === 0 &&
Number(pat) === 0 &&
pre !== undefined &&
GO_PSEUDO_VERSION_PRERELEASE_RE.test(pre)
) {
return {
major: 0,
minor: 0,
patch: 0,
prerelease: pre,
build,
raw,
unknown: true,
};
}
return {
major: Number(maj),
minor: Number(min),
patch: Number(pat),
prerelease: pre,
build,
raw,
unknown: false,
};
}
/** Pretty-print a parsed version for logs (no `v` prefix, includes prerelease). */
export function formatServerVersion(version: ServerVersion): string {
if (version.unknown) return version.raw;
const core = `${version.major}.${version.minor}.${version.patch}`;
return version.prerelease ? `${core}-${version.prerelease}` : core;
}
/**
* Returns true when `version` is at least `target` (ignoring prerelease
* tags — see module-level note). `target` must be a plain semver
* string like `0.28.0`. Throws on a malformed target since those come
* from static capability tables, not user input.
*/
export function gte(version: ServerVersion, target: string): boolean {
if (version.unknown) return true;
const t = parseTarget(target);
if (version.major !== t.major) return version.major > t.major;
if (version.minor !== t.minor) return version.minor > t.minor;
return version.patch >= t.patch;
}
interface TargetVersion {
major: number;
minor: number;
patch: number;
}
function parseTarget(target: string): TargetVersion {
const match = SEMVER_RE.exec(target);
if (!match) {
throw new Error(`Invalid capability target version: ${target}`);
}
return {
major: Number(match[1]),
minor: Number(match[2]),
patch: Number(match[3]),
};
}
+191
View File
@@ -0,0 +1,191 @@
// MARK: Headscale Transport
//
// Internal HTTP transport for talking to a Headscale server. Owns
// the Undici agent (and any custom CA), the base URL, error
// translation, and the distinction between authenticated `/api/v1`
// calls and unauthenticated public endpoints (`/version`, `/health`).
//
// This module is intentionally not exported from the package; all
// consumers should go through `Headscale` and `HeadscaleClient` in
// `./index.ts`, never the transport directly.
import { readFile } from "node:fs/promises";
import { data } from "react-router";
import { Agent, type Dispatcher, request } from "undici";
import log from "~/utils/log";
import { undiciToFriendlyError } from "./error";
import { type HeadscaleAPIError, isApiError } from "./error-client";
export interface TransportRequest {
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
/** API path without the `/api/` prefix (e.g. `v1/node`). */
path: `v1/${string}`;
apiKey: string;
/** JSON request body for non-GET/DELETE requests. */
body?: Record<string, unknown>;
/** Query parameters for GET/DELETE requests. */
query?: Record<string, unknown>;
}
export interface Transport {
/**
* Send an authenticated JSON request against `/api/{path}`.
* Throws a React Router `data()` 502 response on transport errors
* and a typed `HeadscaleAPIError` (wrapped in `data()`) on API
* errors with statusCode >= 400.
*/
request<T>(opts: TransportRequest): Promise<T>;
/**
* Send an unauthenticated GET against the server root
* (e.g. `/version`, `/health`). Returns parsed JSON.
*/
getPublic<T>(path: `/${string}`): Promise<T>;
/** True if `GET /health` returns 200. Never throws. */
health(): Promise<boolean>;
/** Shut down the underlying Undici agent. */
dispose(): Promise<void>;
}
export interface TransportOptions {
url: string;
certPath?: string;
}
export async function createTransport(opts: TransportOptions): Promise<Transport> {
const agent = await createUndiciAgent(opts.certPath);
const baseUrl = opts.url;
async function rawRequest(
url: string,
options: Partial<Dispatcher.RequestOptions> & { method: string },
): Promise<Dispatcher.ResponseData> {
log.debug("api", "%s %s", options.method, url);
try {
return await request(new URL(url, baseUrl), {
dispatcher: agent,
headers: {
...options.headers,
Accept: "application/json",
"User-Agent": `Headplane/${__VERSION__}`,
},
body: options.body,
method: options.method,
});
} catch (error) {
const errorBody = undiciToFriendlyError(error, `${options.method} ${url}`);
throw data(errorBody, { status: 502, statusText: "Bad Gateway" });
}
}
return {
async request<T>({ method, path, apiKey, body, query }: TransportRequest): Promise<T> {
let url = `/api/${path}`;
const options: Partial<Dispatcher.RequestOptions> & { method: string } = {
method,
headers: { Authorization: `Bearer ${apiKey}` },
};
if (query && (method === "GET" || method === "DELETE")) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value !== undefined) {
params.append(key, String(value));
}
}
if ([...params.keys()].length > 0) {
url += `?${params.toString()}`;
}
} else if (body && method !== "GET" && method !== "DELETE") {
options.body = JSON.stringify(body);
options.headers = { ...options.headers, "Content-Type": "application/json" };
}
const res = await rawRequest(url, options);
if (res.statusCode >= 400) {
log.debug("api", "%s %s failed with status %d", method, path, res.statusCode);
const rawData = await res.body.text();
const jsonData = (() => {
try {
return JSON.parse(rawData) as Record<string, unknown>;
} catch {
return null;
}
})();
throw data(
{
requestUrl: `${method} ${path}`,
statusCode: res.statusCode,
rawData,
data: jsonData,
} satisfies HeadscaleAPIError,
{ status: 502, statusText: "Bad Gateway" },
);
}
return res.body.json() as Promise<T>;
},
async getPublic<T>(path: `/${string}`): Promise<T> {
const res = await rawRequest(path, { method: "GET" });
if (res.statusCode >= 400) {
const rawData = await res.body.text();
const jsonData = (() => {
try {
return JSON.parse(rawData) as Record<string, unknown>;
} catch {
return null;
}
})();
throw data(
{
requestUrl: `GET ${path}`,
statusCode: res.statusCode,
rawData,
data: jsonData,
} satisfies HeadscaleAPIError,
{ status: 502, statusText: "Bad Gateway" },
);
}
return res.body.json() as Promise<T>;
},
async health() {
try {
const res = await rawRequest("/health", { method: "GET" });
// Drain the body so the connection can be reused.
await res.body.dump();
return res.statusCode === 200;
} catch (error) {
if (isApiError(error)) {
log.debug("api", "Health check failed: %d", error.statusCode);
}
return false;
}
},
async dispose() {
await agent.close();
},
};
}
async function createUndiciAgent(certPath?: string): Promise<Agent> {
if (!certPath) return new Agent();
try {
log.debug("config", "Loading certificate from %s", certPath);
const cert = await readFile(certPath, "utf8");
log.info("config", "Using certificate from %s", certPath);
return new Agent({ connect: { ca: cert.trim() } });
} catch (error) {
log.error("config", "Failed to load Headscale TLS cert: %s", error);
log.debug("config", "Error Details: %o", error);
return new Agent();
}
}

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