mirror of
https://github.com/tale/headplane.git
synced 2026-07-26 15:58:14 +00:00
Compare commits
88 Commits
v0.7.0-beta.2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b3c0c1c691 | |||
| 3d9b9b0f7e | |||
| f52c4161cc | |||
| fb73181cba | |||
| 17e7b35478 | |||
| 51a4613295 | |||
| 1345b7ca13 | |||
| 948cfad58c | |||
| 990e1e9ff5 | |||
| 18263e4878 | |||
| 12cee9763e | |||
| 4d0c0cacca | |||
| 83671ff3b0 | |||
| 476a0419f1 | |||
| 0439175e73 | |||
| a3cd8444a3 | |||
| 10055541cc | |||
| dc32427cff | |||
| 5aebc6d932 | |||
| e29221e5f7 | |||
| 846c030bc1 | |||
| 5d6eef5843 | |||
| 3f9dcd5eb4 | |||
| 0c4d175eb7 | |||
| 96f2721272 | |||
| c7822e3ec2 | |||
| 4179e3e604 | |||
| f00d8ab87e | |||
| 3252482e0b | |||
| 57c8046f99 | |||
| c8b1a30f34 | |||
| 8017436bb6 | |||
| dea19f9330 | |||
| 21806caa05 | |||
| 2f3a440de5 | |||
| e74e0d4542 | |||
| db39b5f2bd | |||
| 262c7bf82a | |||
| f8b23a5c89 | |||
| 775b81a7fa | |||
| 62817efa6e | |||
| b95d601ff6 | |||
| ea27c846e2 | |||
| 8c508e0602 | |||
| 0a51182eed | |||
| 22a521dff5 | |||
| de07372427 | |||
| 2584a4ef55 | |||
| 30b528c491 | |||
| d5acba88c7 | |||
| d7f1d665a4 | |||
| 7901f37002 | |||
| 7a62359b6a | |||
| 2e38a1d5e3 | |||
| 7221f1c2c3 | |||
| 56c5e5ac8c | |||
| 09be95c7bc | |||
| 0512565f8e | |||
| d4eee702e9 | |||
| fb4b0b1404 | |||
| 1e0ff7ead6 | |||
| c6b6cbc122 | |||
| deb284e2b4 | |||
| 5a2098eea5 | |||
| b961b339bb | |||
| ac6f9e4f7e | |||
| 3026b33834 | |||
| ecd284b5d8 | |||
| 4cf4e5c040 | |||
| 9e5e5a613a | |||
| 4d252833ef | |||
| 9238f69bfc | |||
| d110dd2bcb | |||
| addef55f30 | |||
| 67c6c0b453 | |||
| 418c3bc255 | |||
| 946921fff7 | |||
| e4030ed254 | |||
| fccd2eefc4 | |||
| 93be180479 | |||
| 6582f8ae07 | |||
| f0f02b3c4c | |||
| c030d1fbe4 | |||
| 724e454466 | |||
| dc80c93184 | |||
| c164e07336 | |||
| d26c23313c | |||
| d5f76637f5 |
@@ -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
|
||||
@@ -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>
|
||||
+75
-43
@@ -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`.
|
||||
|
||||
---
|
||||
|
||||
@@ -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" ]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>} />
|
||||
) : (
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
Vendored
+4
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)];
|
||||
@@ -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>
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
}))}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 machine’s IP address depending on the destination."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { redirect } from 'react-router';
|
||||
import { redirect } from "react-router";
|
||||
|
||||
export async function loader() {
|
||||
return redirect('/machines');
|
||||
return redirect("/machines");
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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!);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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()),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
}));
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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 },
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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]),
|
||||
};
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user