Compare commits

...

59 Commits

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

Closes HP-558
2026-07-13 11:00:43 -04:00
Sandro 3d9b9b0f7e Fix typo url in code to docs (#586) 2026-07-12 18:20:07 -04:00
Sandro f52c4161cc Fix wasm ssh in nix build (#588) 2026-07-12 18:18:48 -04:00
Tommy Nevtelen fb73181cba fix: treat Go pseudo-versions as unknown server versions (#590)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 18:18:21 -04:00
Aarnav Tale 17e7b35478 chore: v0.7.0 2026-07-04 11:10:57 -04:00
Aarnav Tale 51a4613295 chore: fix unit tests 2026-07-04 00:55:42 -04:00
Aarnav Tale 1345b7ca13 fix(ui): fix assigning ACL tags to non-user nodes 2026-07-04 00:50:57 -04:00
Aarnav Tale 948cfad58c fix: handle registration keys on headscale 0.29+
Closes HP-555.
2026-07-04 00:07:53 -04:00
Aarnav Tale 990e1e9ff5 chore(ssh): warn about broken SSH on certain versions 2026-07-01 11:23:35 -04:00
Aarnav Tale 18263e4878 fix(ui): show display names in lists
Closes HP-549.
2026-06-22 20:23:41 -04:00
Aarnav Tale 12cee9763e fix(ui): stop split DNS from crashing the page when undefined
Closes HP-548.
2026-06-22 20:23:40 -04:00
github-actions[bot] 4d0c0cacca chore: update nix pnpm deps hash 2026-06-22 20:44:09 +00:00
Aarnav Tale 83671ff3b0 chore: add audit fix 2026-06-22 16:42:10 -04:00
Aarnav Tale 476a0419f1 chore: update dev stuff 2026-06-22 16:40:29 -04:00
Aarnav Tale 0439175e73 feat(auth): support role sync on re-login 2026-06-22 16:40:29 -04:00
Aarnav Tale a3cd8444a3 chore(auth): add more informative logs 2026-06-22 16:40:29 -04:00
Aarnav Tale 10055541cc feat(config): simplify required headscale config 2026-06-22 16:40:29 -04:00
Aarnav Tale dc32427cff feat(config): use minimal headscale config 2026-06-22 16:40:28 -04:00
github-actions[bot] 5aebc6d932 chore: update nix pnpm deps hash 2026-06-20 22:14:47 +00:00
Aarnav Tale e29221e5f7 feat: add support for headscale 0.29+ 2026-06-20 18:03:14 -04:00
Aarnav Tale 846c030bc1 chore: update to react router v8 2026-06-20 13:33:24 -04:00
Aarnav Tale 5d6eef5843 feat: update to the v8 middleware api 2026-06-20 12:38:09 -04:00
Aarnav Tale 3f9dcd5eb4 feat: update build for react router v8 2026-06-20 12:09:42 -04:00
Aarnav Tale 0c4d175eb7 feat(auth): support deriving roles from the IDP
Closes HP-352.
2026-06-20 11:53:09 -04:00
Aarnav Tale 96f2721272 feat(auth): support reverse-proxy driven proxy auth
Closes HP-353.
2026-06-20 11:41:44 -04:00
eccgecko c7822e3ec2 fix(wasm): make whole vendored tailscale tree writable before patching (#567) 2026-06-18 10:53:21 -04:00
github-actions[bot] 4179e3e604 chore: update nix pnpm deps hash 2026-06-17 20:06:57 +00:00
Aarnav Tale f00d8ab87e chore: changelog 2026-06-17 16:05:21 -04:00
Aarnav Tale 3252482e0b feat: switch logging to pino
Closes HP-279
2026-06-17 15:58:48 -04:00
Aarnav Tale 57c8046f99 feat(ui): add support for showing existing tag options 2026-06-17 11:35:38 -04:00
Aarnav Tale c8b1a30f34 fix(wasm): account for custom DERP ports when checking the probe
Closes HP-540
2026-06-17 11:30:34 -04:00
Aarnav Tale 8017436bb6 fix(ui): increase ssh pre-auth key expiry time and present errors
Closes HP-546
2026-06-17 11:27:07 -04:00
Aarnav Tale dea19f9330 fix(ui): validate machine names before rename submission
Closes HP-545
2026-06-17 11:15:30 -04:00
Aarnav Tale 21806caa05 fix(oidc): correctly handle client_secret_basic fallback 2026-06-17 11:13:46 -04:00
Aarnav Tale 2f3a440de5 fix: auto read dns.extra_records_path instead of making it required
Closes HP-538
2026-06-17 11:11:20 -04:00
Aarnav Tale e74e0d4542 fix: don't require postgres pass when password_file is supplied
Closes HP-528
2026-06-17 11:09:12 -04:00
Aarnav Tale db39b5f2bd chore: add pullfrog.yml workflow 2026-06-17 10:26:20 -04:00
Aarnav Tale 262c7bf82a chore: v0.7.0-beta.4 2026-05-31 14:46:03 -04:00
Aarnav Tale f8b23a5c89 chore: changelog 2026-05-31 14:39:53 -04:00
Aarnav Tale 775b81a7fa feat: support multiple CAs through documentation 2026-05-31 14:38:10 -04:00
Aarnav Tale 62817efa6e feat: gate the healthcheck listen file to docker only 2026-05-30 20:46:32 -04:00
Aarnav Tale b95d601ff6 feat: automate health check with a written file 2026-05-30 20:45:15 -04:00
Aarnav Tale ea27c846e2 feat: add support for https 2026-05-30 19:39:02 -04:00
Aarnav Tale 8c508e0602 feat(users): expose rename and delete for unlinked Headscale users
The user-actions handler already supported `rename_user` and
`delete_user` (both keyed by headscale_user_id), but the Unlinked
Headscale Users table had no menu to invoke them. That left admins
with no UI path to remove or rename Headscale users that have no
Headplane counterpart — e.g. users created via the Headscale CLI
before an OIDC migration.

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

Closes #525

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

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

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

Send user.name instead.

Closes #532

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

Closes #508

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

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

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

Closes #535

Amp-Thread-ID: https://ampcode.com/threads/T-019e7ae4-6862-760c-a3e7-239350eab71d
Co-authored-by: Amp <amp@ampcode.com>
2026-05-30 18:47:57 -04:00
Aarnav Tale d5acba88c7 chore: changelog 2026-05-30 17:43:24 -04:00
Aarnav Tale d7f1d665a4 feat: bump headscale minimum to 0.27 2026-05-30 17:32:00 -04:00
Aarnav Tale 7901f37002 fix: correctly handle user id passthrough on headscale actions 2026-05-30 17:14:28 -04:00
Aarnav Tale 7a62359b6a chore: add a resilience test 2026-05-30 16:25:00 -04:00
Vitalij Dovhanyc 2e38a1d5e3 fix: constrain dialog panel height and make content scrollable (#556)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-05-30 16:21:17 -04:00
github-actions[bot] 7221f1c2c3 chore: update flake.lock (#545) 2026-05-27 20:37:47 -04:00
Aarnav Tale 56c5e5ac8c feat: make api calls more resilient and stuff 2026-05-25 17:14:26 -04:00
github-actions[bot] 09be95c7bc chore: update nix pnpm deps hash 2026-05-25 15:53:44 +00:00
Aarnav Tale 0512565f8e feat: replace openapi hashing system with /version
Apparently I didn't use my brain cells and rely on the /version
endpoint that Headscale has exposed since 0.26 (our lowest supported
version). Switching to that significantly simplifies the API surface.
2026-05-25 11:51:02 -04:00
Aarnav Tale d4eee702e9 refactor(server): replace AppContext undefined sentinels with Feature<T>
Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019e5550-4435-7118-8393-cdcc97042178
2026-05-23 16:56:34 -04:00
143 changed files with 7075 additions and 4743 deletions
+57
View File
@@ -0,0 +1,57 @@
# PULLFROG ACTION — DO NOT EDIT EXCEPT WHERE INDICATED
name: Pullfrog
run-name: ${{ inputs.name || github.workflow }}
on:
workflow_dispatch:
inputs:
prompt:
type: string
description: Agent prompt
name:
type: string
description: Run name
permissions:
contents: read
jobs:
pullfrog:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Run agent
uses: pullfrog/pullfrog@v0
with:
prompt: ${{ inputs.prompt }}
env:
# add at least one provider API key
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
GOOGLE_GENERATIVE_AI_API_KEY:
${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
# for Amazon Bedrock (https://docs.pullfrog.com/bedrock)
# AWS_BEARER_TOKEN_BEDROCK: ${{ secrets.AWS_BEARER_TOKEN_BEDROCK }}
# AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
# AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
# AWS_REGION: us-east-1
# BEDROCK_MODEL_ID: <bedrock-model-id>
# for Google Vertex AI (https://docs.pullfrog.com/vertex)
# VERTEX_SERVICE_ACCOUNT_JSON: ${{ secrets.VERTEX_SERVICE_ACCOUNT_JSON }}
# GOOGLE_CLOUD_PROJECT: my-project
# VERTEX_LOCATION: global
# VERTEX_MODEL_ID: <vertex-model-id>
+44
View File
@@ -1,3 +1,47 @@
# 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.
+7
View File
@@ -57,6 +57,11 @@ COPY --from=go-base /bin/hp_healthcheck /bin/hp_healthcheck
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD ["/bin/hp_healthcheck"]
# Tells Headplane to publish its loopback healthcheck URL to this
# file on startup; `hp_healthcheck` reads it. Docker-only — native
# installs don't ship a consumer.
ENV HEADPLANE_LISTEN_FILE=/tmp/headplane-listen
WORKDIR /app
CMD [ "/app/build/server/index.js" ]
@@ -73,5 +78,7 @@ COPY --from=go-base /bin/hp_healthcheck /bin/hp_healthcheck
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD ["/bin/hp_healthcheck"]
ENV HEADPLANE_LISTEN_FILE=/tmp/headplane-listen
WORKDIR /app
CMD [ "node", "/app/build/server/index.js" ]
+10 -3
View File
@@ -58,7 +58,8 @@ function Panel(props: DialogPanelProps) {
return (
<AlertDialog.Popup
className={cn(
"w-full max-w-lg rounded-xl p-4",
"flex w-full max-w-lg flex-col rounded-xl p-4",
"max-h-[90dvh]",
"outline-hidden",
"bg-white dark:bg-mist-900",
"border border-mist-200 dark:border-mist-800",
@@ -67,6 +68,7 @@ function Panel(props: DialogPanelProps) {
>
<Form
method={method ?? "POST"}
className="flex min-h-0 flex-1 flex-col"
onSubmit={(event) => {
if (onSubmit) {
onSubmit(event);
@@ -77,8 +79,13 @@ function Panel(props: DialogPanelProps) {
}
}}
>
<div className="flex flex-col gap-4">{children}</div>
<div className="mt-5 flex justify-end gap-3">
{/* px-1 -mx-1 gives focus rings on inputs/buttons room to render
without being clipped by overflow-y-auto (which implicitly forces
overflow-x: auto per CSS spec). */}
<div className="-mx-1 flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-1">
{children}
</div>
<div className="mt-5 flex shrink-0 justify-end gap-3">
{variant === "unactionable" ? (
<AlertDialog.Close render={<Button>Close</Button>} />
) : (
+1 -1
View File
@@ -24,7 +24,7 @@ export default function Tooltip({ children, content, className }: TooltipProps)
{children}
</BaseTooltip.Trigger>
<BaseTooltip.Portal>
<BaseTooltip.Positioner sideOffset={4}>
<BaseTooltip.Positioner side="top" sideOffset={4} collisionPadding={8}>
<BaseTooltip.Popup
className={cn(
"z-50 rounded-lg p-3 text-sm w-48",
+5 -3
View File
@@ -4,16 +4,18 @@ import { createReadableStreamFromReadable } from "@react-router/node";
import { isbot } from "isbot";
import type { RenderToPipeableStreamOptions } from "react-dom/server";
import { renderToPipeableStream } from "react-dom/server";
import type { AppLoadContext, EntryContext } from "react-router";
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);
}
},
},
+45 -32
View File
@@ -2,8 +2,17 @@ import { Outlet, redirect, type ShouldRevalidateFunction } from "react-router";
import { ErrorBanner } from "~/components/error-banner";
import StatusBanner from "~/components/status-banner";
import {
appConfigContext,
authContext,
headscaleConfigContext,
headscaleContext,
headscaleLiveStoreContext,
requestApiContext,
} from "~/server/context";
import { isDataUnauthorizedError } from "~/server/headscale/api/error-client";
import { usersResource } from "~/server/headscale/live-store";
import { isUserPrincipal } from "~/server/web/auth";
import { Capabilities } from "~/server/web/roles";
import log from "~/utils/log";
@@ -30,36 +39,40 @@ export const shouldRevalidate: ShouldRevalidateFunction = ({
};
export async function loader({ request, context }: Route.LoaderArgs) {
const auth = context.get(authContext);
const config = context.get(appConfigContext);
const getRequestApi = context.get(requestApiContext);
const headscale = context.get(headscaleContext);
const headscaleConfig = context.get(headscaleConfigContext);
const headscaleLiveStore = context.get(headscaleLiveStoreContext);
try {
const principal = await context.auth.require(request);
const { principal, api } = await getRequestApi(request);
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
const user =
principal.kind === "oidc"
? {
email: principal.profile.email,
name: principal.profile.name,
picture: principal.profile.picture,
subject: principal.user.subject,
username: principal.profile.username,
}
: { name: principal.displayName, subject: "api_key" };
const user = isUserPrincipal(principal)
? {
email: principal.profile.email,
name: principal.profile.name,
picture: principal.profile.picture,
subject: principal.user.subject,
username: principal.profile.username,
}
: { name: principal.displayName, subject: "api_key" };
// MARK: The session should stay valid if Headscale isn't healthy
const isHealthy = await api.isHealthy();
const isHealthy = await headscale.health();
if (isHealthy) {
try {
await api.getApiKeys();
await api.apiKeys.list();
} catch (error) {
if (isDataUnauthorizedError(error)) {
const displayName =
principal.kind === "oidc" ? principal.profile.name : principal.displayName;
const displayName = isUserPrincipal(principal)
? principal.profile.name
: principal.displayName;
log.warn("auth", "Logging out %s due to expired API key", displayName);
return redirect("/login", {
headers: {
"Set-Cookie": await context.auth.destroySession(request),
"Set-Cookie": await auth.destroySession(request),
},
});
}
@@ -67,11 +80,11 @@ export async function loader({ request, context }: Route.LoaderArgs) {
// Self-heal: if the linked Headscale user was deleted, clear the
// stale link so the user gets prompted to re-link.
if (principal.kind === "oidc" && principal.user.headscaleUserId) {
if (isUserPrincipal(principal) && principal.user.headscaleUserId) {
try {
const usersSnap = await context.hsLive.get(usersResource, api);
const usersSnap = await headscaleLiveStore.get(usersResource, api);
if (!usersSnap.data.some((u) => u.id === principal.user.headscaleUserId)) {
await context.auth.unlinkHeadscaleUser(principal.user.id);
await auth.unlinkHeadscaleUser(principal.user.id);
}
} catch {
// API call failed, skip validation
@@ -81,23 +94,23 @@ export async function loader({ request, context }: Route.LoaderArgs) {
return {
access: {
dns: context.auth.can(principal, Capabilities.read_network),
machines: context.auth.can(principal, Capabilities.read_machines),
policy: context.auth.can(principal, Capabilities.read_policy),
settings: context.auth.can(principal, Capabilities.read_feature),
ui: context.auth.can(principal, Capabilities.ui_access),
users: context.auth.can(principal, Capabilities.read_users),
dns: auth.can(principal, Capabilities.read_network),
machines: auth.can(principal, Capabilities.read_machines),
policy: auth.can(principal, Capabilities.read_policy),
settings: auth.can(principal, Capabilities.read_feature),
ui: auth.can(principal, Capabilities.ui_access),
users: auth.can(principal, Capabilities.read_users),
},
baseUrl: context.config.headscale.public_url ?? context.config.headscale.url,
configAvailable: context.hs.readable(),
isDebug: context.config.debug,
baseUrl: config.headscale.public_url ?? config.headscale.url,
configAvailable: headscaleConfig.readable(),
isDebug: config.debug,
isHealthy,
user,
};
} catch {
return redirect("/login", {
headers: {
"Set-Cookie": await context.auth.destroySession(request),
"Set-Cookie": await auth.destroySession(request),
},
});
}
-6
View File
@@ -1,6 +0,0 @@
{
"0.26.1": ["0.26.0", "0.26.1"],
"0.27.0": ["0.27.0"],
"0.27.1": ["0.27.1"],
"0.28.0": ["0.28.0"]
}
-135
View File
@@ -1,135 +0,0 @@
{
"0.26.0": {
"GET /api/v1/apikey": "efe31b6dc980e158",
"POST /api/v1/apikey": "39953a96c1da5312",
"POST /api/v1/apikey/expire": "ca56add866802f17",
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
"GET /api/v1/node": "8bb18b8c7cfb4f20",
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
"POST /api/v1/node/register": "539f7cb3a84d43d4",
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
"POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784",
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
"GET /api/v1/policy": "d6c639be304cd3c0",
"PUT /api/v1/policy": "6cbe80bde771a388",
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
"GET /api/v1/user": "228831b58ccc5a17",
"POST /api/v1/user": "a4e1d889d7962da5",
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
},
"0.26.1": {
"GET /api/v1/apikey": "efe31b6dc980e158",
"POST /api/v1/apikey": "39953a96c1da5312",
"POST /api/v1/apikey/expire": "ca56add866802f17",
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
"GET /api/v1/node": "8bb18b8c7cfb4f20",
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
"POST /api/v1/node/register": "539f7cb3a84d43d4",
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
"POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784",
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
"GET /api/v1/policy": "d6c639be304cd3c0",
"PUT /api/v1/policy": "6cbe80bde771a388",
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
"GET /api/v1/user": "228831b58ccc5a17",
"POST /api/v1/user": "a4e1d889d7962da5",
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
},
"0.27.0": {
"GET /api/v1/apikey": "efe31b6dc980e158",
"POST /api/v1/apikey": "39953a96c1da5312",
"POST /api/v1/apikey/expire": "ca56add866802f17",
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
"GET /api/v1/health": "5e447272e72b2e5f",
"GET /api/v1/node": "8bb18b8c7cfb4f20",
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
"POST /api/v1/node/register": "539f7cb3a84d43d4",
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
"POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784",
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
"GET /api/v1/policy": "d6c639be304cd3c0",
"PUT /api/v1/policy": "6cbe80bde771a388",
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
"GET /api/v1/user": "228831b58ccc5a17",
"POST /api/v1/user": "a4e1d889d7962da5",
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
},
"0.27.1": {
"GET /api/v1/apikey": "efe31b6dc980e158",
"POST /api/v1/apikey": "39953a96c1da5312",
"POST /api/v1/apikey/expire": "ca56add866802f17",
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
"GET /api/v1/health": "5e447272e72b2e5f",
"GET /api/v1/node": "8bb18b8c7cfb4f20",
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
"POST /api/v1/node/register": "539f7cb3a84d43d4",
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
"POST /api/v1/node/{nodeId}/expire": "53efc8e2017c16ae",
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
"GET /api/v1/policy": "d6c639be304cd3c0",
"PUT /api/v1/policy": "6cbe80bde771a388",
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
"GET /api/v1/user": "228831b58ccc5a17",
"POST /api/v1/user": "a4e1d889d7962da5",
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
},
"0.28.0": {
"GET /api/v1/apikey": "efe31b6dc980e158",
"POST /api/v1/apikey": "39953a96c1da5312",
"POST /api/v1/apikey/expire": "ca56add866802f17",
"DELETE /api/v1/apikey/{prefix}": "b10ca7d2750405b2",
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
"GET /api/v1/health": "5e447272e72b2e5f",
"GET /api/v1/node": "8bb18b8c7cfb4f20",
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
"POST /api/v1/node/register": "539f7cb3a84d43d4",
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
"POST /api/v1/node/{nodeId}/expire": "53efc8e2017c16ae",
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
"GET /api/v1/policy": "d6c639be304cd3c0",
"PUT /api/v1/policy": "6cbe80bde771a388",
"GET /api/v1/preauthkey": "8428b44e3a821e9e",
"DELETE /api/v1/preauthkey": "f05ea1bc8ad89a09",
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
"GET /api/v1/user": "228831b58ccc5a17",
"POST /api/v1/user": "a4e1d889d7962da5",
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
}
}
+28 -67
View File
@@ -1,5 +1,6 @@
import { data } from "react-router";
import { authContext, requestApiContext } from "~/server/context";
import { isDataWithApiError } from "~/server/headscale/api/error-client";
import { Capabilities } from "~/server/web/roles";
@@ -9,8 +10,11 @@ import type { Route } from "./+types/overview";
// If it isn't, it'll gracefully error anyways, since this means some
// fishy client manipulation is happening.
export async function aclAction({ request, context }: Route.ActionArgs) {
const principal = await context.auth.require(request);
const check = context.auth.can(principal, Capabilities.write_policy);
const auth = context.get(authContext);
const getRequestApi = context.get(requestApiContext);
const principal = await auth.require(request);
const check = auth.can(principal, Capabilities.write_policy);
if (!check) {
throw data("You do not have permission to write to the ACL policy", {
status: 403,
@@ -26,10 +30,9 @@ export async function aclAction({ request, context }: Route.ActionArgs) {
});
}
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
const { api } = await getRequestApi(request);
try {
const { policy, updatedAt } = await api.setPolicy(policyData);
const { policy, updatedAt } = await api.policy.set(policyData);
return data({
success: true,
error: undefined,
@@ -55,71 +58,29 @@ export async function aclAction({ request, context }: Route.ActionArgs) {
throw error;
}
// Starting in Headscale 0.27.0 the ACLs parsing was changed meaning
// we need to reference other error messages based on API version.
if (context.hsApi.clientHelpers.isAtleast("0.27.0")) {
if (message.includes("parsing HuJSON:")) {
const cutIndex = message.indexOf("parsing HuJSON:");
const trimmed =
cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 16).trim()}` : message;
// Policy parse errors carry one of two prefixes (HuJSON syntax vs.
// structural unmarshal). Headscale 0.27.0+ uses these forms; older
// releases are no longer supported.
if (message.includes("parsing HuJSON:")) {
const cutIndex = message.indexOf("parsing HuJSON:");
const trimmed =
cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 16).trim()}` : message;
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
return data(
{ success: false, error: trimmed, policy: undefined, updatedAt: undefined },
400,
);
}
if (message.includes("parsing policy from bytes:")) {
const cutIndex = message.indexOf("parsing policy from bytes:");
const trimmed =
cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 26).trim()}` : message;
if (message.includes("parsing policy from bytes:")) {
const cutIndex = message.indexOf("parsing policy from bytes:");
const trimmed =
cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 26).trim()}` : message;
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
} else {
// Pre-0.27.0 error messages
if (message.includes("parsing hujson")) {
const cutIndex = message.indexOf("err: hujson:");
const trimmed = cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 12)}` : message;
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
if (message.includes("unmarshalling policy")) {
const cutIndex = message.indexOf("err:");
const trimmed = cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 5)}` : message;
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
return data(
{ success: false, error: trimmed, policy: undefined, updatedAt: undefined },
400,
);
}
}
+10 -7
View File
@@ -1,5 +1,6 @@
import { data } from "react-router";
import { authContext, requestApiContext } from "~/server/context";
import { isDataWithApiError } from "~/server/headscale/api/error-client";
import { Capabilities } from "~/server/web/roles";
@@ -11,10 +12,13 @@ import type { Route } from "./+types/overview";
// 2. Does the user have permission to write to the policy?
// 3. Is the Headscale policy in file or database mode?
// If database, we can read/write easily via the API.
// If in file mode, we can only write if context.config is available.
// If in file mode, we can only write if the Headscale config is available.
export async function aclLoader({ request, context }: Route.LoaderArgs) {
const principal = await context.auth.require(request);
const check = context.auth.can(principal, Capabilities.read_policy);
const auth = context.get(authContext);
const getRequestApi = context.get(requestApiContext);
const principal = await auth.require(request);
const check = auth.can(principal, Capabilities.read_policy);
if (!check) {
throw data("You do not have permission to read the ACL policy.", {
status: 403,
@@ -23,16 +27,15 @@ export async function aclLoader({ request, context }: Route.LoaderArgs) {
const flags = {
// Can the user write to the ACL policy
access: context.auth.can(principal, Capabilities.write_policy),
access: auth.can(principal, Capabilities.write_policy),
writable: false,
policy: "",
};
// Try to load the ACL policy from the API.
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
const { api } = await getRequestApi(request);
try {
const { policy, updatedAt } = await api.getPolicy();
const { policy, updatedAt } = await api.policy.get();
flags.writable = updatedAt !== null;
flags.policy = policy;
return flags;
+9 -3
View File
@@ -1,11 +1,15 @@
import { redirect } from "react-router";
import { authContext, headscaleContext } from "~/server/context";
import { isDataWithApiError } from "~/server/headscale/api/error-client";
import log from "~/utils/log";
import type { Route } from "./+types/page";
export async function loginAction({ request, context }: Route.LoaderArgs) {
const auth = context.get(authContext);
const headscale = context.get(headscaleContext);
const formData = await request.formData();
const apiKey = formData.has("api_key") ? String(formData.get("api_key")) : undefined;
@@ -33,9 +37,11 @@ export async function loginAction({ request, context }: Route.LoaderArgs) {
};
}
const api = context.hsApi.getRuntimeClient(apiKey);
// Build a client with the candidate API key the user just submitted, so the
// GET /api/v1/apikey call below validates the key against Headscale itself.
const api = headscale.client(apiKey);
try {
const apiKeys = await api.getApiKeys();
const apiKeys = await api.apiKeys.list();
// We don't need to check for 0 API keys because this request cannot
// be authenticated correctly without an API key
@@ -68,7 +74,7 @@ export async function loginAction({ request, context }: Route.LoaderArgs) {
return redirect("/machines", {
headers: {
"Set-Cookie": await context.auth.createApiKeySession(
"Set-Cookie": await auth.createApiKeySession(
apiKey,
`${lookup.prefix}...`,
expiry.getTime() - Date.now(),
+39 -12
View File
@@ -7,7 +7,10 @@ import Card from "~/components/card";
import Code from "~/components/code";
import Input from "~/components/input";
import Link from "~/components/link";
import { appConfigContext, authContext, oidcContext } from "~/server/context";
import type { OidcError, OidcService } from "~/server/oidc/provider";
import { useLiveData } from "~/utils/live-data";
import log from "~/utils/log";
import type { Route } from "./+types/page";
import { loginAction } from "./action";
@@ -15,24 +18,41 @@ import { OidcConfigErrorNotice, OidcDiscoveryFailedNotice } from "./config-error
import Logout from "./logout";
import { OidcErrorNotice } from "./oidc-error";
export async function loader({ request, context }: Route.LoaderArgs) {
export async function loader({ request, context, url }: Route.LoaderArgs) {
const auth = context.get(authContext);
const config = context.get(appConfigContext);
const oidc = context.get(oidcContext);
try {
await context.auth.require(request);
await auth.require(request);
return redirect("/machines");
} catch {}
const qp = new URL(request.url).searchParams;
const qp = url.searchParams;
const urlState = qp.get("s") ?? undefined;
const oidcService = context.oidc?.service;
const oidcStatus = oidcService
? await oidcService.discover().then(
(r) => (r.ok ? oidcService.status() : oidcService.status()),
() => oidcService.status(),
)
: undefined;
const oidcService = oidc.state === "enabled" ? oidc.value : undefined;
let oidcStatus: ReturnType<OidcService["status"]> | undefined;
if (oidcService) {
try {
const result = await oidcService.discover();
if (!result.ok) {
logLoginOidcError("OIDC discovery failed", result.error);
}
} catch (error) {
log.error("auth", "OIDC discovery failed unexpectedly: %s", String(error));
log.debug("auth", "OIDC discovery error details: %o", error);
}
if (context.oidc?.disableApiKeyLogin && oidcStatus?.state === "ready" && urlState !== "logout") {
oidcStatus = oidcService.status();
}
if (
oidcService &&
config.oidc?.disable_api_key_login &&
oidcStatus?.state === "ready" &&
urlState !== "logout"
) {
return redirect("/oidc/start");
}
@@ -40,7 +60,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
const oidcErrorCodes = oidcStatus?.state === "error" ? [oidcStatus.error.code] : [];
return {
isCookieSecureEnabled: context.config.server.cookie_secure,
isCookieSecureEnabled: config.server.cookie_secure,
isOidcConnectorEnabled,
oidcErrorCodes,
urlState,
@@ -49,6 +69,13 @@ export async function loader({ request, context }: Route.LoaderArgs) {
export const action = loginAction;
function logLoginOidcError(context: string, error: OidcError): void {
log.error("auth", "%s [%s]: %s", context, error.code, error.message);
if (error.hint) {
log.error("auth", "Hint: %s", error.hint);
}
}
export default function Page({ loaderData, actionData }: Route.ComponentProps) {
const { isCookieSecureEnabled, isOidcConnectorEnabled, oidcErrorCodes, urlState } = loaderData;
+15 -10
View File
@@ -1,37 +1,42 @@
import { type ActionFunctionArgs, redirect } from "react-router";
import type { AppContext } from "~/server/context";
import { appConfigContext, authContext, oidcContext } from "~/server/context";
export async function loader() {
return redirect("/machines");
}
export async function action({ request, context }: ActionFunctionArgs<AppContext>) {
let principal: Awaited<ReturnType<typeof context.auth.require>> | undefined;
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 {
principal = await context.auth.require(request);
principal = await auth.require(request);
} catch {
return redirect("/login");
}
// When API key is disabled, we need to explicitly redirect
// with a logout state to prevent auto login again.
let 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" && context.oidc?.useEndSession && context.oidc.service) {
const status = context.oidc.service.status();
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 context.oidc.service.discover();
await service.discover();
}
const endSessionUrl = context.oidc.service.buildEndSessionUrl(principal.idToken);
const endSessionUrl = service.buildEndSessionUrl(principal.idToken);
if (endSessionUrl) {
url = endSessionUrl;
}
@@ -39,7 +44,7 @@ export async function action({ request, context }: ActionFunctionArgs<AppContext
return redirect(url, {
headers: {
"Set-Cookie": await context.auth.destroySession(request),
"Set-Cookie": await auth.destroySession(request),
},
});
}
+47 -21
View File
@@ -1,23 +1,38 @@
import { data, redirect } from "react-router";
import {
appConfigContext,
authContext,
headscaleApiKeyContext,
headscaleContext,
oidcContext,
} from "~/server/context";
import { logOidcError } from "~/server/oidc/provider";
import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity";
import { Roles } from "~/server/web/roles";
import log from "~/utils/log";
import { createOidcStateCookie } from "~/utils/oidc-state";
import type { Route } from "./+types/oidc-callback";
export async function loader({ request, context }: Route.LoaderArgs) {
const service = context.oidc?.service;
if (!service) {
throw data("OIDC is not enabled or misconfigured", { status: 501 });
}
export async function loader({ request, context, url }: Route.LoaderArgs) {
const auth = context.get(authContext);
const config = context.get(appConfigContext);
const headscale = context.get(headscaleContext);
const headscaleApiKey = context.get(headscaleApiKeyContext);
const oidc = context.get(oidcContext);
if (oidc.state !== "enabled") {
throw data(`OIDC is unavailable: ${oidc.reason}`, { status: 501 });
}
const service = oidc.value;
const url = new URL(request.url);
if (url.searchParams.toString().length === 0) {
log.warn("auth", "Called OIDC callback without query parameters");
return redirect("/login?s=error_no_query");
}
const cookie = createOidcStateCookie(context.config);
const cookie = createOidcStateCookie(config);
const oidcCookieState = await cookie.parse(request.headers.get("Cookie"));
if (oidcCookieState == null) {
@@ -40,27 +55,38 @@ 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));
@@ -68,11 +94,11 @@ export async function loader({ request, context }: Route.LoaderArgs) {
// Only persist the id_token when RP-initiated logout is enabled — otherwise
// we'd be storing a credential we never use.
const idToken = context.oidc?.useEndSession ? identity.idToken : undefined;
const idToken = config.oidc?.use_end_session ? identity.idToken : undefined;
return redirect("/", {
headers: {
"Set-Cookie": await context.auth.createOidcSession(
"Set-Cookie": await auth.createOidcSession(
userId,
{
name: identity.name,
+12 -5
View File
@@ -1,27 +1,34 @@
import { data, redirect } from "react-router";
import { appConfigContext, authContext, oidcContext } from "~/server/context";
import { logOidcError } from "~/server/oidc/provider";
import { createOidcStateCookie } from "~/utils/oidc-state";
import type { Route } from "./+types/oidc-start";
export async function loader({ request, context }: Route.LoaderArgs) {
const auth = context.get(authContext);
const config = context.get(appConfigContext);
const oidc = context.get(oidcContext);
try {
await context.auth.require(request);
await auth.require(request);
return redirect("/");
} catch {}
const service = context.oidc?.service;
if (!service) {
throw data("OIDC is not enabled or misconfigured", { status: 501 });
if (oidc.state !== "enabled") {
throw data(`OIDC is unavailable: ${oidc.reason}`, { status: 501 });
}
const service = oidc.value;
const result = await service.startFlow();
if (!result.ok) {
logOidcError("OIDC start failed", result.error);
return redirect(`/login?s=${result.error.code}`);
}
const { url, flowState } = result.value;
const cookie = createOidcStateCookie(context.config);
const cookie = createOidcStateCookie(config);
return redirect(url, {
status: 302,
+45 -40
View File
@@ -1,24 +1,32 @@
import { data } from "react-router";
import {
authContext,
headscaleConfigContext,
headscaleContext,
integrationContext,
} from "~/server/context";
import { Capabilities } from "~/server/web/roles";
import type { Route } from "./+types/overview";
export async function dnsAction({ request, context }: Route.ActionArgs) {
const principal = await context.auth.require(request);
const check = context.auth.can(principal, Capabilities.write_network);
const auth = context.get(authContext);
const headscale = context.get(headscaleContext);
const headscaleConfig = context.get(headscaleConfigContext);
const integration = context.get(integrationContext);
const principal = await auth.require(request);
const check = auth.can(principal, Capabilities.write_network);
if (!check) {
return data({ success: false }, 403);
}
if (!context.hs.writable()) {
if (!headscaleConfig.writable()) {
return data({ success: false }, 403);
}
// We only need it for health checks which don't require auth
const api = context.hsApi.getRuntimeClient("fake-api-key");
const formData = await request.formData();
const action = formData.get("action_id")?.toString();
if (!action) {
@@ -32,14 +40,14 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
return data({ success: false }, 400);
}
await context.hs.patch([
await headscaleConfig.patch([
{
path: "dns.base_domain",
value: newName,
},
]);
await context.integration?.onConfigChange(api);
await integration?.onConfigChange(headscale);
return { message: "Tailnet renamed successfully" };
}
case "toggle_magic": {
@@ -48,18 +56,18 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
return data({ success: false }, 400);
}
await context.hs.patch([
await headscaleConfig.patch([
{
path: "dns.magic_dns",
value: newState === "enabled",
},
]);
await context.integration?.onConfigChange(api);
await integration?.onConfigChange(headscale);
return { message: "Magic DNS state updated successfully" };
}
case "remove_ns": {
const config = context.hs.c!;
const config = headscaleConfig.getDNSConfig();
const ns = formData.get("ns")?.toString();
const splitName = formData.get("split_name")?.toString();
@@ -68,19 +76,19 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
}
if (splitName === "global") {
const servers = config.dns.nameservers.global.filter((i) => i !== ns);
const servers = config.nameservers.filter((i) => i !== ns);
await context.hs.patch([
await headscaleConfig.patch([
{
path: "dns.nameservers.global",
value: servers,
},
]);
} else {
const splits = config.dns.nameservers.split;
const splits = config.splitDns;
const servers = splits[splitName].filter((i) => i !== ns);
await context.hs.patch([
await headscaleConfig.patch([
{
path: `dns.nameservers.split."${splitName}"`,
value: servers.length > 0 ? servers : null,
@@ -88,11 +96,11 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
]);
}
await context.integration?.onConfigChange(api);
await integration?.onConfigChange(headscale);
return { message: "Nameserver removed successfully" };
}
case "add_ns": {
const config = context.hs.c!;
const config = headscaleConfig.getDNSConfig();
const ns = formData.get("ns")?.toString();
const splitName = formData.get("split_name")?.toString();
@@ -101,21 +109,19 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
}
if (splitName === "global") {
const servers = config.dns.nameservers.global;
servers.push(ns);
const servers = [...config.nameservers, ns];
await context.hs.patch([
await headscaleConfig.patch([
{
path: "dns.nameservers.global",
value: servers,
},
]);
} else {
const splits = config.dns.nameservers.split;
const servers = splits[splitName] ?? [];
servers.push(ns);
const splits = config.splitDns;
const servers = [...(splits[splitName] ?? []), ns];
await context.hs.patch([
await headscaleConfig.patch([
{
path: `dns.nameservers.split."${splitName}"`,
value: servers,
@@ -123,45 +129,44 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
]);
}
await context.integration?.onConfigChange(api);
await integration?.onConfigChange(headscale);
return { message: "Nameserver added successfully" };
}
case "remove_domain": {
const config = context.hs.c!;
const config = headscaleConfig.getDNSConfig();
const domain = formData.get("domain")?.toString();
if (!domain) {
return data({ success: false }, 400);
}
const domains = config.dns.search_domains.filter((i) => i !== domain);
await context.hs.patch([
const domains = config.searchDomains.filter((i) => i !== domain);
await headscaleConfig.patch([
{
path: "dns.search_domains",
value: domains,
},
]);
await context.integration?.onConfigChange(api);
await integration?.onConfigChange(headscale);
return { message: "Domain removed successfully" };
}
case "add_domain": {
const config = context.hs.c!;
const config = headscaleConfig.getDNSConfig();
const domain = formData.get("domain")?.toString();
if (!domain) {
return data({ success: false }, 400);
}
const domains = config.dns.search_domains;
domains.push(domain);
const domains = [...config.searchDomains, domain];
await context.hs.patch([
await headscaleConfig.patch([
{
path: "dns.search_domains",
value: domains,
},
]);
await context.integration?.onConfigChange(api);
await integration?.onConfigChange(headscale);
return { message: "Domain added successfully" };
}
case "remove_record": {
@@ -173,7 +178,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
}
// Value is not needed for removal
const restart = await context.hs.removeDNS({
const restart = await headscaleConfig.removeDNS({
name: recordName,
type: recordType,
value: "",
@@ -183,7 +188,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
return;
}
await context.integration?.onConfigChange(api);
await integration?.onConfigChange(headscale);
return { message: "DNS record removed successfully" };
}
case "add_record": {
@@ -195,7 +200,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
return data({ success: false }, 400);
}
const restart = await context.hs.addDNS({
const restart = await headscaleConfig.addDNS({
name: recordName,
type: recordType,
value: recordValue,
@@ -205,7 +210,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
return;
}
await context.integration?.onConfigChange(api);
await integration?.onConfigChange(headscale);
return { message: "DNS record added successfully" };
}
case "override_dns": {
@@ -215,14 +220,14 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
}
const overrideValue = override === "true";
await context.hs.patch([
await headscaleConfig.patch([
{
path: "dns.override_local_dns",
value: overrideValue,
},
]);
await context.integration?.onConfigChange(api);
await integration?.onConfigChange(headscale);
return { message: "DNS override updated successfully" };
}
default:
+13 -19
View File
@@ -1,12 +1,13 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router";
import type { ActionFunctionArgs } from "react-router";
import { useLoaderData } from "react-router";
import Code from "~/components/code";
import Notice from "~/components/notice";
import PageError from "~/components/page-error";
import type { AppContext } from "~/server/context";
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<AppContext>) {
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<AppContext
);
}
const writablePermission = context.auth.can(principal, Capabilities.write_network);
const writablePermission = auth.can(principal, Capabilities.write_network);
const config = context.hs.c!;
const dns = {
prefixes: config.prefixes,
magicDns: config.dns.magic_dns,
baseDomain: config.dns.base_domain,
nameservers: config.dns.nameservers.global,
splitDns: config.dns.nameservers.split,
searchDomains: config.dns.search_domains,
overrideDns: config.dns.override_local_dns,
extraRecords: context.hs.d,
};
const dns = headscaleConfig.getDNSConfig();
return {
...dns,
access: writablePermission,
writable: context.hs.writable(),
writable: headscaleConfig.writable(),
};
}
+29 -21
View File
@@ -10,7 +10,14 @@ import Card from "~/components/card";
import CodeBlock from "~/components/code-block";
import Link from "~/components/link";
import LinkAccount from "~/layout/link-account";
import {
authContext,
headscaleConfigContext,
headscaleLiveStoreContext,
requestApiContext,
} from "~/server/context";
import { usersResource } from "~/server/headscale/live-store";
import { isUserPrincipal } from "~/server/web/auth";
import { Capabilities } from "~/server/web/roles";
import cn from "~/utils/cn";
import { getUserDisplayName } from "~/utils/user";
@@ -18,24 +25,24 @@ import { getUserDisplayName } from "~/utils/user";
import type { Route } from "./+types/home";
export async function loader({ request, context }: Route.LoaderArgs) {
const principal = await context.auth.require(request);
const auth = context.get(authContext);
const getRequestApi = context.get(requestApiContext);
const headscaleConfig = context.get(headscaleConfigContext);
const headscaleLiveStore = context.get(headscaleLiveStoreContext);
// If the OIDC user has no linked Headscale user, check for
// Unclaimed users they can pick from before anything else.
const principal = await auth.require(request);
// If the signed-in Headplane user has no linked Headscale user,
// check for unclaimed users they can pick from before anything else.
let unlinked = false;
if (
typeof context.oidc === "object" &&
principal.kind === "oidc" &&
!principal.user.headscaleUserId
) {
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
if (isUserPrincipal(principal) && !principal.user.headscaleUserId) {
const { api } = await getRequestApi(request);
let headscaleUsers: { id: string; name: string }[] = [];
try {
const [usersSnap, claimed] = await Promise.all([
context.hsLive.get(usersResource, api),
context.auth.claimedHeadscaleUserIds(),
headscaleLiveStore.get(usersResource, api),
auth.claimedHeadscaleUserIds(),
]);
const apiUsers = usersSnap.data;
@@ -54,23 +61,22 @@ export async function loader({ request, context }: Route.LoaderArgs) {
// Only warn if Headscale isn't using OIDC — if it is, the user
// Just needs to connect a device and Headscale will auto-create
// Their account, at which point auto-link will pick it up.
if (!context.hs.c?.oidc) {
if (!headscaleConfig.hasOIDCConfig()) {
unlinked = true;
}
}
if (context.auth.can(principal, Capabilities.ui_access)) {
if (auth.can(principal, Capabilities.ui_access)) {
return redirect("/machines");
}
// No UI access — show the download/connect page
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
const { api } = await getRequestApi(request);
let linkedUserName: string | undefined;
if (principal.kind === "oidc" && principal.user.headscaleUserId) {
if (isUserPrincipal(principal) && principal.user.headscaleUserId) {
try {
const usersSnap = await context.hsLive.get(usersResource, api);
const usersSnap = await headscaleLiveStore.get(usersResource, api);
const hsUser = usersSnap.data.find((u) => u.id === principal.user.headscaleUserId);
linkedUserName = hsUser?.name;
} catch {
@@ -82,8 +88,10 @@ export async function loader({ request, context }: Route.LoaderArgs) {
}
export async function action({ request, context }: Route.ActionArgs) {
const principal = await context.auth.require(request);
if (principal.kind !== "oidc") {
const auth = context.get(authContext);
const principal = await auth.require(request);
if (!isUserPrincipal(principal)) {
return redirect("/");
}
@@ -91,7 +99,7 @@ export async function action({ request, context }: Route.ActionArgs) {
const headscaleUserId = formData.get("headscale_user_id")?.toString();
if (headscaleUserId) {
await context.auth.linkHeadscaleUser(principal.user.id, headscaleUserId);
await auth.linkHeadscaleUser(principal.user.id, headscaleUserId);
}
return redirect("/");
+17 -4
View File
@@ -12,10 +12,11 @@ import Text from "~/components/text";
import Title from "~/components/title";
import { useForm } from "~/hooks/use-form";
import type { User } from "~/types";
import { normalizeRegistrationKey } from "~/utils/register-key";
import { getUserDisplayName } from "~/utils/user";
const registerSchema = type({
register_key: "string == 24",
register_key: "string > 0",
user: "string > 0",
});
@@ -28,7 +29,16 @@ export interface NewMachineProps {
export default function NewMachine(data: NewMachineProps) {
const [pushDialog, setPushDialog] = useState(false);
const form = useForm({ schema: registerSchema });
const form = useForm({
schema: registerSchema,
validate: (values) =>
normalizeRegistrationKey(String(values.register_key ?? ""))
? undefined
: {
register_key:
"Paste the registration URL or full hskey-authreq-... key from tailscale up.",
},
});
const navigate = useNavigate();
return (
@@ -43,7 +53,8 @@ export default function NewMachine(data: NewMachineProps) {
{...form.field("register_key")}
required
label="Machine Key"
placeholder="AbCd..."
placeholder="hskey-authreq-XXXXXXXXXXXXXXXXXXXXXXXX"
description="Paste the registration URL or full key shown by tailscale up."
/>
<Select
required
@@ -52,7 +63,9 @@ export default function NewMachine(data: NewMachineProps) {
onValueChange={(v) => form.setValue("user", v)}
placeholder="Select a user"
items={data.users.map((user) => ({
value: user.id,
// Headscale's v1/node/register endpoint resolves the owner by
// username via GetUserByName, so we must pass user.name (not id).
value: user.name,
label: getUserDisplayName(user),
}))}
/>
+13 -1
View File
@@ -12,6 +12,17 @@ const renameSchema = type({
name: "string > 0",
});
const dnsLabelPattern = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
function validateMachineName(values: Record<string, unknown>) {
const name = String(values.name ?? "").toLowerCase();
if (!dnsLabelPattern.test(name)) {
return {
name: "Use a valid DNS label: lowercase letters, numbers, and hyphens only. It must start and end with a letter or number.",
};
}
}
interface RenameProps {
machine: Machine;
isOpen: boolean;
@@ -23,12 +34,13 @@ export default function Rename({ machine, magic, isOpen, setIsOpen }: RenameProp
const form = useForm({
schema: renameSchema,
defaultValues: { name: machine.givenName },
validate: validateMachineName,
});
const name = form.values.name as string;
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<DialogPanel>
<DialogPanel isDisabled={!form.canSubmit}>
<Title>Edit machine name for {machine.givenName}</Title>
<Text className="mb-6">
This name is shown in the admin panel, in Tailscale clients, and used when generating
+21
View File
@@ -24,6 +24,10 @@ export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsP
const submittingRef = useRef(false);
const [tags, setTags] = useState([...machine.tags]);
const [tag, setTag] = useState("tag:");
const tagOptions = useMemo(
() => (existingTags ?? []).filter((existingTag) => !tags.includes(existingTag)),
[existingTags, tags],
);
const tagIsInvalid = useMemo(
() => tag.length === 0 || !tag.startsWith("tag:") || tags.includes(tag),
[tag, tags],
@@ -98,6 +102,7 @@ export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsP
onClick={() => {
setTags(tags.filter((tag) => tag !== item));
}}
type="button"
>
<X className="p-1" />
</Button>
@@ -124,10 +129,26 @@ export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsP
setTags([...tags, tag]);
setTag("tag:");
}}
type="button"
>
<Plus className="p-1" size={30} />
</Button>
</div>
{tagOptions.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{tagOptions.map((option) => (
<Button
className="px-2 py-1 font-mono text-xs"
key={option}
onClick={() => setTags([...tags, option])}
type="button"
variant="ghost"
>
{option}
</Button>
))}
</div>
) : null}
<p className="mt-2 text-sm opacity-50">
Not seeing the tags you expect? Tags need to be defined in your access control policy
before they can be assigned to machines.
+57 -21
View File
@@ -1,16 +1,21 @@
import { data, redirect } from "react-router";
import { authContext, headscaleLiveStoreContext, requestApiContext } from "~/server/context";
import { isDataWithApiError } from "~/server/headscale/api/error-client";
import { nodesResource } from "~/server/headscale/live-store";
import { Capabilities } from "~/server/web/roles";
import { normalizeRegistrationKey } from "~/utils/register-key";
import type { Route } from "./+types/machine";
export async function machineAction({ request, context }: Route.ActionArgs) {
const principal = await context.auth.require(request);
const auth = context.get(authContext);
const getRequestApi = context.get(requestApiContext);
const headscaleLiveStore = context.get(headscaleLiveStoreContext);
const { principal, api } = await getRequestApi(request);
const formData = await request.formData();
const api = context.hsApi.getRuntimeClient(context.auth.getHeadscaleApiKey(principal));
const action = formData.get("action_id")?.toString();
if (!action) {
@@ -21,19 +26,26 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
// Fast track register since it doesn't require an existing machine
if (action === "register") {
if (!context.auth.can(principal, Capabilities.write_machines)) {
if (!auth.can(principal, Capabilities.write_machines)) {
throw data("You do not have permission to manage machines", {
status: 403,
});
}
const registrationKey = formData.get("register_key")?.toString();
if (!registrationKey) {
const registrationKeyInput = formData.get("register_key")?.toString();
if (!registrationKeyInput) {
throw data("Missing `register_key` in the form data.", {
status: 400,
});
}
const registrationKey = normalizeRegistrationKey(registrationKeyInput);
if (!registrationKey) {
throw data("Invalid `register_key` in the form data.", {
status: 400,
});
}
const user = formData.get("user")?.toString();
if (!user) {
throw data("Missing `user` in the form data.", {
@@ -41,8 +53,8 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
});
}
const node = await api.registerNode(user, registrationKey);
await context.hsLive.refresh(nodesResource, api);
const node = await api.nodes.register(user, registrationKey);
await headscaleLiveStore.refresh(nodesResource, api);
return redirect(`/machines/${node.id}`);
}
@@ -54,14 +66,14 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
});
}
const node = await api.getNode(nodeId);
const node = await api.nodes.get(nodeId);
if (!node) {
throw data(`Machine with ID ${nodeId} not found`, {
status: 404,
});
}
if (!context.auth.canManageNode(principal, node)) {
if (!auth.canManageNode(principal, node)) {
throw data("You do not have permission to act on this machine", {
status: 403,
});
@@ -77,20 +89,27 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
}
const name = String(formData.get("name"));
await api.renameNode(nodeId, name);
await context.hsLive.refresh(nodesResource, api);
if (!/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(name.toLowerCase())) {
throw data(
"Machine names must be valid DNS labels: lowercase letters, numbers, and hyphens only, and must start and end with a letter or number.",
{ status: 400 },
);
}
await api.nodes.rename(nodeId, name);
await headscaleLiveStore.refresh(nodesResource, api);
return { message: "Machine renamed" };
}
case "delete": {
await api.deleteNode(nodeId);
await context.hsLive.refresh(nodesResource, api);
await api.nodes.delete(nodeId);
await headscaleLiveStore.refresh(nodesResource, api);
return redirect("/machines");
}
case "expire": {
await api.expireNode(nodeId);
await context.hsLive.refresh(nodesResource, api);
await api.nodes.expire(nodeId);
await headscaleLiveStore.refresh(nodesResource, api);
return { message: "Machine expired" };
}
@@ -103,12 +122,12 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
}
try {
await api.setNodeTags(
await api.nodes.setTags(
nodeId,
tags.map((tag) => tag.trim()).filter((tag) => tag !== ""),
);
await context.hsLive.refresh(nodesResource, api);
await headscaleLiveStore.refresh(nodesResource, api);
return { success: true as const, message: "Tags updated" };
} catch (error) {
if (isDataWithApiError(error) && error.data.statusCode === 400) {
@@ -116,6 +135,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
{
success: false as const,
error:
extractApiErrorMessage(error.data) ??
"One or more tags are not defined in your ACL policy. Please add them to your policy before assigning them to a machine.",
},
{ status: 400 },
@@ -172,8 +192,8 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
}
}
await api.approveNodeRoutes(nodeId, newApproved);
await context.hsLive.refresh(nodesResource, api);
await api.nodes.approveRoutes(nodeId, newApproved);
await headscaleLiveStore.refresh(nodesResource, api);
return { message: "Routes updated" };
}
@@ -185,8 +205,13 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
});
}
await api.setNodeUser(nodeId, user);
await context.hsLive.refresh(nodesResource, api);
if (!api.nodes.reassignUser) {
throw data("Reassigning a node owner is no longer supported on this Headscale version.", {
status: 400,
});
}
await api.nodes.reassignUser(nodeId, user);
await headscaleLiveStore.refresh(nodesResource, api);
return { message: "Machine reassigned" };
}
@@ -196,3 +221,14 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
});
}
}
function extractApiErrorMessage(error: { data?: unknown; rawData: string }) {
if (error.data != null && typeof error.data === "object" && "message" in error.data) {
const message = (error.data as { message?: unknown }).message;
if (typeof message === "string" && message.length > 0) {
return message;
}
}
return error.rawData.length > 0 ? error.rawData : undefined;
}
+31 -18
View File
@@ -9,10 +9,17 @@ import Chip from "~/components/chip";
import Link from "~/components/link";
import StatusCircle from "~/components/status-circle";
import Tooltip from "~/components/tooltip";
import {
agentsContext,
headscaleConfigContext,
headscaleContext,
headscaleLiveStoreContext,
requestApiContext,
} from "~/server/context";
import { nodesResource, usersResource } from "~/server/headscale/live-store";
import cn from "~/utils/cn";
import { getOSInfo, getTSVersion } from "~/utils/host-info";
import { isNoExpiry, 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,
+42 -22
View File
@@ -7,10 +7,20 @@ import Input from "~/components/input";
import Link from "~/components/link";
import PageError from "~/components/page-error";
import Tooltip from "~/components/tooltip";
import {
agentsContext,
appConfigContext,
authContext,
headscaleConfigContext,
headscaleContext,
headscaleLiveStoreContext,
requestApiContext,
} from "~/server/context";
import { nodesResource, usersResource } from "~/server/headscale/live-store";
import { isUserPrincipal } from "~/server/web/auth";
import { Capabilities } from "~/server/web/roles";
import cn from "~/utils/cn";
import { mapNodes, sortNodeTags, type PopulatedNode } from "~/utils/node-info";
import { mapNodes, sortAssignableTags, type PopulatedNode } from "~/utils/node-info";
import type { Route } from "./+types/overview";
import { MachineFilters } from "./components/machine-filters";
@@ -20,51 +30,61 @@ import { useMachineFilterParams } from "./hooks/use-machine-filter-params";
import { machineAction } from "./machine-actions";
export async function loader({ request, context }: Route.LoaderArgs) {
const principal = await context.auth.require(request);
const agentsFeature = context.get(agentsContext);
const auth = context.get(authContext);
const config = context.get(appConfigContext);
const getRequestApi = context.get(requestApiContext);
const headscale = context.get(headscaleContext);
const headscaleConfig = context.get(headscaleConfigContext);
const headscaleLiveStore = context.get(headscaleLiveStoreContext);
if (!context.auth.can(principal, Capabilities.read_machines)) {
const principal = await auth.require(request);
if (!auth.can(principal, Capabilities.read_machines)) {
throw new Error(
"You do not have permission to view this page. Please contact your administrator.",
);
}
const writablePermission = context.auth.can(principal, Capabilities.write_machines);
const writablePermission = auth.can(principal, Capabilities.write_machines);
const api = context.hsApi.getRuntimeClient(context.auth.getHeadscaleApiKey(principal));
const { api } = await getRequestApi(request);
const [nodesSnap, usersSnap] = await Promise.all([
context.hsLive.get(nodesResource, api),
context.hsLive.get(usersResource, api),
headscaleLiveStore.get(nodesResource, api),
headscaleLiveStore.get(usersResource, api),
]);
const nodes = nodesSnap.data;
const users = usersSnap.data;
let magic: string | undefined;
if (context.hs.readable()) {
if (context.hs.c?.dns.magic_dns) {
magic = context.hs.c.dns.base_domain;
}
}
const magic = headscaleConfig.getMagicDNSBaseDomain();
const stats = await context.agents?.lookup(nodes.map((node) => node.nodeKey));
const agents = agentsFeature.state === "enabled" ? agentsFeature.value : undefined;
const [statsResult, policyResult] = await Promise.allSettled([
agents?.lookup(nodes.map((node) => node.nodeKey)),
api.policy.get(),
]);
const stats = statsResult.status === "fulfilled" ? statsResult.value : undefined;
const policy = policyResult.status === "fulfilled" ? policyResult.value.policy : undefined;
const populatedNodes = mapNodes(nodes, stats);
const supportsNodeOwnerChange = !context.hsApi.clientHelpers.isAtleast("0.28.0");
const agentSync = context.agents?.lastSync();
const supportsNodeOwnerChange = !headscale.capabilities.nodeOwnerIsImmutable;
const agentSync = agents?.lastSync();
return {
agent: agentSync
? {
syncedAt: agentSync.syncedAt?.toISOString() ?? null,
nodeCount: agentSync.nodeCount,
nodeKey: context.agents?.agentNodeKey(),
nodeKey: agents?.agentNodeKey(),
}
: undefined,
headscaleUserId: principal.kind === "oidc" ? principal.user.headscaleUserId : undefined,
headscaleUserId: isUserPrincipal(principal) ? principal.user.headscaleUserId : undefined,
existingTags: sortAssignableTags(nodes, policy),
magic,
nodes,
populatedNodes,
preAuth: context.auth.can(principal, Capabilities.generate_authkeys),
publicServer: context.config.headscale.public_url,
server: context.config.headscale.url,
preAuth: auth.can(principal, Capabilities.generate_authkeys),
publicServer: config.headscale.public_url,
server: config.headscale.url,
supportsNodeOwnerChange: supportsNodeOwnerChange,
users,
writable: writablePermission,
@@ -422,7 +442,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
) : (
filteredAndSortedNodes.map((node) => (
<MachineRow
existingTags={sortNodeTags(loaderData.nodes)}
existingTags={loaderData.existingTags}
isAgent={
loaderData.agent !== undefined
? node.nodeKey === loaderData.agent.nodeKey
+40 -14
View File
@@ -6,36 +6,48 @@ import Notice from "~/components/notice";
import StatusCircle from "~/components/status-circle";
import Text from "~/components/text";
import Title from "~/components/title";
import { agentsContext, authContext } from "~/server/context";
import { formatTimeDelta } from "~/utils/time";
import type { Route } from "./+types/agent";
export async function loader({ request, context }: Route.LoaderArgs) {
const principal = await context.auth.require(request);
const agents = context.get(agentsContext);
const auth = context.get(authContext);
if (!context.agents) {
return { enabled: false as const };
await auth.require(request);
if (agents.state !== "enabled") {
return { enabled: false as const, reason: agents.reason };
}
const sync = context.agents.lastSync();
const sync = agents.value.lastSync();
return {
enabled: true as const,
syncedAt: sync.syncedAt?.toISOString() ?? null,
nodeCount: sync.nodeCount,
error: sync.error,
authUrl: sync.authUrl,
};
}
export async function action({ request, context }: Route.ActionArgs) {
await context.auth.require(request);
const agents = context.get(agentsContext);
const auth = context.get(authContext);
if (!context.agents) {
return { success: false, error: "Agent is not enabled" };
await auth.require(request);
if (agents.state !== "enabled") {
return { success: false, error: agents.reason };
}
await context.agents.triggerSync();
const sync = context.agents.lastSync();
return { success: !sync.error, error: sync.error };
await agents.value.triggerSync();
const sync = agents.value.lastSync();
return {
success: !sync.error,
error: sync.error,
authUrl: sync.authUrl,
};
}
export default function Page({ loaderData }: Route.ComponentProps) {
@@ -47,8 +59,8 @@ export default function Page({ loaderData }: Route.ComponentProps) {
<div className="flex max-w-(--breakpoint-lg) flex-col gap-8">
<Title>Headplane Agent</Title>
<Notice title="Agent Not Enabled">
The Headplane Agent is not enabled. To learn how to set up the agent, visit the{" "}
<Link external styled to="https://headplane.dev/docs/agent">
{loaderData.reason}. To learn how to set up the agent, visit the{" "}
<Link external styled to="https://headplane.net/features/agent">
documentation
</Link>
</Notice>
@@ -56,6 +68,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
);
}
const isPending = !loaderData.syncedAt && loaderData.authUrl;
const hasError = Boolean(loaderData.error);
return (
@@ -69,8 +82,10 @@ export default function Page({ loaderData }: Route.ComponentProps) {
</div>
<div className="flex items-center gap-3">
<StatusCircle isOnline={!hasError} className="h-5 w-5" />
<span className="text-lg font-medium">{hasError ? "Error" : "Healthy"}</span>
<StatusCircle isOnline={!hasError && !isPending} className="h-5 w-5" />
<span className="text-lg font-medium">
{hasError ? "Error" : isPending ? "Waiting for approval" : "Healthy"}
</span>
</div>
<div className="flex flex-col gap-2">
@@ -88,6 +103,17 @@ export default function Page({ loaderData }: Route.ComponentProps) {
</Text>
</div>
{isPending ? (
<Notice variant="warning" title="Agent Needs Approval">
The agent is waiting for its Tailnet registration to be approved. Headplane will attempt
to auto-approve it, but if that fails, you can complete approval by visiting{" "}
<Link external styled to={loaderData.authUrl!}>
this link
</Link>
.
</Notice>
) : undefined}
{loaderData.error ? (
<Notice variant="error" title="Sync Error">
{loaderData.error}
+28 -14
View File
@@ -1,5 +1,7 @@
import { data } from "react-router";
import { authContext, requestApiContext } from "~/server/context";
import { isUserPrincipal } from "~/server/web/auth";
import { getOidcSubject } from "~/server/web/headscale-identity";
import { Capabilities } from "~/server/web/roles";
import type { PreAuthKey } from "~/types";
@@ -7,12 +9,13 @@ import type { PreAuthKey } from "~/types";
import type { Route } from "./+types/overview";
export async function authKeysAction({ request, context }: Route.ActionArgs) {
const principal = await context.auth.require(request);
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
const auth = context.get(authContext);
const getRequestApi = context.get(requestApiContext);
const canGenerateAny = context.auth.can(principal, Capabilities.generate_authkeys);
const canGenerateOwn = context.auth.can(principal, Capabilities.generate_own_authkeys);
const { principal, api } = await getRequestApi(request);
const canGenerateAny = auth.can(principal, Capabilities.generate_authkeys);
const canGenerateOwn = auth.can(principal, Capabilities.generate_own_authkeys);
if (!canGenerateAny && !canGenerateOwn) {
throw data("You do not have permission to manage pre-auth keys", {
@@ -22,12 +25,15 @@ export async function authKeysAction({ request, context }: Route.ActionArgs) {
async function checkSelfServiceOwnership(userId: string) {
if (canGenerateAny || !canGenerateOwn) return;
const [targetUser] = await api.getUsers(userId);
const [targetUser] = await api.users.list({ id: userId });
if (!targetUser) {
throw data("User not found.", { status: 404 });
}
const targetSubject = getOidcSubject(targetUser);
if (principal.kind !== "oidc" || targetSubject !== principal.user.subject) {
const ownsTarget =
isUserPrincipal(principal) &&
(principal.user.headscaleUserId === userId || targetSubject === principal.user.subject);
if (!ownsTarget) {
throw data("You do not have permission to manage this user's pre-auth keys", {
status: 403,
});
@@ -86,13 +92,13 @@ export async function authKeysAction({ request, context }: Route.ActionArgs) {
const date = new Date();
date.setDate(date.getDate() + day);
const key = await api.createPreAuthKey(
const key = await api.preAuthKeys.create({
user,
ephemeral === "on",
reusable === "on",
date,
aclTags.length > 0 ? aclTags : null,
);
ephemeral: ephemeral === "on",
reusable: reusable === "on",
expiration: date,
aclTags: aclTags.length > 0 ? aclTags : null,
});
return data({ success: true as const, key: key.key });
}
@@ -114,7 +120,15 @@ export async function authKeysAction({ request, context }: Route.ActionArgs) {
}
await checkSelfServiceOwnership(user);
await api.expirePreAuthKey(user, { id: keyId, key } as unknown as PreAuthKey);
// `user` here is the Headscale numeric user id (form field is wired
// from User.id). Pre-0.28 expire posts a uint64 `user` field, which
// the API layer reads from `key.user?.id`. Headscale 0.28+ only
// looks at `key.id` (the stable preauthkey id).
await api.preAuthKeys.expire({
id: keyId,
key,
user: { id: user },
} as unknown as PreAuthKey);
return data("Pre-auth key expired");
}
@@ -18,10 +18,22 @@ interface AddAuthKeyProps {
users: User[];
url: string;
selfServiceOnly: boolean;
currentHeadscaleUserId?: string;
currentSubject?: string;
}
function findCurrentUser(users: User[], subject: string | undefined): User | undefined {
function findCurrentUser(
users: User[],
headscaleUserId: string | undefined,
subject: string | undefined,
): User | undefined {
if (headscaleUserId) {
const linked = users.find((u) => u.id === headscaleUserId);
if (linked) {
return linked;
}
}
if (!subject) {
return undefined;
}
@@ -38,6 +50,7 @@ export default function AddAuthKey({
users,
url,
selfServiceOnly,
currentHeadscaleUserId,
currentSubject,
}: AddAuthKeyProps) {
const fetcher = useFetcher();
@@ -46,7 +59,9 @@ export default function AddAuthKey({
const [reusable, setReusable] = useState(false);
const [ephemeral, setEphemeral] = useState(false);
const [tagOnly, setTagOnly] = useState(false);
const currentUser = selfServiceOnly ? findCurrentUser(users, currentSubject) : null;
const currentUser = selfServiceOnly
? findCurrentUser(users, currentHeadscaleUserId, currentSubject)
: null;
const availableUsers = selfServiceOnly && currentUser ? [currentUser] : users;
const [userId, setUserId] = useState<string | null>(availableUsers[0]?.id);
const [tags, setTags] = useState("");
+37 -14
View File
@@ -6,7 +6,14 @@ import Link from "~/components/link";
import Notice from "~/components/notice";
import Select from "~/components/select";
import TableList from "~/components/table-list";
import {
appConfigContext,
authContext,
headscaleLiveStoreContext,
requestApiContext,
} from "~/server/context";
import { usersResource } from "~/server/headscale/live-store";
import { isUserPrincipal } from "~/server/web/auth";
import { Capabilities } from "~/server/web/roles";
import type { PreAuthKey } from "~/types";
import type { User } from "~/types/User";
@@ -19,11 +26,14 @@ import AuthKeyRow from "./auth-key-row";
import AddAuthKey from "./dialogs/add-auth-key";
export async function loader({ request, context }: Route.LoaderArgs) {
const principal = await context.auth.require(request);
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
const auth = context.get(authContext);
const config = context.get(appConfigContext);
const getRequestApi = context.get(requestApiContext);
const headscaleLiveStore = context.get(headscaleLiveStoreContext);
const usersSnap = await context.hsLive.get(usersResource, api);
const { principal, api } = await getRequestApi(request);
const usersSnap = await headscaleLiveStore.get(usersResource, api);
const users = usersSnap.data;
let keys: { user: User | null; preAuthKeys: PreAuthKey[] }[];
@@ -31,10 +41,12 @@ export async function loader({ request, context }: Route.LoaderArgs) {
// Try fetching all keys at once (Headscale 0.28+), fall back to per-user
let allKeys: PreAuthKey[] | null = null;
try {
allKeys = await api.getAllPreAuthKeys();
} catch {
// Older versions don't support this endpoint
if (api.preAuthKeys.listAll) {
try {
allKeys = await api.preAuthKeys.listAll();
} catch {
// Treat any failure as "no global list available" and fall through.
}
}
if (allKeys !== null) {
@@ -67,7 +79,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
.filter((u) => u.id?.length > 0)
.map(async (user) => {
try {
const preAuthKeys = await api.getPreAuthKeys(user.id);
const preAuthKeys = await api.preAuthKeys.listForUser(user.id);
return { preAuthKeys, success: true as const, user };
} catch (error) {
log.error("api", "GET /v1/preauthkey for %s: %o", user.name, error);
@@ -85,16 +97,17 @@ export async function loader({ request, context }: Route.LoaderArgs) {
.map(({ user, error }) => ({ error, user }));
}
const canGenerateAny = context.auth.can(principal, Capabilities.generate_authkeys);
const canGenerateOwn = context.auth.can(principal, Capabilities.generate_own_authkeys);
const canGenerateAny = auth.can(principal, Capabilities.generate_authkeys);
const canGenerateOwn = auth.can(principal, Capabilities.generate_own_authkeys);
return {
access: canGenerateAny || canGenerateOwn,
currentSubject: principal.kind === "oidc" ? principal.user.subject : undefined,
currentHeadscaleUserId: isUserPrincipal(principal) ? principal.user.headscaleUserId : undefined,
currentSubject: isUserPrincipal(principal) ? principal.user.subject : undefined,
keys,
missing,
selfServiceOnly: !canGenerateAny && canGenerateOwn,
url: context.config.headscale.public_url ?? context.config.headscale.url,
url: config.headscale.public_url ?? config.headscale.url,
users,
};
}
@@ -103,7 +116,16 @@ export const action = authKeysAction;
type Status = "all" | "active" | "expired" | "reusable" | "ephemeral";
export default function Page({
loaderData: { keys, missing, users, url, access, selfServiceOnly, currentSubject },
loaderData: {
keys,
missing,
users,
url,
access,
selfServiceOnly,
currentHeadscaleUserId,
currentSubject,
},
}: Route.ComponentProps) {
const [selectedUser, setSelectedUser] = useState("__headplane_all");
const [status, setStatus] = useState<Status>("active");
@@ -199,6 +221,7 @@ export default function Page({
</Link>
</p>
<AddAuthKey
currentHeadscaleUserId={currentHeadscaleUserId}
currentSubject={currentSubject}
selfServiceOnly={selfServiceOnly}
url={url}
+6 -2
View File
@@ -2,13 +2,17 @@ import { ArrowRight } from "lucide-react";
import Link from "~/components/link";
import PageError from "~/components/page-error";
import { headscaleConfigContext, oidcContext } from "~/server/context";
import type { Route } from "./+types/overview";
export async function loader({ context }: Route.LoaderArgs) {
const headscaleConfig = context.get(headscaleConfigContext);
const oidc = context.get(oidcContext);
return {
config: context.hs.writable(),
isOidcEnabled: context.oidc?.service.status().state === "ready",
config: headscaleConfig.writable(),
isOidcEnabled: oidc.state === "enabled" && oidc.value.status().state === "ready",
};
}
+36 -23
View File
@@ -1,12 +1,23 @@
import { data } from "react-router";
import {
authContext,
headscaleConfigContext,
headscaleContext,
integrationContext,
} from "~/server/context";
import { Capabilities } from "~/server/web/roles";
import type { Route } from "./+types/overview";
export async function restrictionAction({ request, context }: Route.ActionArgs) {
const principal = await context.auth.require(request);
const check = context.auth.can(principal, Capabilities.configure_iam);
const auth = context.get(authContext);
const headscale = context.get(headscaleContext);
const headscaleConfig = context.get(headscaleConfigContext);
const integration = context.get(integrationContext);
const principal = await auth.require(request);
const check = auth.can(principal, Capabilities.configure_iam);
if (!check) {
throw data("You do not have permission to modify IAM settings.", {
@@ -14,7 +25,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
});
}
if (!context.hs.writable()) {
if (!headscaleConfig.writable()) {
throw data("The Headscale configuration file is not editable.", {
status: 403,
});
@@ -28,8 +39,6 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
});
}
// We only need healthchecks which don't rely on an API key
const api = context.hsApi.getRuntimeClient("fake-api-key");
switch (action) {
case "add_domain": {
const domain = formData.get("domain")?.toString()?.trim();
@@ -39,16 +48,18 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
});
}
const domains = [...new Set([...(context.hs.c?.oidc?.allowed_domains ?? []), domain])];
const domains = [
...new Set([...(headscaleConfig.getOIDCConfig()?.allowedDomains ?? []), domain]),
];
await context.hs.patch([
await headscaleConfig.patch([
{
path: "oidc.allowed_domains",
value: domains,
},
]);
context.integration?.onConfigChange(api);
integration?.onConfigChange(headscale);
return data("Domain added successfully.");
}
@@ -60,7 +71,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
});
}
const storedDomains = context.hs.c?.oidc?.allowed_domains ?? [];
const storedDomains = headscaleConfig.getOIDCConfig()?.allowedDomains ?? [];
if (!storedDomains.includes(domain)) {
// Domain not found in the list
throw data(`Domain "${domain}" not found in allowed domains.`, {
@@ -70,13 +81,13 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
// Filter out the domain to remove it from the list
const domains = storedDomains.filter((d: string) => d !== domain);
await context.hs.patch([
await headscaleConfig.patch([
{
path: "oidc.allowed_domains",
value: domains,
},
]);
context.integration?.onConfigChange(api);
integration?.onConfigChange(headscale);
return data("Domain removed successfully.");
}
@@ -88,16 +99,18 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
});
}
const groups = [...new Set([...(context.hs.c?.oidc?.allowed_groups ?? []), group])];
const groups = [
...new Set([...(headscaleConfig.getOIDCConfig()?.allowedGroups ?? []), group]),
];
await context.hs.patch([
await headscaleConfig.patch([
{
path: "oidc.allowed_groups",
value: groups,
},
]);
context.integration?.onConfigChange(api);
integration?.onConfigChange(headscale);
return data("Group added successfully.");
}
@@ -109,7 +122,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
});
}
const storedGroups = context.hs.c?.oidc?.allowed_groups ?? [];
const storedGroups = headscaleConfig.getOIDCConfig()?.allowedGroups ?? [];
if (!storedGroups.includes(group)) {
// Group not found in the list
throw data(`Group "${group}" not found in allowed groups.`, {
@@ -119,14 +132,14 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
// Filter out the group to remove it from the list
const groups = storedGroups.filter((d: string) => d !== group);
await context.hs.patch([
await headscaleConfig.patch([
{
path: "oidc.allowed_groups",
value: groups,
},
]);
context.integration?.onConfigChange(api);
integration?.onConfigChange(headscale);
return data("Group removed successfully.");
}
@@ -138,16 +151,16 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
});
}
const users = [...new Set([...(context.hs.c?.oidc?.allowed_users ?? []), user])];
const users = [...new Set([...(headscaleConfig.getOIDCConfig()?.allowedUsers ?? []), user])];
await context.hs.patch([
await headscaleConfig.patch([
{
path: "oidc.allowed_users",
value: users,
},
]);
context.integration?.onConfigChange(api);
integration?.onConfigChange(headscale);
return data("User added successfully.");
}
@@ -159,7 +172,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
});
}
const storedUsers = context.hs.c?.oidc?.allowed_users ?? [];
const storedUsers = headscaleConfig.getOIDCConfig()?.allowedUsers ?? [];
if (!storedUsers.includes(user)) {
// User not found in the list
throw data(`User "${user}" not found in allowed users.`, {
@@ -169,14 +182,14 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
// Filter out the user to remove it from the list
const users = storedUsers.filter((d: string) => d !== user);
await context.hs.patch([
await headscaleConfig.patch([
{
path: "oidc.allowed_users",
value: users,
},
]);
context.integration?.onConfigChange(api);
integration?.onConfigChange(headscale);
return data("User removed successfully.");
}
+13 -8
View File
@@ -2,6 +2,7 @@ import { data } from "react-router";
import Link from "~/components/link";
import Notice from "~/components/notice";
import { authContext, headscaleConfigContext } from "~/server/context";
import { Capabilities } from "~/server/web/roles";
import type { Route } from "./+types/overview";
@@ -12,28 +13,32 @@ import AddUser from "./dialogs/add-user";
import RestrictionTable from "./table";
export async function loader({ request, context }: Route.LoaderArgs) {
const principal = await context.auth.require(request);
const check = context.auth.can(principal, Capabilities.read_users);
const auth = context.get(authContext);
const headscaleConfig = context.get(headscaleConfigContext);
const principal = await auth.require(request);
const check = auth.can(principal, Capabilities.read_users);
if (!check) {
throw data("You do not have permission to view IAM settings.", {
status: 403,
});
}
if (!context.hs.c?.oidc) {
const oidc = headscaleConfig.getOIDCConfig();
if (!oidc) {
throw data("OIDC is not configured on this Headscale instance.", {
status: 501,
});
}
return {
access: context.auth.can(principal, Capabilities.configure_iam),
access: auth.can(principal, Capabilities.configure_iam),
settings: {
domains: [...new Set(context.hs.c.oidc.allowed_domains)],
groups: [...new Set(context.hs.c.oidc.allowed_groups)],
users: [...new Set(context.hs.c.oidc.allowed_users)],
domains: [...new Set(oidc.allowedDomains)],
groups: [...new Set(oidc.allowedGroups)],
users: [...new Set(oidc.allowedUsers)],
},
writable: context.hs.writable(),
writable: headscaleConfig.writable(),
};
}
+112 -39
View File
@@ -5,6 +5,13 @@ import { data, isRouteErrorResponse, type ShouldRevalidateFunction } from "react
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";
@@ -16,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[] = [];
@@ -37,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,
@@ -93,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,32 +146,66 @@ export const links: Route.LinksFunction = () => [
];
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({
@@ -176,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);
@@ -27,6 +27,11 @@ export default function HeadplaneUserRow({
);
const displayName = user.linkedHeadscaleUser?.displayName || user.name || user.email || user.sub;
const displayUsername =
user.linkedHeadscaleUser?.displayName &&
user.linkedHeadscaleUser.displayName !== user.linkedHeadscaleUser.name
? user.linkedHeadscaleUser.name
: undefined;
const displayEmail = user.linkedHeadscaleUser?.email ?? user.email;
return (
@@ -40,6 +45,7 @@ export default function HeadplaneUserRow({
)}
<div className="ml-4">
<p className="leading-snug font-semibold">{displayName}</p>
{displayUsername && <p className="text-sm opacity-50">{displayUsername}</p>}
{displayEmail && <p className="text-sm opacity-50">{displayEmail}</p>}
{!user.headscaleUserId && (
<p className="text-xs text-amber-600 dark:text-amber-400">Not linked</p>
@@ -0,0 +1,58 @@
import { Ellipsis } from "lucide-react";
import { useState } from "react";
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "~/components/menu";
import Delete from "../dialogs/delete-user";
import Rename from "../dialogs/rename-user";
import type { UnlinkedHeadscaleUser } from "../overview";
interface HeadscaleUserMenuProps {
user: UnlinkedHeadscaleUser;
}
type Modal = "rename" | "delete" | null;
export default function HeadscaleUserMenu({ user }: HeadscaleUserMenuProps) {
const [modal, setModal] = useState<Modal>(null);
// Headscale-managed OIDC users cannot be renamed via the API.
const canRename = user.provider !== "oidc";
return (
<>
{modal === "rename" && canRename && (
<Rename
isOpen={modal === "rename"}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
user={user}
/>
)}
{modal === "delete" && (
<Delete
isOpen={modal === "delete"}
machines={user.machines}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
user={user}
/>
)}
<Menu>
<MenuTrigger className="w-10 rounded-full bg-transparent p-1 py-0.5 hover:bg-mist-100 dark:hover:bg-mist-800">
<Ellipsis className="h-5" />
</MenuTrigger>
<MenuContent>
{canRename && <MenuItem onClick={() => setModal("rename")}>Rename</MenuItem>}
{canRename && <MenuSeparator />}
<MenuItem variant="danger" onClick={() => setModal("delete")}>
Delete
</MenuItem>
</MenuContent>
</Menu>
</>
);
}
@@ -4,33 +4,35 @@ import StatusCircle from "~/components/status-circle";
import cn from "~/utils/cn";
import type { UnlinkedHeadscaleUser } from "../overview";
import HeadscaleUserMenu from "./headscale-user-menu";
interface HeadscaleUserRowProps {
user: UnlinkedHeadscaleUser;
writable?: boolean;
}
export default function HeadscaleUserRow({ user }: HeadscaleUserRowProps) {
export default function HeadscaleUserRow({ user, writable }: HeadscaleUserRowProps) {
const isOnline = user.machines.some((machine) => machine.online);
const lastSeen = user.machines.reduce(
(acc, machine) => Math.max(acc, new Date(machine.lastSeen).getTime()),
0,
);
const displayName = user.displayName || user.name;
const displayUsername =
user.displayName && user.displayName !== user.name ? user.name : undefined;
return (
<tr className="group hover:bg-mist-50 dark:hover:bg-mist-950" key={user.id}>
<td className="py-2 pl-0.5">
<div className="flex items-center">
{user.profilePicUrl ? (
<img
alt={user.name || user.displayName}
className="h-10 w-10 rounded-full"
src={user.profilePicUrl}
/>
<img alt={displayName} className="h-10 w-10 rounded-full" src={user.profilePicUrl} />
) : (
<CircleUser className="h-10 w-10" />
)}
<div className="ml-4">
<p className="leading-snug font-semibold">{user.name || user.displayName}</p>
<p className="leading-snug font-semibold">{displayName}</p>
{displayUsername && <p className="text-sm opacity-50">{displayUsername}</p>}
{user.email && <p className="text-sm opacity-50">{user.email}</p>}
</div>
</div>
@@ -54,9 +56,7 @@ export default function HeadscaleUserRow({ user }: HeadscaleUserRowProps) {
<p className="text-sm text-mist-600 dark:text-mist-300">No machines</p>
)}
</td>
<td className="py-2 pr-0.5">
{/* Unlinked users only get basic Headscale operations (rename, delete) */}
</td>
<td className="py-2 pr-0.5">{writable ? <HeadscaleUserMenu user={user} /> : null}</td>
</tr>
);
}
+3 -3
View File
@@ -54,24 +54,24 @@ export default function UserMenu({
{modal === "reassign" && (
<Reassign
displayName={displayName}
headplaneUserId={user.id}
isOpen={modal === "reassign"}
role={user.role}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
userId={user.linkedHeadscaleUser?.id ?? user.id}
/>
)}
{modal === "link" && (
<LinkUser
currentLink={currentLink}
displayName={displayName}
headplaneUserId={user.id}
headscaleUsers={linkableUsers}
isOpen={modal === "link"}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
userId={user.linkedHeadscaleUser?.id ?? user.id}
/>
)}
{modal === "transfer" && (
@@ -81,7 +81,7 @@ export default function UserMenu({
if (!isOpen) setModal(null);
}}
targetDisplayName={displayName}
targetUserId={user.linkedHeadscaleUser?.id ?? user.id}
targetHeadplaneUserId={user.id}
/>
)}
+1 -1
View File
@@ -34,7 +34,7 @@ export default function DeleteUser({ user, machines, isOpen, setIsOpen }: Delete
</Text>
)}
<input name="action_id" type="hidden" value="delete_user" />
<input name="user_id" type="hidden" value={user.id} />
<input name="headscale_user_id" type="hidden" value={user.id} />
</DialogPanel>
</Dialog>
);
+3 -3
View File
@@ -5,7 +5,7 @@ import Title from "~/components/title";
import cn from "~/utils/cn";
interface LinkUserProps {
userId: string;
headplaneUserId: string;
displayName: string;
headscaleUsers: { id: string; name: string }[];
currentLink?: string;
@@ -14,7 +14,7 @@ interface LinkUserProps {
}
export default function LinkUser({
userId,
headplaneUserId,
displayName,
headscaleUsers,
currentLink,
@@ -34,7 +34,7 @@ export default function LinkUser({
) : (
<>
<input name="action_id" type="hidden" value="link_user" />
<input name="user_id" type="hidden" value={userId} />
<input name="headplane_user_id" type="hidden" value={headplaneUserId} />
<select
className={cn(
"w-full rounded-lg border p-2",
+3 -3
View File
@@ -8,7 +8,7 @@ import { Roles } from "~/server/web/roles";
import type { Role } from "~/server/web/roles";
interface ReassignProps {
userId: string;
headplaneUserId: string;
displayName: string;
role: Role;
isOpen: boolean;
@@ -16,7 +16,7 @@ interface ReassignProps {
}
export default function ReassignUser({
userId,
headplaneUserId,
displayName,
role,
isOpen,
@@ -38,7 +38,7 @@ export default function ReassignUser({
) : (
<>
<input name="action_id" type="hidden" value="reassign_user" />
<input name="user_id" type="hidden" value={userId} />
<input name="headplane_user_id" type="hidden" value={headplaneUserId} />
<RadioGroup className="gap-4" defaultValue={role} label="Role" name="new_role">
{Object.keys(Roles)
.filter((r) => r !== "owner")
+1 -1
View File
@@ -21,7 +21,7 @@ export default function RenameUser({ user, isOpen, setIsOpen }: RenameProps) {
update any ACL policies that may refer to this user by their old username.
</Text>
<input name="action_id" type="hidden" value="rename_user" />
<input name="user_id" type="hidden" value={user.id} />
<input name="headscale_user_id" type="hidden" value={user.id} />
<Input
defaultValue={user.name}
required
@@ -4,14 +4,14 @@ import Text from "~/components/text";
import Title from "~/components/title";
interface TransferOwnershipProps {
targetUserId: string;
targetHeadplaneUserId: string;
targetDisplayName: string;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
export default function TransferOwnership({
targetUserId,
targetHeadplaneUserId,
targetDisplayName,
isOpen,
setIsOpen,
@@ -29,7 +29,7 @@ export default function TransferOwnership({
ownership.
</Notice>
<input name="action_id" type="hidden" value="transfer_ownership" />
<input name="user_id" type="hidden" value={targetUserId} />
<input name="headplane_user_id" type="hidden" value={targetHeadplaneUserId} />
</DialogPanel>
</Dialog>
);
+27 -19
View File
@@ -1,7 +1,15 @@
import { createHash } from "node:crypto";
import PageError from "~/components/page-error";
import {
appConfigContext,
authContext,
headscaleConfigContext,
headscaleLiveStoreContext,
requestApiContext,
} from "~/server/context";
import { nodesResource, usersResource } from "~/server/headscale/live-store";
import { isUserPrincipal } from "~/server/web/auth";
import { Capabilities, Roles } from "~/server/web/roles";
import type { Role } from "~/server/web/roles";
import type { Machine, User } from "~/types";
@@ -35,18 +43,24 @@ export interface UnlinkedHeadscaleUser extends User {
}
export async function loader({ request, context }: Route.LoaderArgs) {
const principal = await context.auth.require(request);
const check = await context.auth.can(principal, Capabilities.read_users);
const auth = context.get(authContext);
const config = context.get(appConfigContext);
const getRequestApi = context.get(requestApiContext);
const headscaleConfig = context.get(headscaleConfigContext);
const headscaleLiveStore = context.get(headscaleLiveStoreContext);
const principal = await auth.require(request);
const check = await auth.can(principal, Capabilities.read_users);
if (!check) {
throw new Error(
"You do not have permission to view this page. Please contact your administrator.",
);
}
const writablePermission = await context.auth.can(principal, Capabilities.write_users);
const writablePermission = await auth.can(principal, Capabilities.write_users);
// Primary data: Headplane users from the database (always available)
const hpUsers = await context.auth.listUsers();
const hpUsers = await auth.listUsers();
// Secondary data: Headscale API (may fail)
let apiUsers: User[] = [];
@@ -54,11 +68,10 @@ export async function loader({ request, context }: Route.LoaderArgs) {
let apiError: string | undefined;
try {
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
const { api } = await getRequestApi(request);
const [nodesSnap, usersSnap] = await Promise.all([
context.hsLive.get(nodesResource, api),
context.hsLive.get(usersResource, api),
headscaleLiveStore.get(nodesResource, api),
headscaleLiveStore.get(usersResource, api),
]);
nodes = nodesSnap.data;
apiUsers = usersSnap.data;
@@ -68,7 +81,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
"Could not connect to the Headscale API. Headscale user data and machine information are unavailable.";
}
const useGravatar = context.config.oidc?.profile_picture_source === "gravatar";
const useGravatar = config.oidc?.profile_picture_source === "gravatar";
function resolveProfilePic(email?: string, profilePicUrl?: string): string | undefined {
if (!useGravatar) return profilePicUrl;
@@ -125,20 +138,15 @@ export async function loader({ request, context }: Route.LoaderArgs) {
claimed: claimedIds.has(u.id),
}));
let magic: string | undefined;
if (context.hs.readable()) {
if (context.hs.c?.dns.magic_dns) {
magic = context.hs.c.dns.base_domain;
}
}
const magic = headscaleConfig.getMagicDNSBaseDomain();
const isOwner = principal.kind === "oidc" && principal.user.role === "owner";
const isOwner = isUserPrincipal(principal) && principal.user.role === "owner";
return {
writable: writablePermission,
currentUserId: principal.kind === "oidc" ? principal.user.id : undefined,
currentUserId: isUserPrincipal(principal) ? principal.user.id : undefined,
isOwner,
oidc: context.config.oidc ? { issuer: context.config.oidc.issuer } : undefined,
oidc: config.oidc ? { issuer: config.oidc.issuer } : undefined,
magic,
apiError,
headplaneUsers,
@@ -235,7 +243,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
)}
>
{loaderData.unlinkedHeadscaleUsers.map((user) => (
<HeadscaleUserRow key={user.id} user={user} />
<HeadscaleUserRow key={user.id} user={user} writable={loaderData.writable} />
))}
</tbody>
</table>
+36 -68
View File
@@ -1,15 +1,20 @@
import { data } from "react-router";
import { authContext, headscaleLiveStoreContext, requestApiContext } from "~/server/context";
import { usersResource } from "~/server/headscale/live-store";
import { getOidcSubject } from "~/server/web/headscale-identity";
import { isUserPrincipal } from "~/server/web/auth";
import { Capabilities } from "~/server/web/roles";
import type { Role } from "~/server/web/roles";
import type { Route } from "./+types/overview";
export async function userAction({ request, context }: Route.ActionArgs) {
const principal = await context.auth.require(request);
const check = await context.auth.can(principal, Capabilities.write_users);
const auth = context.get(authContext);
const getRequestApi = context.get(requestApiContext);
const headscaleLiveStore = context.get(headscaleLiveStoreContext);
const principal = await auth.require(request);
const check = await auth.can(principal, Capabilities.write_users);
if (!check) {
throw data("You do not have permission to update users", {
status: 403,
@@ -24,8 +29,7 @@ export async function userAction({ request, context }: Route.ActionArgs) {
});
}
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
const { api } = await getRequestApi(request);
switch (action) {
case "create_user": {
const name = formData.get("username")?.toString();
@@ -38,33 +42,33 @@ export async function userAction({ request, context }: Route.ActionArgs) {
});
}
await api.createUser(name, email, displayName);
await context.hsLive.refresh(usersResource, api);
await api.users.create({ name, email, displayName });
await headscaleLiveStore.refresh(usersResource, api);
return { message: "User created successfully" };
}
case "delete_user": {
const userId = formData.get("user_id")?.toString();
if (!userId) {
throw data("Missing `user_id` in the form data.", {
const headscaleUserId = formData.get("headscale_user_id")?.toString();
if (!headscaleUserId) {
throw data("Missing `headscale_user_id` in the form data.", {
status: 400,
});
}
await api.deleteUser(userId);
await context.hsLive.refresh(usersResource, api);
await api.users.delete(headscaleUserId);
await headscaleLiveStore.refresh(usersResource, api);
return { message: "User deleted successfully" };
}
case "rename_user": {
const userId = formData.get("user_id")?.toString();
const headscaleUserId = formData.get("headscale_user_id")?.toString();
const newName = formData.get("new_name")?.toString();
if (!userId || !newName) {
if (!headscaleUserId || !newName) {
return data({ success: false }, 400);
}
const users = await api.getUsers(userId);
const user = users.find((user) => user.id === userId);
const users = await api.users.list({ id: headscaleUserId });
const user = users.find((user) => user.id === headscaleUserId);
if (!user) {
throw data(`No user found with id: ${userId}`, { status: 400 });
throw data(`No user found with id: ${headscaleUserId}`, { status: 400 });
}
if (user.provider === "oidc") {
@@ -74,34 +78,20 @@ export async function userAction({ request, context }: Route.ActionArgs) {
});
}
await api.renameUser(userId, newName);
await context.hsLive.refresh(usersResource, api);
await api.users.rename(headscaleUserId, newName);
await headscaleLiveStore.refresh(usersResource, api);
return { message: "User renamed successfully" };
}
case "reassign_user": {
const userId = formData.get("user_id")?.toString();
const headplaneUserId = formData.get("headplane_user_id")?.toString();
const newRole = formData.get("new_role")?.toString();
if (!userId || !newRole) {
throw data("Missing `user_id` or `new_role` in the form data.", {
if (!headplaneUserId || !newRole) {
throw data("Missing `headplane_user_id` or `new_role` in the form data.", {
status: 400,
});
}
const users = await api.getUsers(userId);
const user = users.find((user) => user.id === userId);
if (!user) {
throw data("Specified user not found", {
status: 400,
});
}
const subject = getOidcSubject(user);
if (!subject) {
throw data("Specified user is not an OIDC user or has no subject.", { status: 400 });
}
const result = await context.auth.reassignSubject(subject, newRole as Role);
const result = await auth.reassignUser(headplaneUserId, newRole as Role);
if (!result) {
throw data("Failed to reassign user role.", { status: 500 });
}
@@ -109,27 +99,16 @@ export async function userAction({ request, context }: Route.ActionArgs) {
return { message: "User reassigned successfully" };
}
case "transfer_ownership": {
if (principal.kind !== "oidc" || principal.user.role !== "owner") {
if (!isUserPrincipal(principal) || principal.user.role !== "owner") {
throw data("Only the owner can transfer ownership.", { status: 403 });
}
const userId = formData.get("user_id")?.toString();
if (!userId) {
throw data("Missing `user_id` in the form data.", { status: 400 });
const headplaneUserId = formData.get("headplane_user_id")?.toString();
if (!headplaneUserId) {
throw data("Missing `headplane_user_id` in the form data.", { status: 400 });
}
const users = await api.getUsers(userId);
const user = users.find((user) => user.id === userId);
if (!user) {
throw data("Specified user not found", { status: 400 });
}
const targetSubject = getOidcSubject(user);
if (!targetSubject) {
throw data("Target user is not an OIDC user or has no subject.", { status: 400 });
}
const result = await context.auth.transferOwnership(principal.user.subject, targetSubject);
const result = await auth.transferOwnership(principal.user.id, headplaneUserId);
if (!result) {
throw data("Failed to transfer ownership.", { status: 500 });
}
@@ -137,26 +116,15 @@ export async function userAction({ request, context }: Route.ActionArgs) {
return { message: "Ownership transferred successfully" };
}
case "link_user": {
const userId = formData.get("user_id")?.toString();
const headplaneUserId = formData.get("headplane_user_id")?.toString();
const headscaleUserId = formData.get("headscale_user_id")?.toString();
if (!userId || !headscaleUserId) {
throw data("Missing `user_id` or `headscale_user_id` in the form data.", {
if (!headplaneUserId || !headscaleUserId) {
throw data("Missing `headplane_user_id` or `headscale_user_id` in the form data.", {
status: 400,
});
}
const users = await api.getUsers(userId);
const user = users.find((user) => user.id === userId);
if (!user) {
throw data("Specified user not found", { status: 400 });
}
const subject = getOidcSubject(user);
if (!subject) {
throw data("Specified user is not an OIDC user or has no subject.", { status: 400 });
}
const linked = await context.auth.linkHeadscaleUserBySubject(subject, headscaleUserId);
const linked = await auth.linkHeadscaleUser(headplaneUserId, headscaleUserId);
if (!linked) {
throw data("That Headscale user is already linked to another account.", { status: 409 });
}
+5 -3
View File
@@ -1,9 +1,11 @@
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);
const healthy = await headscale.health();
return new Response(JSON.stringify({ status: healthy ? "OK" : "ERROR" }), {
status: healthy ? 200 : 500,
+9 -6
View File
@@ -2,10 +2,15 @@ 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) {
const config = context.get(appConfigContext);
const headscale = context.get(headscaleContext);
if (config.server.info_secret == null) {
throw data(
{
status: "Forbidden",
@@ -25,7 +30,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
}
const token = bearer.slice("Bearer ".length).trim();
if (token !== context.config.server.info_secret) {
if (token !== config.server.info_secret) {
throw data(
{
status: "Forbidden",
@@ -34,14 +39,12 @@ export async function loader({ request, context }: Route.LoaderArgs) {
);
}
// Use a fake API key for healthcheck
const api = context.hsApi.getRuntimeClient("fake-api-key");
const healthy = await api.isHealthy();
const healthy = await headscale.health();
const body = {
status: healthy ? "healthy" : "unhealthy",
headplane_version: __VERSION__,
headscale_canonical_version: healthy ? context.hsApi.apiVersion : "unknown",
headscale_canonical_version: healthy ? headscale.version.raw : "unknown",
internal_versions: {
node: versions.node,
v8: versions.v8,
+9 -7
View File
@@ -1,17 +1,19 @@
import { headscaleLiveStoreContext, requestApiContext } from "~/server/context";
import { nodesResource, usersResource } from "~/server/headscale/live-store";
import log from "~/utils/log";
import type { Route } from "./+types/live";
export async function loader({ request, context }: Route.LoaderArgs) {
const principal = await context.auth.require(request);
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
const getRequestApi = context.get(requestApiContext);
const headscaleLiveStore = context.get(headscaleLiveStoreContext);
const { api } = await getRequestApi(request);
// Ensure resources are loaded before streaming
await Promise.all([
context.hsLive.get(nodesResource, api),
context.hsLive.get(usersResource, api),
headscaleLiveStore.get(nodesResource, api),
headscaleLiveStore.get(usersResource, api),
]);
const stream = new ReadableStream({
@@ -27,11 +29,11 @@ export async function loader({ request, context }: Route.LoaderArgs) {
}
};
const versions = context.hsLive.getVersions();
const versions = headscaleLiveStore.getVersions();
log.debug("sse", "Client connected, sending hello with versions: %o", versions);
send("hello", versions);
const unsubscribe = context.hsLive.subscribe((resource, version) => {
const unsubscribe = headscaleLiveStore.subscribe((resource, version) => {
log.debug("sse", "Sending change event: %s v%s", resource, version);
send("changed", { resource, version });
});
+17 -14
View File
@@ -9,7 +9,7 @@ runs only on the Node process — never in the browser.
app/server/
├── app.ts ← The Headplane application (load context, RR listener)
├── main.ts ← Production bootstrap (binds an http(s) server)
├── context.ts ← createAppContext() — assembles the AppLoadContext
├── 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
@@ -26,9 +26,10 @@ There are two SSR entries; both are picked up by Vite via `vite.config.ts`.
### `app.ts` — the application module
Loads config → builds the `AppLoadContext` (via [`context.ts`](./context.ts))
exports the React Router `RequestListener` as `default`, plus the
resolved `config` as a named export.
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
@@ -63,7 +64,7 @@ constructs everything that needs to live for the lifetime of the
process:
- the SQLite client (`db`)
- the Headscale REST interface (`hsApi`)
- the Headscale REST interface (`headscale`)
- the optional Headplane agent manager (`agents`)
- the auth service (`auth`)
- the optional OIDC service (`oidc`)
@@ -71,17 +72,19 @@ process:
- the (best-effort) parsed Headscale config (`hs`)
- the integration adapter (`integration`)
The returned object is the `AppLoadContext` exposed to every React
Router loader/action. The module also `declare module "react-router" { interface AppLoadContext extends AppContext {} }`
so route handlers get full type inference on `context`.
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 the type, import it from `~/server/context`:
When a route needs a service, import the matching context from
`~/server/context`:
```ts
import type { AppContext } from "~/server/context";
import { authContext } from "~/server/context";
export async function loader({ context }: LoaderFunctionArgs<AppContext>) {
// …
export async function loader({ context, request }: Route.LoaderArgs) {
const auth = context.get(authContext);
const principal = await auth.require(request);
}
```
@@ -105,7 +108,7 @@ file is loaded, not by runtime conditionals.
that names a coherent concern, e.g. `metrics/`, `ratelimit/`).
2. If it owns process-lifetime state (a connection pool, a service
client, …), construct it in [`context.ts`](./context.ts) and add it
to the returned object — this gives every route automatic access via
`context.<name>`.
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.
+56 -3
View File
@@ -12,6 +12,7 @@
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";
@@ -19,7 +20,20 @@ import log from "~/utils/log";
import type { HeadplaneConfig } from "./config/config-schema";
import { ConfigError } from "./config/error";
import { loadConfig } from "./config/load";
import { createAppContext } from "./context";
import {
agentsContext,
appConfigContext,
authContext,
createAppContext,
dbContext,
headscaleApiKeyContext,
headscaleConfigContext,
headscaleContext,
headscaleLiveStoreContext,
integrationContext,
oidcContext,
requestApiContext,
} from "./context";
log.info("server", "Running Node.js %s", versions.node);
@@ -35,16 +49,55 @@ try {
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.auth.start();
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: () => ctx,
getLoadContext,
});
+37
View File
@@ -42,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({
@@ -55,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({
@@ -83,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",
@@ -116,6 +149,8 @@ const oidcConfig = type({
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>?",
@@ -143,6 +178,8 @@ const partialOidcConfig = type({
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"?',
+2 -2
View File
@@ -1,4 +1,4 @@
import type { RuntimeApiClient } from "~/server/headscale/api/endpoints";
import type { Headscale } from "~/server/headscale/api";
export abstract class Integration<T> {
protected context: NonNullable<T>;
@@ -11,6 +11,6 @@ export abstract class Integration<T> {
}
abstract isAvailable(): Promise<boolean> | boolean;
abstract onConfigChange(client: RuntimeApiClient): Promise<void> | void;
abstract onConfigChange(headscale: Headscale): Promise<void> | void;
abstract get name(): string;
}
+3 -3
View File
@@ -4,7 +4,7 @@ import { setTimeout } from "node:timers/promises";
import { type } from "arktype";
import { Client } from "undici";
import type { RuntimeApiClient } from "~/server/headscale/api/endpoints";
import type { Headscale } from "~/server/headscale/api";
import log from "~/utils/log";
import { Integration } from "./abstract";
@@ -255,7 +255,7 @@ export default class DockerIntegration extends Integration<typeof configSchema.f
return this.client !== undefined && this.containerId !== undefined;
}
async onConfigChange(client: RuntimeApiClient) {
async onConfigChange(headscale: Headscale) {
if (!this.client) {
return;
}
@@ -290,7 +290,7 @@ export default class DockerIntegration extends Integration<typeof configSchema.f
while (attempts <= this.maxAttempts) {
try {
log.debug("config", "Checking Headscale status (attempt %d)", attempts);
const status = await client.isHealthy();
const status = await headscale.health();
if (status === false) {
throw new Error("Headscale is not running");
}
+3 -3
View File
@@ -5,7 +5,7 @@ import { join } from "node:path";
import { CoreV1Api, KubeConfig } from "@kubernetes/client-node";
import { type } from "arktype";
import type { RuntimeApiClient } from "~/server/headscale/api/endpoints";
import type { Headscale } from "~/server/headscale/api";
import log from "~/utils/log";
import { Integration } from "./abstract";
@@ -154,12 +154,12 @@ export default class KubernetesIntegration extends Integration<typeof configSche
}
}
async onConfigChange(client: RuntimeApiClient) {
async onConfigChange(headscale: Headscale) {
if (!this.pid) {
return;
}
await signalAndWaitHealthy(client, {
await signalAndWaitHealthy(headscale, {
pid: this.pid,
signal: "SIGHUP",
});
+4 -4
View File
@@ -3,7 +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";
/**
@@ -75,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;
@@ -96,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;
+3 -3
View File
@@ -2,7 +2,7 @@ import { platform } from "node:os";
import { type } from "arktype";
import type { RuntimeApiClient } from "~/server/headscale/api/endpoints";
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",
});
+157 -60
View File
@@ -1,50 +1,66 @@
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 { createHeadscaleInterface } from "./headscale/api";
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 { createAgentManager } from "./hp-agent";
import { createOidcService } from "./oidc/provider";
import { createAuthService } from "./web/auth";
import { type AgentManager, createAgentManager } from "./hp-agent";
import { createOidcService, type OidcService } from "./oidc/provider";
import { createAuthService, type Principal } from "./web/auth";
export type AppContext = Awaited<ReturnType<typeof createAppContext>>;
declare module "react-router" {
interface AppLoadContext extends AppContext {}
}
export 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 hsApi = await createHeadscaleInterface(
config.headscale.url,
config.headscale.tls_cert_path,
);
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;
let agents;
if (headscaleApiKey) {
agents = await createAgentManager(
config.integration?.agent,
config.headscale.url,
hsApi.getRuntimeClient(headscaleApiKey),
hsApi.clientHelpers.isAtleast("0.28.0"),
db,
);
} else if (config.integration?.agent?.enabled) {
log.warn("agent", "Agent is enabled but no headscale.api_key is configured");
}
const 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",
@@ -54,49 +70,130 @@ export async function createAppContext(config: HeadplaneConfig) {
},
});
const oidc =
config.oidc && config.oidc.enabled !== false && headscaleApiKey
? {
service: createOidcService({
issuer: config.oidc.issuer,
clientId: config.oidc.client_id,
clientSecret: config.oidc.client_secret,
baseUrl: config.server.base_url ?? "",
authorizationEndpoint: config.oidc.authorization_endpoint,
tokenEndpoint: config.oidc.token_endpoint,
userinfoEndpoint: config.oidc.userinfo_endpoint,
endSessionEndpoint: config.oidc.end_session_endpoint,
tokenEndpointAuthMethod:
config.oidc.token_endpoint_auth_method === "client_secret_jwt"
? undefined
: config.oidc.token_endpoint_auth_method,
usePkce: config.oidc.use_pkce,
scope: config.oidc.scope,
subjectClaims: config.oidc.subject_claims,
allowWeakRsaKeys: config.oidc.allow_weak_rsa_keys,
extraParams: config.oidc.extra_params,
profilePictureSource: config.oidc.profile_picture_source,
postLogoutRedirectUri: config.oidc.post_logout_redirect_uri,
}),
disableApiKeyLogin: config.oidc.disable_api_key_login,
useEndSession: config.oidc.use_end_session,
}
: undefined;
const 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,
hsApi,
headscale,
headscaleApiKey,
agents,
auth,
oidc,
hsLive: createLiveStore([nodesResource, usersResource]),
hs: await loadHeadscaleConfig(
config.headscale.config_path,
config.headscale.config_strict,
config.headscale.dns_records_path,
),
integration: await loadIntegration(config.integration),
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);
}
+1 -1
View File
@@ -32,7 +32,7 @@ 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"),
+20
View File
@@ -0,0 +1,20 @@
// MARK: Feature<T>
//
// A two-state tagged union used in place of `T | undefined` for
// optional features on the AppContext. Carries a human-readable
// reason when the feature is disabled so loaders can surface it (or
// choose to ignore it).
//
// This is deliberately *not* a Result/Either. It does not represent
// async readiness or retryable failure — it represents "is this
// feature wired up at all." Services that have their own runtime
// status (e.g. OIDC discovery) keep that on the service itself.
export type Feature<T> = { state: "enabled"; value: T } | { state: "disabled"; reason: string };
export const enabled = <T>(value: T): Feature<T> => ({ state: "enabled", value });
export const disabled = (reason: string): Feature<never> => ({
state: "disabled",
reason,
});
+56
View File
@@ -0,0 +1,56 @@
// MARK: Headscale Capabilities
//
// Behavioural facts about the connected Headscale server, derived
// once from `ServerVersion` at boot. Capabilities are named for *what
// changed* — not for the version number it changed in — so a reader
// who has never seen Headscale can tell what each flag controls
// without consulting a release note.
//
// Add a capability here when you find yourself reaching for a raw
// version comparison in endpoint or route code. Adding a capability
// is also the right answer when a new Headscale release changes wire
// format or removes an endpoint.
import { gte, type ServerVersion } from "./server-version";
export interface Capabilities {
/**
* Pre-auth keys have stable IDs. `GET /api/v1/preauthkey` (no
* user filter) returns every key in the system, and
* `POST /api/v1/preauthkey/expire` takes `{ id }` instead of
* `{ user, key }`. Tag-only pre-auth keys (no owning user) are
* supported. Introduced in 0.28.0.
*/
readonly preAuthKeysHaveStableIds: boolean;
/**
* Node tags are a flat `tags: string[]` field on the wire.
* Pre-0.28 returned `forcedTags` / `validTags` / `invalidTags`
* that the client had to union itself. Introduced in 0.28.0.
*/
readonly nodeTagsAreFlat: boolean;
/**
* A node's owning user is immutable after creation;
* `POST /api/v1/node/{id}/user` no longer reassigns. Effective in
* 0.28.0+.
*/
readonly nodeOwnerIsImmutable: boolean;
/**
* The `POST /api/v1/node/register` endpoint expects the full
* `hskey-authreq-<id>` AuthID as the `key` parameter. Pre-0.29
* Headscale expected the raw 24-character registration ID without
* the `hskey-authreq-` prefix. Introduced in 0.29.0.
*/
readonly registerKeyIncludesAuthReqPrefix: boolean;
}
export function capabilitiesFor(version: ServerVersion): Capabilities {
return {
preAuthKeysHaveStableIds: gte(version, "0.28.0"),
nodeTagsAreFlat: gte(version, "0.28.0"),
nodeOwnerIsImmutable: gte(version, "0.28.0"),
registerKeyIncludesAuthReqPrefix: gte(version, "0.29.0"),
};
}
@@ -1,20 +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,75 +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);
-171
View File
@@ -1,171 +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/${encodeURIComponent(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,87 +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/${encodeURIComponent(newName)}`,
apiKey,
);
},
}));
-38
View File
@@ -1,38 +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;
}
-51
View File
@@ -1,51 +0,0 @@
import { createHash } from "node:crypto";
import { dereference } from "@readme/openapi-parser";
import { OpenAPIV2 } from "openapi-types";
/**
* A map of operation IDs to their hashes.
*/
export interface DocumentHash {
[operationId: string]: string;
}
/**
* Given an OpenAPI v2 document, generate a map of operations with hashes.
* This gives us deterministic identifers to determine the version of Headscale
* that is being used at runtime.
*
* @param doc The OpenAPI v2 document to hash.
* @returns A map of operation IDs to their hashes.
*/
export async function hashOpenApiDocument(doc: OpenAPIV2.Document): Promise<DocumentHash> {
const spec = await dereference(doc);
const hashes: DocumentHash = {};
const seen = new Set<string>();
for (const [path, item] of Object.entries(spec.paths)) {
for (const [method, operation] of Object.entries(item)) {
if (typeof operation !== "object") {
continue;
}
const { parameters, responses } = operation as OpenAPIV2.OperationObject;
const raw = JSON.stringify(
{
path,
method: method.toUpperCase(),
parameters,
responses,
},
Object.keys({ path, method, parameters, responses }).sort(),
);
const hash = createHash("md5").update(raw).digest("hex").slice(0, 16);
const final = seen.has(hash) ? `${hash}_${seen.size}` : hash;
seen.add(final);
hashes[`${method.toUpperCase()} ${path}`] = final;
}
}
return hashes;
}
+144 -306
View File
@@ -1,328 +1,166 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { dereference } from "@readme/openapi-parser";
import type { OpenAPIV2 } from "openapi-types";
import { data } from "react-router";
import { Agent, type Dispatcher, request } from "undici";
// MARK: Headscale API
//
// The public entry point for talking to a Headscale server. At boot
// we try `GET /version` (unauthenticated, present since Headscale
// 0.27.0 — the minimum version Headplane supports) to derive a
// typed `Capabilities` object. Boot outcomes:
//
// - success: parse the response, derive capabilities, done.
// - 404: Headscale is reachable but predates 0.27.0 and is no
// longer supported. Log an error and keep retrying so an
// upgrade is picked up without a Headplane restart.
// - any other failure (network, 5xx, parse): Headplane still
// boots with `version = unknown` (capabilities-permissive) and
// a background retry. This handles docker-compose start-order
// races without making the whole process unhappy.
//
// Capabilities are always derived from `version`; once detection
// finishes there's no further state to track.
import log from "~/utils/log";
import endpointSets, { RuntimeApiClient } from "./endpoints";
import { undiciToFriendlyError } from "./error";
import { HeadscaleAPIError, isApiError } from "./error-client";
import { detectApiVersion, isAtLeast, type Version } from "./version";
import { type Capabilities, capabilitiesFor } from "./capabilities";
import { isDataWithApiError } from "./error-client";
import { type ApiKeyApi, makeApiKeyApi } from "./resources/api-keys";
import { type AuthApi, makeAuthApi } from "./resources/auth";
import { makeNodeApi, type NodeApi } from "./resources/nodes";
import { makePolicyApi, type PolicyApi } from "./resources/policy";
import { makePreAuthKeyApi, type PreAuthKeyApi } from "./resources/pre-auth-keys";
import { makeUserApi, type UserApi } from "./resources/users";
import { formatServerVersion, parseServerVersion, type ServerVersion } from "./server-version";
import { createTransport } from "./transport";
/**
* A low-level composed interface for interacting with the Headscale API.
* This interface provides direct access to the underlying Undici agent
* and methods for making API requests.
*
* It is also responsible for handling OpenAPI spec polling and hashing to
* determine the implementations of API methods when requested for use.
*/
export interface HeadscaleApiInterface {
/**
* The underlying Undici agent used for making requests.
*/
undiciAgent: Agent;
const MIN_SUPPORTED_VERSION = "0.27.0";
/**
* The base URL of the Headscale API.
*/
baseUrl: string;
/**
* The OpenAPI hashes retrieved from the Headscale instance at runtime.
* This is used to determine which implementations of API methods to use.
*/
openapiHashes: Record<string, string> | null;
/**
* The detected API version of the connected Headscale instance.
*/
apiVersion: Version;
/**
* Retrieves a runtime API client for the given API key.
*
* @param apiKey The API key to use for authentication.
* @returns A `RuntimeApiClient` instance for interacting with the API.
*/
getRuntimeClient(apiKey: string): RuntimeApiClient;
/**
* A set of helper methods made available to API method implementations.
* The idea is to make interacting with the API easier by providing
* common functionality that can be reused across multiple methods.
*/
clientHelpers: {
/**
* Checks if the connected Headscale instance's API version
* is at least the specified version.
*
* @param version The version to check against.
* @returns `true` if the API version is at least the specified version, `false` otherwise.
*/
isAtleast(version: Version): boolean;
/**
* Makes a raw fetch request to the Headscale API via the Undici agent.
* This method is used internally by API method implementations
* to make requests to the Headscale API.
*
* @param path The API path to request.
* @param options Optional request options.
* @returns A promise that resolves to the response data.
*/
rawFetch(
path: string,
options?: Partial<Dispatcher.RequestOptions>,
): Promise<Dispatcher.ResponseData>;
/**
* Makes a typed API fetch request to the Headscale API.
* This method is used internally by API method implementations
* to make requests to the Headscale API and parse the response.
*
* @param method The HTTP method to use.
* @param apiPath The API path to request.
* @param apiKey The API key to use for authentication.
* @param bodyOrQuery Optional body or query parameters.
* @returns A promise that resolves to the typed response data.
*/
apiFetch<T>(
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH",
apiPath: `v1/${string}`,
apiKey: string,
bodyOrQuery?: Record<string, unknown>,
): Promise<T>;
};
export interface Headscale {
readonly version: ServerVersion;
readonly capabilities: Capabilities;
/** True if the Headscale server's `/health` endpoint returns 200. */
health(): Promise<boolean>;
/** Build an API client bound to a specific Headscale API key. */
client(apiKey: string): HeadscaleClient;
/** Stop background work and close the underlying HTTP agent. */
dispose(): Promise<void>;
}
/**
* Creates a new Headscale API client interface.
*
* @param baseUrl The base URL of the Headscale API.
* @param certPath Optional path to a custom TLS certificate for secure connections.
* @returns A promise that resolves to a `HeadscaleApiClient` instance.
*/
export async function createHeadscaleInterface(
baseUrl: string,
certPath?: string,
): Promise<HeadscaleApiInterface> {
const undiciAgent = await createUndiciAgent(certPath);
let openapiHashes: Record<string, string> | null = null;
let apiVersion: Version;
const rawFetch = async (
url: string,
options?: Partial<Dispatcher.RequestOptions>,
): Promise<Dispatcher.ResponseData> => {
const method = options?.method ?? "GET";
log.debug("api", "%s %s", method, url);
try {
const res = await request(new URL(url, baseUrl), {
dispatcher: undiciAgent,
headers: {
...options?.headers,
Accept: "application/json",
"User-Agent": `Headplane/${__VERSION__}`,
},
body: options?.body,
method,
});
return res;
} catch (error) {
const errorBody = undiciToFriendlyError(error, `${method} ${url}`);
throw data(errorBody, {
status: 502,
statusText: "Bad Gateway",
});
}
};
const apiFetch = async <T>(
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH",
apiPath: `v1/${string}`,
apiKey: string,
bodyOrQuery?: Record<string, unknown>,
): Promise<T> => {
let url = `/api/${apiPath}`;
const options: Partial<Dispatcher.RequestOptions> = {
method: method,
headers: {
Authorization: `Bearer ${apiKey}`,
},
};
if (bodyOrQuery) {
if (method === "GET" || method === "DELETE") {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(bodyOrQuery)) {
if (value !== undefined) {
params.append(key, String(value));
}
}
if ([...params.keys()].length > 0) {
url += `?${params.toString()}`;
}
} else {
options.body = JSON.stringify(bodyOrQuery);
options.headers = {
...options.headers,
"Content-Type": "application/json",
};
}
}
const res = await rawFetch(url, options);
if (res.statusCode >= 400) {
log.debug("api", "%s %s failed with status %d", method, apiPath, res.statusCode);
const rawData = await res.body.text();
const jsonData = (() => {
try {
return JSON.parse(rawData) as Record<string, unknown>;
} catch {
return null;
}
})();
throw data(
{
requestUrl: `${method} ${apiPath}`,
statusCode: res.statusCode,
rawData,
data: jsonData,
} satisfies HeadscaleAPIError,
{ status: 502, statusText: "Bad Gateway" },
);
}
return res.body.json() as Promise<T>;
};
export interface HeadscaleClient {
nodes: NodeApi;
users: UserApi;
policy: PolicyApi;
preAuthKeys: PreAuthKeyApi;
apiKeys: ApiKeyApi;
auth: AuthApi;
}
export interface CreateHeadscaleOptions {
url: string;
certPath?: string;
/**
* Polls the OpenAPI spec endpoint and generates operation hashes.
* This is used to determine which implementations of API methods to use.
*
* @returns A promise that resolves to the OpenAPI operation hashes.
* How often to retry `/version` while Headscale is unreachable.
* Defaults to 30 seconds. Exposed for tests.
*/
async function fetchAndHashOpenapi(): Promise<Record<string, string> | null> {
try {
const res = await rawFetch("/swagger/v1/openapiv2.json");
if (res.statusCode !== 200) {
log.error("api", "Failed to fetch OpenAPI spec: %d", res.statusCode);
return null;
}
retryIntervalMs?: number;
}
const body = await res.body.json();
const spec = await dereference(body as OpenAPIV2.Document);
const hashes = generateSpecHashes(spec);
log.debug("api", "OpenAPI hashes updated (%d endpoints)", Object.keys(hashes).length);
return hashes;
} catch (error) {
if (isApiError(error)) {
log.debug("api", "Failed to fetch OpenAPI spec: %d", error.statusCode);
}
const DEFAULT_RETRY_INTERVAL_MS = 30_000;
return null;
export async function createHeadscale(opts: CreateHeadscaleOptions): Promise<Headscale> {
const transport = await createTransport({ url: opts.url, certPath: opts.certPath });
const retryIntervalMs = opts.retryIntervalMs ?? DEFAULT_RETRY_INTERVAL_MS;
let version: ServerVersion = parseServerVersion("unreachable");
let capabilities: Capabilities = capabilitiesFor(version);
let detected = false;
let retryTimer: ReturnType<typeof setTimeout> | undefined;
let disposed = false;
function settle(parsed: ServerVersion) {
version = parsed;
capabilities = capabilitiesFor(parsed);
detected = true;
if (parsed.unknown) {
log.warn(
"api",
"Could not parse Headscale version %s, assuming newest known capabilities",
parsed.raw,
);
} else {
log.info("api", "Connected to Headscale %s", formatServerVersion(parsed));
}
}
const isAtleast = (version: Version): boolean => {
return isAtLeast(apiVersion, version);
};
openapiHashes = await fetchAndHashOpenapi();
apiVersion = detectApiVersion(openapiHashes);
setInterval(async () => {
const hashes = await fetchAndHashOpenapi();
if (hashes) {
openapiHashes = hashes;
apiVersion = detectApiVersion(openapiHashes);
async function detectOnce(): Promise<boolean> {
try {
const { version: raw } = await transport.getPublic<{ version: string }>("/version");
settle(parseServerVersion(raw));
return true;
} catch (error) {
// 404 means Headscale is reachable but predates 0.27.0 (where
// /version was introduced). That server is below the supported
// floor, so we don't settle — leave capabilities permissive and
// keep retrying in case the operator upgrades in place.
if (isDataWithApiError(error) && error.data.statusCode === 404) {
log.error(
"api",
"Headscale /version returned 404; Headplane requires Headscale %s or newer",
MIN_SUPPORTED_VERSION,
);
return false;
}
log.debug("api", "Headscale /version probe failed: %s", String(error));
return false;
}
}, 60_000); // every 60 seconds
}
function scheduleRetry() {
if (disposed || detected) return;
retryTimer = setTimeout(async () => {
retryTimer = undefined;
if (disposed) return;
if (await detectOnce()) return;
scheduleRetry();
}, retryIntervalMs);
// Don't keep the event loop alive on this timer alone — Headplane
// should still shut down cleanly while we're waiting to retry.
retryTimer.unref?.();
}
if (!(await detectOnce())) {
log.warn(
"api",
"Headscale unreachable at boot; defaulting to newest-known capabilities and retrying every %dms",
retryIntervalMs,
);
scheduleRetry();
}
return {
undiciAgent,
baseUrl,
openapiHashes,
apiVersion,
getRuntimeClient: (apiKey: string) => {
return endpointSets(
{
rawFetch,
apiFetch,
isAtleast,
},
apiKey,
);
// Getters so callers always observe the latest detected values
// without having to know about the retry loop.
get version() {
return version;
},
clientHelpers: {
rawFetch,
apiFetch,
isAtleast,
get capabilities() {
return capabilities;
},
health: () => transport.health(),
client(apiKey) {
return {
nodes: makeNodeApi(transport, capabilities, apiKey),
users: makeUserApi(transport, capabilities, apiKey),
policy: makePolicyApi(transport, capabilities, apiKey),
preAuthKeys: makePreAuthKeyApi(transport, capabilities, apiKey),
apiKeys: makeApiKeyApi(transport, capabilities, apiKey),
auth: makeAuthApi(transport, capabilities, apiKey),
};
},
async dispose() {
disposed = true;
if (retryTimer) {
clearTimeout(retryTimer);
retryTimer = undefined;
}
await transport.dispose();
},
};
}
/**
* Creates a new Undici agent for making HTTP requests.
*
* @param certPath Optional path to a custom TLS certificate for secure connections.
* @returns A promise that resolves to an `Agent` instance.
*/
async function createUndiciAgent(certPath?: string): Promise<Agent> {
if (!certPath) {
return new Agent();
}
try {
log.debug("config", "Loading certificate from %s", certPath);
const data = await readFile(certPath, "utf8");
log.info("config", "Using certificate from %s", certPath);
return new Agent({ connect: { ca: data.trim() } });
} catch (error) {
log.error("config", "Failed to load Headscale TLS cert: %s", error);
log.debug("config", "Error Details: %o", error);
return new Agent();
}
}
function generateSpecHashes(spec: OpenAPIV2.Document) {
const hashes: Record<string, string> = {};
const seen = new Set<string>();
for (const [path, item] of Object.entries(spec.paths)) {
for (const [method, operation] of Object.entries(item)) {
if (typeof operation !== "object") {
continue;
}
const { parameters, responses } = operation as OpenAPIV2.OperationObject;
const raw = JSON.stringify(
{
path,
method: method.toUpperCase(),
parameters,
responses,
},
Object.keys({ path, method, parameters, responses }).sort(),
);
const hash = createHash("md5").update(raw).digest("hex").slice(0, 16);
const final = seen.has(hash) ? `${hash}_${seen.size}` : hash;
seen.add(final);
hashes[`${method.toUpperCase()} ${path}`] = final;
}
}
return hashes;
}
@@ -0,0 +1,25 @@
import type { Key } from "~/types";
import type { Capabilities } from "../capabilities";
import type { Transport } from "../transport";
export interface ApiKeyApi {
list(): Promise<Key[]>;
}
export function makeApiKeyApi(
transport: Transport,
_capabilities: Capabilities,
apiKey: string,
): ApiKeyApi {
return {
list: async () => {
const { apiKeys } = await transport.request<{ apiKeys: Key[] }>({
method: "GET",
path: "v1/apikey",
apiKey,
});
return apiKeys;
},
};
}
@@ -0,0 +1,27 @@
import type { Capabilities } from "../capabilities";
import type { Transport } from "../transport";
export interface AuthApi {
/**
* Approve a pending Headscale authentication request.
* Used by the Headplane agent to auto-approve its own registration.
*/
approve(authId: string): Promise<void>;
}
export function makeAuthApi(
transport: Transport,
_capabilities: Capabilities,
apiKey: string,
): AuthApi {
return {
approve: async (authId) => {
await transport.request({
method: "POST",
path: "v1/auth/approve",
apiKey,
body: { authId },
});
},
};
}
+121
View File
@@ -0,0 +1,121 @@
import type { Machine } from "~/types";
import type { Capabilities } from "../capabilities";
import type { Transport } from "../transport";
interface RawMachine extends Omit<Machine, "tags"> {
tags?: string[];
forcedTags?: string[];
validTags?: string[];
invalidTags?: string[];
}
export interface NodeApi {
list(): Promise<Machine[]>;
get(id: string): Promise<Machine>;
delete(id: string): Promise<void>;
register(user: string, key: string): Promise<Machine>;
approveRoutes(id: string, routes: string[]): Promise<void>;
expire(id: string): Promise<void>;
rename(id: string, newName: string): Promise<void>;
setTags(id: string, tags: string[]): Promise<void>;
/**
* Reassign a node to a different user. Only present when
* `capabilities.nodeOwnerIsImmutable` is false (Headscale < 0.28).
*/
reassignUser?: (id: string, user: string) => Promise<void>;
}
export function makeNodeApi(
transport: Transport,
capabilities: Capabilities,
apiKey: string,
): NodeApi {
function normalize(raw: RawMachine): Machine {
if (capabilities.nodeTagsAreFlat) {
return { ...raw, tags: raw.tags ?? [] } as Machine;
}
const tags = Array.from(new Set([...(raw.forcedTags ?? []), ...(raw.validTags ?? [])]));
return { ...raw, tags } as Machine;
}
const api: NodeApi = {
list: async () => {
const { nodes } = await transport.request<{ nodes: RawMachine[] }>({
method: "GET",
path: "v1/node",
apiKey,
});
return nodes.map(normalize);
},
get: async (id) => {
const { node } = await transport.request<{ node: RawMachine }>({
method: "GET",
path: `v1/node/${id}`,
apiKey,
});
return normalize(node);
},
delete: async (id) => {
await transport.request({ method: "DELETE", path: `v1/node/${id}`, apiKey });
},
register: async (user, key) => {
// Headscale's node-register endpoint expects the registration
// params as both query string and body — preserved as-is.
// Pre-0.29 expects the raw 24-char registration ID; 0.29+ expects
// the full `hskey-authreq-<id>` AuthID.
const registerKey = capabilities.registerKeyIncludesAuthReqPrefix
? key
: key.replace(/^hskey-authreq-/, "");
const qp = new URLSearchParams();
qp.append("user", user);
qp.append("key", registerKey);
const { node } = await transport.request<{ node: RawMachine }>({
method: "POST",
path: `v1/node/register?${qp.toString()}`,
apiKey,
body: { user, key: registerKey },
});
return normalize(node);
},
approveRoutes: async (id, routes) => {
await transport.request({
method: "POST",
path: `v1/node/${id}/approve_routes`,
apiKey,
body: { routes },
});
},
expire: async (id) => {
await transport.request({ method: "POST", path: `v1/node/${id}/expire`, apiKey });
},
rename: async (id, newName) => {
await transport.request({
method: "POST",
path: `v1/node/${id}/rename/${encodeURIComponent(newName)}`,
apiKey,
});
},
setTags: async (id, tags) => {
await transport.request({
method: "POST",
path: `v1/node/${id}/tags`,
apiKey,
body: { tags },
});
},
};
if (!capabilities.nodeOwnerIsImmutable) {
api.reassignUser = async (id, user) => {
await transport.request({
method: "POST",
path: `v1/node/${id}/user`,
apiKey,
body: { user },
});
};
}
return api;
}
@@ -0,0 +1,33 @@
import type { Capabilities } from "../capabilities";
import type { Transport } from "../transport";
export interface PolicyApi {
get(): Promise<{ policy: string; updatedAt: Date | null }>;
set(policy: string): Promise<{ policy: string; updatedAt: Date }>;
}
export function makePolicyApi(
transport: Transport,
_capabilities: Capabilities,
apiKey: string,
): PolicyApi {
return {
get: async () => {
const { policy, updatedAt } = await transport.request<{
policy: string;
updatedAt: string;
}>({ method: "GET", path: "v1/policy", apiKey });
return {
policy,
updatedAt: updatedAt !== null ? new Date(updatedAt) : null,
};
},
set: async (policy) => {
const { policy: newPolicy, updatedAt } = await transport.request<{
policy: string;
updatedAt: string;
}>({ method: "PUT", path: "v1/policy", apiKey, body: { policy } });
return { policy: newPolicy, updatedAt: new Date(updatedAt) };
},
};
}
@@ -0,0 +1,90 @@
import type { PreAuthKey } from "~/types";
import type { Capabilities } from "../capabilities";
import type { Transport } from "../transport";
export interface CreatePreAuthKeyOptions {
/** Owning user ID, or `null` for tag-only keys (0.28+). */
user: string | null;
ephemeral: boolean;
reusable: boolean;
expiration: Date | null;
aclTags: string[] | null;
}
export interface PreAuthKeyApi {
/**
* List every pre-auth key on the server. Only present when
* `capabilities.preAuthKeysHaveStableIds` is true (Headscale 0.28+).
* Pre-0.28 callers must use {@link listForUser}.
*/
listAll?: () => Promise<PreAuthKey[]>;
listForUser(userId: string): Promise<PreAuthKey[]>;
create(opts: CreatePreAuthKeyOptions): Promise<PreAuthKey>;
expire(key: PreAuthKey): Promise<void>;
}
export function makePreAuthKeyApi(
transport: Transport,
capabilities: Capabilities,
apiKey: string,
): PreAuthKeyApi {
const api: PreAuthKeyApi = {
listForUser: async (userId) => {
const { preAuthKeys } = await transport.request<{
preAuthKeys: PreAuthKey[];
}>({ method: "GET", path: "v1/preauthkey", apiKey, query: { user: userId } });
return preAuthKeys;
},
create: async ({ user, ephemeral, reusable, expiration, aclTags }) => {
const body: Record<string, unknown> = {
ephemeral,
reusable,
expiration: expiration ? expiration.toISOString() : null,
};
if (user) body.user = user;
if (aclTags && aclTags.length > 0) body.aclTags = aclTags;
const { preAuthKey } = await transport.request<{
preAuthKey: PreAuthKey;
}>({ method: "POST", path: "v1/preauthkey", apiKey, body });
return preAuthKey;
},
expire: async (key) => {
if (capabilities.preAuthKeysHaveStableIds) {
await transport.request({
method: "POST",
path: "v1/preauthkey/expire",
apiKey,
body: { id: key.id },
});
return;
}
// Pre-0.28: expire takes the owning user's ID (a uint64 — Headscale
// rejects names with `proto: invalid value for uint64 field user`)
// plus the key string.
await transport.request({
method: "POST",
path: "v1/preauthkey/expire",
apiKey,
body: { user: key.user?.id ?? "", key: key.key },
});
},
};
if (capabilities.preAuthKeysHaveStableIds) {
api.listAll = async () => {
const { preAuthKeys } = await transport.request<{
preAuthKeys: PreAuthKey[];
}>({ method: "GET", path: "v1/preauthkey", apiKey });
return preAuthKeys;
};
}
return api;
}
@@ -0,0 +1,66 @@
import type { User } from "~/types";
import type { Capabilities } from "../capabilities";
import type { Transport } from "../transport";
export interface CreateUserOptions {
name: string;
email?: string;
displayName?: string;
pictureUrl?: string;
}
export interface ListUsersFilter {
id?: string;
name?: string;
email?: string;
}
export interface UserApi {
list(filter?: ListUsersFilter): Promise<User[]>;
create(opts: CreateUserOptions): Promise<User>;
delete(id: string): Promise<void>;
rename(id: string, newName: string): Promise<void>;
}
export function makeUserApi(
transport: Transport,
_capabilities: Capabilities,
apiKey: string,
): UserApi {
return {
list: async (filter) => {
const { id, name, email } = filter ?? {};
const moreThanOneFilter = [id, name, email].filter((v) => v !== undefined).length > 1;
if (moreThanOneFilter) {
throw new Error("Only one of id, name, or email filters can be provided");
}
const { users } = await transport.request<{ users: User[] }>({
method: "GET",
path: "v1/user",
apiKey,
query: { id, name, email },
});
return users;
},
create: async ({ name, email, displayName, pictureUrl }) => {
const { user } = await transport.request<{ user: User }>({
method: "POST",
path: "v1/user",
apiKey,
body: { name, email, displayName, pictureUrl },
});
return user;
},
delete: async (id) => {
await transport.request({ method: "DELETE", path: `v1/user/${id}`, apiKey });
},
rename: async (id, newName) => {
await transport.request({
method: "POST",
path: `v1/user/${id}/rename/${encodeURIComponent(newName)}`,
apiKey,
});
},
};
}
+125
View File
@@ -0,0 +1,125 @@
// MARK: ServerVersion
//
// Parses the response from Headscale's `GET /version` endpoint into a
// structured value that capability checks can reason about. The
// endpoint exists in every Headscale release we support (0.27.0+) and
// returns a plain semver-like string such as `v0.28.0`, `v0.28.0-beta.1`,
// or `dev` for untagged builds.
//
// Comparisons (`gte`) are lenient about prerelease tags by design:
// `0.28.0-beta.1` is treated as `0.28.0` for capability gating. The
// behavioural changes that capabilities gate on always land in the
// first prerelease of a minor version, so a strict semver
// interpretation would lock prerelease users out of features that
// their server actually has.
const SEMVER_RE = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$/;
// Go pseudo-version prerelease segment: `<14-digit timestamp>-<12-hex sha>`,
// e.g. the prerelease part of `v0.0.0-20260703052708-048308511c72`. Untagged
// Headscale builds (per-commit `main-*` / `development` Docker images) report
// this instead of `dev`, and it parses as semver 0.0.0 — which would strip
// every capability from a server that actually runs the newest code.
const GO_PSEUDO_VERSION_PRERELEASE_RE = /^\d{14}-[0-9a-f]{12}$/;
export interface ServerVersion {
readonly major: number;
readonly minor: number;
readonly patch: number;
readonly prerelease: string | undefined;
readonly build: string | undefined;
/** The raw string as reported by Headscale (e.g. `v0.28.0-beta.1`, `dev`). */
readonly raw: string;
/**
* True when the server reported a version we couldn't parse —
* typically `dev` for an untagged Headscale build. Capability checks
* treat unknown versions as having every known capability so we
* exercise the modern code paths against unfamiliar servers rather
* than silently falling back to compatibility shims.
*/
readonly unknown: boolean;
}
export function parseServerVersion(raw: string): ServerVersion {
const match = SEMVER_RE.exec(raw.trim());
if (!match) {
return {
major: 0,
minor: 0,
patch: 0,
prerelease: undefined,
build: undefined,
raw,
unknown: true,
};
}
const [, maj, min, pat, pre, build] = match;
// A Go pseudo-version (v0.0.0-<timestamp>-<sha>) is an untagged dev build,
// not an ancient release: treat it like `dev` so capability checks assume
// the modern code paths instead of gating everything off.
if (
Number(maj) === 0 &&
Number(min) === 0 &&
Number(pat) === 0 &&
pre !== undefined &&
GO_PSEUDO_VERSION_PRERELEASE_RE.test(pre)
) {
return {
major: 0,
minor: 0,
patch: 0,
prerelease: pre,
build,
raw,
unknown: true,
};
}
return {
major: Number(maj),
minor: Number(min),
patch: Number(pat),
prerelease: pre,
build,
raw,
unknown: false,
};
}
/** Pretty-print a parsed version for logs (no `v` prefix, includes prerelease). */
export function formatServerVersion(version: ServerVersion): string {
if (version.unknown) return version.raw;
const core = `${version.major}.${version.minor}.${version.patch}`;
return version.prerelease ? `${core}-${version.prerelease}` : core;
}
/**
* Returns true when `version` is at least `target` (ignoring prerelease
* tags — see module-level note). `target` must be a plain semver
* string like `0.28.0`. Throws on a malformed target since those come
* from static capability tables, not user input.
*/
export function gte(version: ServerVersion, target: string): boolean {
if (version.unknown) return true;
const t = parseTarget(target);
if (version.major !== t.major) return version.major > t.major;
if (version.minor !== t.minor) return version.minor > t.minor;
return version.patch >= t.patch;
}
interface TargetVersion {
major: number;
minor: number;
patch: number;
}
function parseTarget(target: string): TargetVersion {
const match = SEMVER_RE.exec(target);
if (!match) {
throw new Error(`Invalid capability target version: ${target}`);
}
return {
major: Number(match[1]),
minor: Number(match[2]),
patch: Number(match[3]),
};
}
+191
View File
@@ -0,0 +1,191 @@
// MARK: Headscale Transport
//
// Internal HTTP transport for talking to a Headscale server. Owns
// the Undici agent (and any custom CA), the base URL, error
// translation, and the distinction between authenticated `/api/v1`
// calls and unauthenticated public endpoints (`/version`, `/health`).
//
// This module is intentionally not exported from the package; all
// consumers should go through `Headscale` and `HeadscaleClient` in
// `./index.ts`, never the transport directly.
import { readFile } from "node:fs/promises";
import { data } from "react-router";
import { Agent, type Dispatcher, request } from "undici";
import log from "~/utils/log";
import { undiciToFriendlyError } from "./error";
import { type HeadscaleAPIError, isApiError } from "./error-client";
export interface TransportRequest {
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
/** API path without the `/api/` prefix (e.g. `v1/node`). */
path: `v1/${string}`;
apiKey: string;
/** JSON request body for non-GET/DELETE requests. */
body?: Record<string, unknown>;
/** Query parameters for GET/DELETE requests. */
query?: Record<string, unknown>;
}
export interface Transport {
/**
* Send an authenticated JSON request against `/api/{path}`.
* Throws a React Router `data()` 502 response on transport errors
* and a typed `HeadscaleAPIError` (wrapped in `data()`) on API
* errors with statusCode >= 400.
*/
request<T>(opts: TransportRequest): Promise<T>;
/**
* Send an unauthenticated GET against the server root
* (e.g. `/version`, `/health`). Returns parsed JSON.
*/
getPublic<T>(path: `/${string}`): Promise<T>;
/** True if `GET /health` returns 200. Never throws. */
health(): Promise<boolean>;
/** Shut down the underlying Undici agent. */
dispose(): Promise<void>;
}
export interface TransportOptions {
url: string;
certPath?: string;
}
export async function createTransport(opts: TransportOptions): Promise<Transport> {
const agent = await createUndiciAgent(opts.certPath);
const baseUrl = opts.url;
async function rawRequest(
url: string,
options: Partial<Dispatcher.RequestOptions> & { method: string },
): Promise<Dispatcher.ResponseData> {
log.debug("api", "%s %s", options.method, url);
try {
return await request(new URL(url, baseUrl), {
dispatcher: agent,
headers: {
...options.headers,
Accept: "application/json",
"User-Agent": `Headplane/${__VERSION__}`,
},
body: options.body,
method: options.method,
});
} catch (error) {
const errorBody = undiciToFriendlyError(error, `${options.method} ${url}`);
throw data(errorBody, { status: 502, statusText: "Bad Gateway" });
}
}
return {
async request<T>({ method, path, apiKey, body, query }: TransportRequest): Promise<T> {
let url = `/api/${path}`;
const options: Partial<Dispatcher.RequestOptions> & { method: string } = {
method,
headers: { Authorization: `Bearer ${apiKey}` },
};
if (query && (method === "GET" || method === "DELETE")) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value !== undefined) {
params.append(key, String(value));
}
}
if ([...params.keys()].length > 0) {
url += `?${params.toString()}`;
}
} else if (body && method !== "GET" && method !== "DELETE") {
options.body = JSON.stringify(body);
options.headers = { ...options.headers, "Content-Type": "application/json" };
}
const res = await rawRequest(url, options);
if (res.statusCode >= 400) {
log.debug("api", "%s %s failed with status %d", method, path, res.statusCode);
const rawData = await res.body.text();
const jsonData = (() => {
try {
return JSON.parse(rawData) as Record<string, unknown>;
} catch {
return null;
}
})();
throw data(
{
requestUrl: `${method} ${path}`,
statusCode: res.statusCode,
rawData,
data: jsonData,
} satisfies HeadscaleAPIError,
{ status: 502, statusText: "Bad Gateway" },
);
}
return res.body.json() as Promise<T>;
},
async getPublic<T>(path: `/${string}`): Promise<T> {
const res = await rawRequest(path, { method: "GET" });
if (res.statusCode >= 400) {
const rawData = await res.body.text();
const jsonData = (() => {
try {
return JSON.parse(rawData) as Record<string, unknown>;
} catch {
return null;
}
})();
throw data(
{
requestUrl: `GET ${path}`,
statusCode: res.statusCode,
rawData,
data: jsonData,
} satisfies HeadscaleAPIError,
{ status: 502, statusText: "Bad Gateway" },
);
}
return res.body.json() as Promise<T>;
},
async health() {
try {
const res = await rawRequest("/health", { method: "GET" });
// Drain the body so the connection can be reused.
await res.body.dump();
return res.statusCode === 200;
} catch (error) {
if (isApiError(error)) {
log.debug("api", "Health check failed: %d", error.statusCode);
}
return false;
}
},
async dispose() {
await agent.close();
},
};
}
async function createUndiciAgent(certPath?: string): Promise<Agent> {
if (!certPath) return new Agent();
try {
log.debug("config", "Loading certificate from %s", certPath);
const cert = await readFile(certPath, "utf8");
log.info("config", "Using certificate from %s", certPath);
return new Agent({ connect: { ca: cert.trim() } });
} catch (error) {
log.error("config", "Failed to load Headscale TLS cert: %s", error);
log.debug("config", "Error Details: %o", error);
return new Agent();
}
}
-86
View File
@@ -1,86 +0,0 @@
import canonicals from "~/openapi-canonical-families.json";
import hashes from "~/openapi-operation-hashes.json";
import log from "~/utils/log";
/**
* The known API versions based on operation hashes.
*/
export type Version = keyof typeof hashes;
const VERSIONS = Object.keys(hashes) as Version[];
/**
* Detects the closest matching API version using operation hashes.
* Falls back to the latest known version and emits a warning if unknown.
*
* @param observed - A mapping of operation identifiers to their hashes.
* @returns The detected API version.
*/
export function detectApiVersion(observed: Record<string, string> | null): Version {
if (!observed) {
const latest = VERSIONS.at(-1)!;
log.warn("api", "No operation hashes observed, defaulting to version %s", latest);
return latest;
}
let bestVersion: Version | null = null;
let bestScore = -1;
for (const [version, known] of Object.entries(hashes) as [Version, Record<string, string>][]) {
let score = 0;
for (const [op, hash] of Object.entries(observed)) {
if (known[op] === hash) score++;
}
if (score > bestScore) {
bestVersion = version;
bestScore = score;
}
}
if (!bestVersion || bestScore === 0) {
const latest = VERSIONS.at(-1)!;
log.warn("api", "Could not determine API version, defaulting to %s", latest);
return latest;
}
if (bestScore < Object.keys(observed).length) {
log.warn(
"api",
"Partial version match: %d/%d endpoints for version %s",
bestScore,
Object.keys(observed).length,
bestVersion,
);
}
const canonical = Object.entries(canonicals).find(([_, family]) =>
family.includes(bestVersion),
)?.[0] as Version | undefined;
if (!canonical) {
log.warn("api", "Could not canonicalize detected version %s, using as-is", bestVersion);
return bestVersion;
}
if (canonical !== bestVersion) {
log.info(
"api",
"Canonicalizing detected version %s → %s (same schema)",
bestVersion,
canonical,
);
}
return canonical;
}
/**
* Checks if the current version is at least the baseline version.
*
* @param current - The current API version.
* @param baseline - The baseline API version to compare against.
* @returns True if current is at least baseline, false otherwise.
*/
export function isAtLeast(current: Version, baseline: Version) {
return VERSIONS.indexOf(current) >= VERSIONS.indexOf(baseline);
}
+1 -1
View File
@@ -99,7 +99,7 @@ async function validateConfigPath(path: string) {
try {
await access(path, constants.F_OK | constants.W_OK);
return { w: true, r: true };
} catch (error) {
} catch {
log.warn("config", "Headscale DNS file at %s is not writable", path);
return { w: false, r: true };
}
+324 -266
View File
@@ -1,258 +1,381 @@
import { constants, access, readFile, writeFile } from "node:fs/promises";
import { exit } from "node:process";
import { setTimeout } from "node:timers/promises";
import { type } from "arktype";
import * as v from "valibot";
import { Document, parseDocument } from "yaml";
import log from "~/utils/log";
import { DNSRecord, HeadscaleDNSConfig, loadHeadscaleDNS } from "./config-dns";
import { headscaleConfig } from "./config-schema";
interface PatchConfig {
path: string;
value: unknown;
}
// We need a class for the config because we need to be able to
// support retrieving it via a getter but also be able to
// patch it and to query it for its mode
class HeadscaleConfig {
private config?: typeof headscaleConfig.infer;
private document?: Document;
private access: "rw" | "ro" | "no";
private path?: string;
private writeLock = false;
private dns?: HeadscaleDNSConfig;
interface DNSConfigView {
magicDns: boolean;
baseDomain: string;
nameservers: string[];
splitDns: Record<string, string[]>;
searchDomains: string[];
overrideDns: boolean;
extraRecords: DNSRecord[];
}
constructor(
access: "rw" | "ro" | "no",
dns?: HeadscaleDNSConfig,
config?: typeof headscaleConfig.infer,
document?: Document,
path?: string,
) {
this.access = access;
this.config = config;
this.document = document;
this.path = path;
this.dns = dns;
interface OIDCConfigView {
issuer: string;
allowedDomains: string[];
allowedGroups: string[];
allowedUsers: string[];
}
interface ParsedDNSConfig {
magic_dns: boolean;
base_domain: string;
nameservers: {
global: string[];
split: Record<string, string[]>;
};
search_domains: string[];
override_local_dns: boolean;
extra_records: DNSRecord[];
extra_records_path?: string;
}
const DNS_CONFIG_DEFAULTS: ParsedDNSConfig = {
magic_dns: true,
base_domain: "",
nameservers: {
global: [],
split: {},
},
search_domains: [],
override_local_dns: true,
extra_records: [],
};
const stringSchema = v.string();
const stringArraySchema = v.array(v.string());
const stringArrayRecordSchema = v.record(v.string(), stringArraySchema);
const goBooleanSchema = v.pipe(
v.union([v.boolean(), v.picklist(["true", "false"])]),
v.transform((value) => value === true || value === "true"),
);
const dnsRecordsSchema = v.array(
v.object({
name: v.string(),
type: v.string(),
value: v.string(),
}),
);
const nameserversSchema = v.object({
global: v.optional(v.fallback(stringArraySchema, []), []),
split: v.optional(v.fallback(stringArrayRecordSchema, {}), {}),
});
const dnsConfigSchema = v.object({
magic_dns: v.optional(
v.fallback(goBooleanSchema, DNS_CONFIG_DEFAULTS.magic_dns),
DNS_CONFIG_DEFAULTS.magic_dns,
),
base_domain: v.optional(
v.fallback(stringSchema, DNS_CONFIG_DEFAULTS.base_domain),
DNS_CONFIG_DEFAULTS.base_domain,
),
nameservers: v.optional(
v.fallback(nameserversSchema, DNS_CONFIG_DEFAULTS.nameservers),
DNS_CONFIG_DEFAULTS.nameservers,
),
search_domains: v.optional(
v.fallback(stringArraySchema, DNS_CONFIG_DEFAULTS.search_domains),
DNS_CONFIG_DEFAULTS.search_domains,
),
override_local_dns: v.optional(
v.fallback(goBooleanSchema, DNS_CONFIG_DEFAULTS.override_local_dns),
DNS_CONFIG_DEFAULTS.override_local_dns,
),
extra_records: v.optional(
v.fallback(dnsRecordsSchema, DNS_CONFIG_DEFAULTS.extra_records),
DNS_CONFIG_DEFAULTS.extra_records,
),
extra_records_path: v.optional(v.string()),
});
const headscaleConfigSchema = v.fallback(
v.object({
dns: v.optional(v.fallback(dnsConfigSchema, DNS_CONFIG_DEFAULTS), DNS_CONFIG_DEFAULTS),
}),
{ dns: DNS_CONFIG_DEFAULTS },
);
const oidcConfigSchema = v.object({
issuer: v.string(),
allowed_domains: v.optional(v.fallback(stringArraySchema, []), []),
allowed_groups: v.optional(v.fallback(stringArraySchema, []), []),
allowed_users: v.optional(v.fallback(stringArraySchema, []), []),
});
const rawOIDCConfigSchema = v.fallback(
v.object({
oidc: v.optional(v.unknown()),
}),
{},
);
const extraRecordsConflictSchema = v.object({
dns: v.optional(
v.object({
extra_records: v.optional(v.array(v.unknown())),
extra_records_path: v.optional(v.string()),
}),
),
});
interface HeadscaleConfigState {
document?: Document;
config: unknown;
access: "rw" | "ro" | "no";
path?: string;
writeQueue: Promise<void>;
dns?: HeadscaleDNSConfig;
}
interface HeadscaleConfig {
readable: () => boolean;
writable: () => boolean;
getDNSConfig: () => DNSConfigView;
getMagicDNSBaseDomain: () => string | undefined;
getOIDCConfig: () => OIDCConfigView | undefined;
hasOIDCConfig: () => boolean;
dnsRecords: () => DNSRecord[];
patch: (patches: PatchConfig[]) => Promise<void>;
addDNS: (record: DNSRecord) => Promise<boolean | void>;
removeDNS: (record: DNSRecord) => Promise<boolean | void>;
}
function createHeadscaleConfig(
access: "rw" | "ro" | "no",
dns?: HeadscaleDNSConfig,
document?: Document,
path?: string,
): HeadscaleConfig {
const state: HeadscaleConfigState = {
access,
config: document?.toJSON() ?? {},
document,
path,
writeQueue: Promise.resolve(),
dns,
};
return {
readable: () => readable(state),
writable: () => writable(state),
getDNSConfig: () => getDNSConfig(state),
getMagicDNSBaseDomain: () => getMagicDNSBaseDomain(state),
getOIDCConfig: () => getOIDCConfig(state),
hasOIDCConfig: () => hasOIDCConfig(state),
dnsRecords: () => dnsRecords(state),
patch: (patches) => patchHeadscaleConfig(state, patches),
addDNS: (record) => addDNS(state, record),
removeDNS: (record) => removeDNS(state, record),
};
}
function readable(config: HeadscaleConfigState) {
return config.access !== "no";
}
function writable(config: HeadscaleConfigState) {
return config.access === "rw";
}
function getDNSConfig(config: HeadscaleConfigState): DNSConfigView {
const dns = v.parse(headscaleConfigSchema, config.config).dns;
return {
magicDns: dns.magic_dns,
baseDomain: dns.base_domain,
nameservers: dns.nameservers.global,
splitDns: dns.nameservers.split ?? {},
searchDomains: dns.search_domains,
overrideDns: dns.override_local_dns,
extraRecords: dnsRecords(config),
};
}
function getMagicDNSBaseDomain(config: HeadscaleConfigState) {
if (!readable(config)) return;
const dns = getDNSConfig(config);
return dns.magicDns && dns.baseDomain ? dns.baseDomain : undefined;
}
function getOIDCConfig(config: HeadscaleConfigState): OIDCConfigView | undefined {
const oidc = v.safeParse(oidcConfigSchema, v.parse(rawOIDCConfigSchema, config.config).oidc);
if (!oidc.success) return;
return {
issuer: oidc.output.issuer,
allowedDomains: oidc.output.allowed_domains,
allowedGroups: oidc.output.allowed_groups,
allowedUsers: oidc.output.allowed_users,
};
}
function hasOIDCConfig(config: HeadscaleConfigState) {
return getOIDCConfig(config) !== undefined;
}
function dnsRecords(config: HeadscaleConfigState) {
if (config.dns) {
return config.dns.r;
}
readable() {
return this.access !== "no";
}
return v.parse(headscaleConfigSchema, config.config).dns.extra_records;
}
writable() {
return this.access === "rw";
}
get c() {
return this.config;
}
get d() {
if (this.dns) {
return this.dns.r;
}
return this.config?.dns.extra_records ?? [];
}
async patch(patches: PatchConfig[]) {
if (!this.path || !this.document || !this.readable() || !this.writable()) {
return;
}
log.debug("config", "Patching Headscale configuration");
for (const patch of patches) {
const { path, value } = patch;
log.debug("config", "Patching %s with %o", path, value);
// If the key is something like `test.bar."foo.bar"`, then we treat
// the foo.bar as a single key, and not as two keys, so that needs
// to be split correctly.
// Iterate through each character, and if we find a dot, we check if
// the next character is a quote, and if it is, we skip until the next
// quote, and then we skip the next character, which should be a dot.
// If it's not a quote, we split it.
const key = [];
let current = "";
let quote = false;
for (const char of path) {
if (char === '"') {
quote = !quote;
}
if (char === "." && !quote) {
key.push(current);
current = "";
continue;
}
current += char;
}
key.push(current.replaceAll('"', ""));
if (value === null) {
this.document.deleteIn(key);
continue;
}
this.document.setIn(key, value);
}
// Revalidate our configuration and update the config
// object with the new configuration
log.info("config", "Revalidating Headscale configuration");
const config = validateConfig(this.document.toJSON());
if (!config) {
return;
}
log.debug("config", "Writing updated Headscale configuration to %s", this.path);
// We need to lock the writeLock so that we don't try to write
// to the file while we're already writing to it
while (this.writeLock) {
await setTimeout(100);
}
this.writeLock = true;
await writeFile(this.path, this.document.toString(), "utf8");
this.config = config;
this.writeLock = false;
async function patchHeadscaleConfig(config: HeadscaleConfigState, patches: PatchConfig[]) {
if (!config.path || !config.document || !readable(config) || !writable(config)) {
return;
}
/**
* Adds a DNS record to the Headscale configuration.
* Differentiates between the file mode and config mode automatically.
* @param record The DNS record to add.
* @returns True if we need to restart the integration.
*/
async addDNS(record: DNSRecord) {
if (this.dns) {
if (!this.dns.readable() || !this.dns.writable()) {
log.debug("config", "DNS config is not writable");
return;
}
const write = config.writeQueue.then(() => writePatches(config, patches));
config.writeQueue = write.catch(() => undefined);
await write;
}
const records = this.dns.r;
if (records.some((i) => i.name === record.name && i.type === record.type)) {
log.debug("config", "DNS record already exists");
return;
}
async function writePatches(config: HeadscaleConfigState, patches: PatchConfig[]) {
if (!config.path || !config.document) return;
return this.dns.patch([...records, record]);
log.debug("config", "Patching Headscale configuration");
for (const patch of patches) {
const { path, value } = patch;
log.debug("config", "Patching %s with %o", path, value);
const key = splitPatchPath(path);
if (value === null) {
config.document.deleteIn(key);
continue;
}
// If we get here, we need to add to the main config instead of
// a separate file (which requires an integration restart)
const existing = this.config?.dns.extra_records ?? [];
if (existing.some((i) => i.name === record.name && i.type === record.type)) {
config.document.setIn(key, value);
}
log.debug("config", "Writing updated Headscale configuration to %s", config.path);
await writeFile(config.path, config.document.toString(), "utf8");
config.config = config.document.toJSON();
}
function splitPatchPath(path: string) {
const key = [];
let current = "";
let quote = false;
for (const char of path) {
if (char === '"') {
quote = !quote;
continue;
}
if (char === "." && !quote) {
key.push(current);
current = "";
continue;
}
current += char;
}
key.push(current);
return key;
}
async function addDNS(config: HeadscaleConfigState, record: DNSRecord) {
if (config.dns) {
if (!config.dns.readable() || !config.dns.writable()) {
log.debug("config", "DNS config is not writable");
return;
}
const records = config.dns.r;
if (records.some((i) => i.name === record.name && i.type === record.type)) {
log.debug("config", "DNS record already exists");
return;
}
await this.patch([
{
path: "dns.extra_records",
value: Array.from(new Set([...existing, record])),
},
]);
return true;
return config.dns.patch([...records, record]);
}
/**
* Removes a DNS record from the Headscale configuration.
* Differentiates between the file mode and config mode automatically.
* @param records The DNS record to remove.
* @returns True if we need to restart the integration.
*/
async removeDNS(record: DNSRecord) {
// In this case we need to check both the main config and the DNS config
// to see if the record exists, and if it does, we need to remove it
// from both places.
const existing = dnsRecords(config);
if (existing.some((i) => i.name === record.name && i.type === record.type)) {
log.debug("config", "DNS record already exists");
return;
}
if (this.dns) {
if (!this.dns.readable() || !this.dns.writable()) {
log.debug("config", "DNS config is not writable");
return;
}
await patchHeadscaleConfig(config, [
{
path: "dns.extra_records",
value: [...existing, record],
},
]);
const records = this.dns.r.filter((i) => i.name !== record.name || i.type !== record.type);
return true;
}
return this.dns.patch(records);
}
// If we get here, we need to remove from the main config instead of
// a separate file (which requires an integration restart)
const existing = this.config?.dns.extra_records ?? [];
const filtered = existing.filter((i) => i.name !== record.name || i.type !== record.type);
// If the length of the existing records is the same as the filtered
// records, then we don't need to do anything
if (existing.length === filtered.length) {
async function removeDNS(config: HeadscaleConfigState, record: DNSRecord) {
if (config.dns) {
if (!config.dns.readable() || !config.dns.writable()) {
log.debug("config", "DNS config is not writable");
return;
}
await this.patch([
{
path: "dns.extra_records",
value: existing.filter((i) => i.name !== record.name || i.type !== record.type),
},
]);
return true;
const records = config.dns.r.filter((i) => i.name !== record.name || i.type !== record.type);
return config.dns.patch(records);
}
const existing = dnsRecords(config);
const filtered = existing.filter((i) => i.name !== record.name || i.type !== record.type);
if (existing.length === filtered.length) {
return;
}
await patchHeadscaleConfig(config, [
{
path: "dns.extra_records",
value: filtered,
},
]);
return true;
}
export async function loadHeadscaleConfig(path?: string, strict = true, dnsPath?: string) {
export async function loadHeadscaleConfig(path?: string, dnsPath?: string) {
if (!path) {
log.debug("config", "No Headscale configuration file was provided");
return new HeadscaleConfig("no");
return createHeadscaleConfig("no");
}
log.debug("config", "Loading Headscale configuration file: %s", path);
const { r, w } = await validateConfigPath(path);
if (!r) {
return new HeadscaleConfig("no");
return createHeadscaleConfig("no");
}
const document = await loadConfigFile(path);
if (!document) {
return new HeadscaleConfig("no");
return createHeadscaleConfig("no");
}
if (!strict) {
return new HeadscaleConfig(
w ? "rw" : "ro",
new HeadscaleDNSConfig("no"),
augmentUnstrictConfig(document.toJSON()),
document,
path,
const rawConfig = document.toJSON();
const parsedConfig = v.parse(headscaleConfigSchema, rawConfig);
const conflict = v.safeParse(extraRecordsConflictSchema, rawConfig);
const extraRecordsPath = parsedConfig.dns.extra_records_path;
if (conflict.success && conflict.output.dns?.extra_records && extraRecordsPath) {
log.warn(
"config",
"Both dns.extra_records and dns.extra_records_path are set; Headplane will use the JSON records file",
);
}
const config = validateConfig(document.toJSON());
if (!config) {
return new HeadscaleConfig("no");
}
if (config.dns.extra_records && config.dns.extra_records_path) {
log.warn("config", "Both extra_records and extra_records_path are set, Headscale will crash");
log.warn("config", "Please remove one of them from the configuration file");
return new HeadscaleConfig("no");
}
const dns = await loadHeadscaleDNS(dnsPath);
if (dns && !config.dns.extra_records_path) {
const dns = await loadHeadscaleDNS(dnsPath ?? extraRecordsPath);
if (dns && !extraRecordsPath) {
log.error(
"config",
"Using separate DNS config file but dns.extra_records_path is not set in Headscale config",
@@ -263,7 +386,7 @@ export async function loadHeadscaleConfig(path?: string, strict = true, dnsPath?
exit(1);
}
return new HeadscaleConfig(w ? "rw" : "ro", dns, config, document, path);
return createHeadscaleConfig(w ? "rw" : "ro", dns, document, path);
}
async function validateConfigPath(path: string) {
@@ -279,7 +402,7 @@ async function validateConfigPath(path: string) {
try {
await access(path, constants.F_OK | constants.W_OK);
return { w: true, r: true };
} catch (error) {
} catch {
log.warn("config", "Headscale configuration file at %s is not writable", path);
return { w: false, r: true };
}
@@ -306,68 +429,3 @@ async function loadConfigFile(path: string) {
return false;
}
}
export function validateConfig(config: unknown) {
log.debug("config", "Validating Headscale configuration");
const result = headscaleConfig(config);
if (result instanceof type.errors) {
log.error("config", "Error validating Headscale configuration:");
for (const [number, error] of result.entries()) {
log.error("config", ` - (${number}): ${error.toString()}`);
}
return;
}
return result;
}
// If config_strict is false, we set the defaults and disable
// the schema checking for the values that are not present
function augmentUnstrictConfig(loaded: Partial<typeof headscaleConfig.infer>) {
log.debug("config", "Augmenting Headscale configuration in non-strict mode");
const config = {
...loaded,
tls_letsencrypt_cache_dir: loaded.tls_letsencrypt_cache_dir ?? "/var/www/cache",
tls_letsencrypt_challenge_type: loaded.tls_letsencrypt_challenge_type ?? "HTTP-01",
grpc_listen_addr: loaded.grpc_listen_addr ?? ":50443",
grpc_allow_insecure: loaded.grpc_allow_insecure ?? false,
randomize_client_port: loaded.randomize_client_port ?? false,
unix_socket: loaded.unix_socket ?? "/var/run/headscale/headscale.sock",
unix_socket_permission: loaded.unix_socket_permission ?? "0770",
log: loaded.log ?? {
level: "info",
format: "text",
},
logtail: loaded.logtail ?? {
enabled: false,
},
prefixes: loaded.prefixes ?? {
allocation: "sequential",
v4: "",
v6: "",
},
dns: loaded.dns ?? {
nameservers: {
global: [],
split: {},
},
search_domains: [],
extra_records: [],
magic_dns: false,
base_domain: "headscale.net",
},
};
log.warn("config", "Headscale configuration was loaded in non-strict mode");
log.warn("config", "This is very dangerous and comes with a few caveats:");
log.warn("config", " - Headplane could very easily crash");
log.warn("config", " - Headplane could break your Headscale installation");
log.warn("config", " - The UI could throw random errors/show incorrect data");
return config as typeof headscaleConfig.infer;
}
-150
View File
@@ -1,150 +0,0 @@
import { type } from "arktype";
const goBool = type('boolean | "true" | "false"').pipe((v) => {
if (v === "true") return true;
if (v === "false") return false;
return v;
});
const goDuration = type("0 | string").pipe((v) => {
return v.toString();
});
const databaseConfig = type({
type: '"sqlite" | "sqlite3"',
sqlite: {
path: "string",
write_ahead_log: goBool.default(true),
wal_autocheckpoint: "number = 1000",
},
})
.or({
type: '"postgres"',
postgres: {
host: "string",
port: 'number | ""',
name: "string",
user: "string",
pass: "string",
max_open_conns: "number = 10",
max_idle_conns: "number = 10",
conn_max_idle_time_secs: "number = 3600",
ssl: goBool.default(false),
},
})
.merge({
debug: goBool.default(false),
"gorm?": {
prepare_stmt: goBool.default(true),
parameterized_queries: goBool.default(true),
skip_err_record_not_found: goBool.default(true),
slow_threshold: "number = 1000",
},
});
// Not as strict parsing because we just need the values
// to be slightly truthy enough to safely modify them
export type HeadscaleConfig = typeof headscaleConfig.infer;
export const headscaleConfig = type({
server_url: "string",
listen_addr: "string",
"metrics_listen_addr?": "string",
grpc_listen_addr: 'string = ":50433"',
grpc_allow_insecure: goBool.default(false),
noise: {
private_key_path: "string",
},
prefixes: {
v4: "string?",
v6: "string?",
allocation: '"sequential" | "random" = "sequential"',
},
derp: {
server: {
enabled: goBool.default(true),
region_id: "number?",
region_code: "string?",
region_name: "string?",
stun_listen_addr: "string?",
private_key_path: "string?",
ipv4: "string?",
ipv6: "string?",
automatically_add_embedded_derp_region: goBool.default(true),
},
urls: "string[]?",
paths: "string[]?",
auto_update_enabled: goBool.default(true),
update_frequency: goDuration.default("24h"),
},
disable_check_updates: goBool.default(false),
ephemeral_node_inactivity_timeout: goDuration.default("30m"),
database: databaseConfig,
acme_url: 'string = "https://acme-v02.api.letsencrypt.org/directory"',
acme_email: 'string = ""',
tls_letsencrypt_hostname: 'string = ""',
tls_letsencrypt_cache_dir: 'string = "/var/lib/headscale/cache"',
tls_letsencrypt_challenge_type: 'string = "HTTP-01"',
tls_letsencrypt_listen: 'string = ":http"',
"tls_cert_path?": "string",
"tls_key_path?": "string",
log: type({
format: 'string = "text"',
level: 'string = "info"',
}).default(() => ({ format: "text", level: "info" })),
"policy?": {
mode: '"database" | "file" = "file"',
path: "string?",
},
dns: {
magic_dns: goBool.default(true),
base_domain: 'string = "headscale.net"',
override_local_dns: goBool.default(false),
nameservers: type({
global: type("string[]").default(() => []),
split: type("Record<string, string[]>").default(() => ({})),
}).default(() => ({ global: [], split: {} })),
search_domains: type("string[]").default(() => []),
extra_records: type({
name: "string",
value: "string",
type: 'string | "A"',
})
.array()
.optional(),
extra_records_path: "string?",
},
unix_socket: "string?",
unix_socket_permission: 'string = "0770"',
"oidc?": {
only_start_if_oidc_is_available: goBool.default(false),
issuer: "string",
client_id: "string",
client_secret: "string?",
client_secret_path: "string?",
expiry: goDuration.default("180d"),
use_expiry_from_token: goBool.default(false),
scope: type("string[]").default(() => ["openid", "email", "profile"]),
extra_params: "Record<string, string>?",
allowed_domains: "string[]?",
allowed_groups: "string[]?",
allowed_users: "string[]?",
"pkce?": {
enabled: goBool.default(false),
method: 'string = "S256"',
},
map_legacy_users: goBool.default(false),
},
"logtail?": {
enabled: goBool.default(false),
},
randomize_client_port: goBool.default(false),
});
+10 -10
View File
@@ -1,6 +1,6 @@
import log from "~/utils/log";
import type { RuntimeApiClient } from "./api/endpoints";
import type { HeadscaleClient } from "./api";
/**
* Defines a resource that can be fetched and polled by the live store.
@@ -19,7 +19,7 @@ export interface ResourceDefinition<T> {
/**
* A callback to fire to get the latest data for this resource
*/
readonly fetch: (client: RuntimeApiClient) => Promise<T>;
readonly fetch: (client: HeadscaleClient) => Promise<T>;
}
/**
@@ -37,12 +37,12 @@ export function defineResource<T>(
export const nodesResource = defineResource("nodes", {
pollInterval: 5_000,
fetch: (api) => api.getNodes(),
fetch: (api) => api.nodes.list(),
});
export const usersResource = defineResource("users", {
pollInterval: 15_000,
fetch: (api) => api.getUsers(),
fetch: (api) => api.users.list(),
});
interface Snapshot<T> {
@@ -54,8 +54,8 @@ interface Snapshot<T> {
type ChangeListener = (resourceKey: string, version: string) => void;
export interface LiveStore {
get<T>(resource: ResourceDefinition<T>, apiClient: RuntimeApiClient): Promise<Snapshot<T>>;
refresh<T>(resource: ResourceDefinition<T>, apiClient: RuntimeApiClient): Promise<void>;
get<T>(resource: ResourceDefinition<T>, apiClient: HeadscaleClient): Promise<Snapshot<T>>;
refresh<T>(resource: ResourceDefinition<T>, apiClient: HeadscaleClient): Promise<void>;
getVersions(): Record<string, string>;
subscribe(listener: ChangeListener): () => void;
dispose(): void;
@@ -66,7 +66,7 @@ export function createLiveStore(resources: ResourceDefinition<unknown>[]): LiveS
const serializedCache = new Map<string, string>();
const listeners = new Set<ChangeListener>();
const intervals = new Map<string, ReturnType<typeof setInterval>>();
let storedApiClient: RuntimeApiClient | undefined;
let storedApiClient: HeadscaleClient | undefined;
let versionCounter = 0;
function notifyListeners(resourceKey: string, version: string) {
@@ -77,7 +77,7 @@ export function createLiveStore(resources: ResourceDefinition<unknown>[]): LiveS
async function fetchResource(
resource: ResourceDefinition<unknown>,
apiClient: RuntimeApiClient,
apiClient: HeadscaleClient,
): Promise<void> {
const data = await resource.fetch(apiClient);
const json = JSON.stringify(data);
@@ -138,7 +138,7 @@ export function createLiveStore(resources: ResourceDefinition<unknown>[]): LiveS
return {
async get<T>(
resource: ResourceDefinition<T>,
apiClient: RuntimeApiClient,
apiClient: HeadscaleClient,
): Promise<Snapshot<T>> {
storedApiClient = apiClient;
const def = findResource(resource.key);
@@ -154,7 +154,7 @@ export function createLiveStore(resources: ResourceDefinition<unknown>[]): LiveS
return snapshots.get(resource.key) as Snapshot<T>;
},
async refresh<T>(resource: ResourceDefinition<T>, apiClient: RuntimeApiClient): Promise<void> {
async refresh<T>(resource: ResourceDefinition<T>, apiClient: HeadscaleClient): Promise<void> {
storedApiClient = apiClient;
const def = findResource(resource.key);
if (!def) {
+79 -19
View File
@@ -1,5 +1,5 @@
import { type ChildProcess, spawn } from "node:child_process";
import { access, constants, mkdir, rm, stat } from "node:fs/promises";
import { access, constants, mkdir, stat } from "node:fs/promises";
import { join } from "node:path";
import { createInterface } from "node:readline";
@@ -11,11 +11,16 @@ import log from "~/utils/log";
import { HeadplaneConfig } from "./config/config-schema";
import { hostInfo } from "./db/schema";
import { RuntimeApiClient } from "./headscale/api/endpoints";
import type { HeadscaleClient } from "./headscale/api";
export interface AgentManager {
lookup(nodeKeys: string[]): Promise<Record<string, HostInfo>>;
lastSync(): { syncedAt: Date | null; nodeCount: number; error?: string };
lastSync(): {
syncedAt: Date | null;
nodeCount: number;
error?: string;
authUrl?: string;
};
agentNodeKey(): string | undefined;
triggerSync(): Promise<void>;
dispose(): void;
@@ -32,6 +37,7 @@ interface SyncState {
nodeCount: number;
selfKey?: string;
error?: string;
authUrl?: string;
}
async function hasExistingState(workDir: string): Promise<boolean> {
@@ -46,7 +52,7 @@ async function hasExistingState(workDir: string): Promise<boolean> {
export async function createAgentManager(
agentConfig: NonNullable<NonNullable<HeadplaneConfig["integration"]>["agent"]> | undefined,
headscaleUrl: string,
apiClient: RuntimeApiClient,
apiClient: HeadscaleClient,
supportsTagOnlyKeys: boolean,
db: NodeSQLiteDatabase,
): Promise<AgentManager | undefined> {
@@ -98,12 +104,17 @@ export async function createAgentManager(
let responseHandler: ((line: string) => void) | null = null;
let disposed = false;
let consecutiveErrors = 0;
let approvingAuthId: string | undefined;
async function generateAuthKey(): Promise<string> {
const expiration = new Date(Date.now() + 5 * 60_000);
const pak = await apiClient.createPreAuthKey(null, false, false, expiration, [
`tag:${hostName}`,
]);
const pak = await apiClient.preAuthKeys.create({
user: null,
ephemeral: false,
reusable: false,
expiration,
aclTags: [`tag:${hostName}`],
});
return pak.key;
}
@@ -118,6 +129,9 @@ export async function createAgentManager(
if (authKey) {
env.HEADPLANE_AGENT_TS_AUTHKEY = authKey;
log.info("agent", "Spawning agent with pre-auth key (prefix: %s)", authKey.slice(0, 16));
} else {
log.info("agent", "Spawning agent without pre-auth key (reusing existing state)");
}
const child = spawn(executablePath, [], {
@@ -127,8 +141,40 @@ export async function createAgentManager(
child.stderr?.on("data", (chunk: Buffer) => {
const text = chunk.toString().trim();
if (text) {
log.debug("agent", "%s", text);
if (!text) {
return;
}
log.debug("agent", "%s", text);
// tsnet prints an auth URL when it falls back to interactive login.
// Capture it so the UI can surface the approval link if needed, and try
// to auto-approve the agent using Headplane's admin API access.
const authMatch = text.match(
/To start this tsnet server, restart with TS_AUTHKEY set, or go to: (https:\/\/\S+)/,
);
if (authMatch) {
state.authUrl = authMatch[1];
log.warn("agent", "Agent is waiting for interactive approval; visit: %s", state.authUrl);
const authId = state.authUrl.split("/").pop();
if (authId && authId !== approvingAuthId) {
approvingAuthId = authId;
log.info("agent", "Attempting to auto-approve auth request %s", authId);
apiClient.auth
.approve(authId)
.then(() => {
log.info("agent", "Auto-approved auth request %s", authId);
})
.catch((error) => {
log.warn(
"agent",
"Failed to auto-approve auth request %s: %s",
authId,
error instanceof Error ? error.message : String(error),
);
});
}
}
});
@@ -144,6 +190,13 @@ export async function createAgentManager(
child.on("exit", (code, signal) => {
if (!disposed) {
log.warn("agent", "Agent process exited (code=%s, signal=%s)", code, signal);
} else {
log.info(
"agent",
"Agent process exited during disposal (code=%s, signal=%s)",
code,
signal,
);
}
proc = null;
@@ -165,13 +218,18 @@ export async function createAgentManager(
}
const stateExists = await hasExistingState(workDir);
const authKey = await generateAuthKey();
if (stateExists) {
log.debug("agent", "Reusing existing tsnet identity");
return spawnAgent("");
log.info(
"agent",
"Existing state found; agent will use it and fall back to the pre-auth key if needed",
);
} else {
log.info("agent", "No tsnet state found, agent will register with a pre-auth key");
}
log.info("agent", "No tsnet state found, generating pre-auth key");
return spawnAgent(await generateAuthKey());
return spawnAgent(authKey);
}
function sendSync(child: ChildProcess): Promise<string> {
@@ -210,10 +268,9 @@ export async function createAgentManager(
log.error("agent", "Sync error from agent (%d/5): %s", consecutiveErrors, output.error);
if (consecutiveErrors >= 5 && proc) {
log.warn("agent", "Too many consecutive errors, killing agent and clearing state");
log.warn("agent", "Too many consecutive errors, killing agent process for retry");
proc.kill("SIGTERM");
proc = null;
await rm(join(workDir, "tailscaled.state"), { force: true });
}
return;
}
@@ -254,8 +311,10 @@ export async function createAgentManager(
log.error("agent", "Sync failed (%d/5): %s", consecutiveErrors, message);
if (consecutiveErrors >= 5) {
log.warn("agent", "Too many consecutive failures, clearing state for next attempt");
await rm(join(workDir, "tailscaled.state"), { force: true });
log.warn(
"agent",
"Too many consecutive failures; agent state is being preserved to avoid creating a new host",
);
}
} finally {
isSyncing = false;
@@ -272,7 +331,7 @@ export async function createAgentManager(
*/
async function pruneStaleHostInfo() {
try {
const nodes = await apiClient.getNodes();
const nodes = await apiClient.nodes.list();
const activeKeys = nodes.map((n) => n.nodeKey);
if (activeKeys.length === 0) {
@@ -298,11 +357,11 @@ export async function createAgentManager(
async function pruneEphemeralNodes() {
try {
const nodes = await apiClient.getNodes();
const nodes = await apiClient.nodes.list();
const toPrune = nodes.filter((n) => n.preAuthKey?.ephemeral && !n.online);
for (const node of toPrune) {
await apiClient.deleteNode(node.id);
await apiClient.nodes.delete(node.id);
log.info("agent", "Pruned offline ephemeral node %s", node.givenName);
}
} catch (error) {
@@ -338,6 +397,7 @@ export async function createAgentManager(
syncedAt: state.syncedAt,
nodeCount: state.nodeCount,
error: state.error,
authUrl: state.authUrl,
};
},
+51 -2
View File
@@ -8,23 +8,72 @@
// Vite, and the dev-only `runtime/vite-plugin.ts` dispatches requests
// straight to `./app`'s default export.
import { readFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { exit } from "node:process";
import { fileURLToPath } from "node:url";
import { composeListener, startHttpServer } from "../../runtime/http";
import requestListener, { config } from "./app";
import log from "~/utils/log";
import { type StartOptions, composeListener, startHttpServer } from "../../runtime/http";
import requestListener, { config, dispose } from "./app";
// `import.meta.url` resolves to `build/server/index.js`; the built
// client lives next to it at `build/client/`.
const clientDir = resolve(dirname(fileURLToPath(import.meta.url)), "../client");
let tls: StartOptions["tls"];
const { tls_cert_path: certPath, tls_key_path: keyPath } = config.server;
if (certPath || keyPath) {
if (!certPath || !keyPath) {
log.error(
"server",
"TLS misconfigured: both `server.tls_cert_path` and `server.tls_key_path` must be provided",
);
exit(1);
}
try {
const [cert, key] = await Promise.all([readFile(certPath), readFile(keyPath)]);
tls = { cert, key };
} catch (err) {
log.error("server", "Failed to read TLS material: %s", err);
exit(1);
}
}
// `HEADPLANE_LISTEN_FILE` is a Docker-specific contract: the
// Dockerfile sets it to `/tmp/headplane-listen` so the bundled
// `hp_healthcheck` binary can discover the URL to probe. Native
// installs don't ship a consumer, so we just skip writing anything
// if the var isn't set.
const listenFilePath = process.env.HEADPLANE_LISTEN_FILE;
const listenFile = listenFilePath
? {
path: listenFilePath,
// Full URL including `__PREFIX__` so the Go binary can GET it
// verbatim — no path joining, no basename knowledge needed.
url: `${tls ? "https" : "http"}://127.0.0.1:${config.server.port}${__PREFIX__}/healthz`,
}
: undefined;
const runtimeLogger = {
info: (message: string, ...args: unknown[]) => log.info("server", message, ...args),
error: (message: string, ...args: unknown[]) => log.error("server", message, ...args),
};
startHttpServer({
host: config.server.host,
port: config.server.port,
tls,
logger: runtimeLogger,
listenFile,
listener: composeListener({
basename: __PREFIX__,
staticRoot: clientDir,
immutableAssets: true,
logger: runtimeLogger,
requestListener,
}),
onShutdown: dispose,
});
+44 -4
View File
@@ -23,6 +23,7 @@ export interface OidcConfig {
usePkce?: boolean;
scope?: string;
subjectClaims?: string[];
roleClaim?: string;
allowWeakRsaKeys?: boolean;
extraParams?: Record<string, string>;
profilePictureSource?: "oidc" | "gravatar";
@@ -51,6 +52,7 @@ export interface OidcIdentity {
username: string;
email?: string;
picture?: string;
role?: string;
idToken?: string;
}
@@ -73,6 +75,13 @@ export interface OidcError {
hint?: string;
}
export function logOidcError(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);
}
}
type JwksResolver = (
protectedHeader?: JWSHeaderParameters,
token?: FlattenedJWSInput,
@@ -106,6 +115,7 @@ interface OidcClaims extends JWTPayload {
preferred_username?: string;
email?: string;
picture?: string;
[claim: string]: unknown;
}
interface TokenResponse {
@@ -451,14 +461,15 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
body: URLSearchParams,
method: "client_secret_basic" | "client_secret_post",
): Promise<Result<TokenResponse, OidcError>> {
const requestBody = new URLSearchParams(body);
const headers: Record<string, string> = {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
};
if (method === "client_secret_post") {
body.set("client_id", config.clientId);
body.set("client_secret", config.clientSecret);
requestBody.set("client_id", config.clientId);
requestBody.set("client_secret", config.clientSecret);
} else {
const credentials = btoa(
`${encodeURIComponent(config.clientId)}:${encodeURIComponent(config.clientSecret)}`,
@@ -472,7 +483,7 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
response = await fetch(tokenEndpoint, {
method: "POST",
headers,
body: body.toString(),
body: requestBody.toString(),
signal: AbortSignal.timeout(10_000),
});
} catch (cause) {
@@ -695,7 +706,11 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
const needsEnrichment =
!claims.name && !claims.email && !claims.picture && !!resolveSubject(claims);
const needsSubjectEnrichment = !resolveSubject(claims);
if ((!needsEnrichment && !needsSubjectEnrichment) || !ep.userinfoEndpoint) {
const needsRoleEnrichment = !!config.roleClaim && claims[config.roleClaim] === undefined;
if (
(!needsEnrichment && !needsSubjectEnrichment && !needsRoleEnrichment) ||
!ep.userinfoEndpoint
) {
return claims;
}
@@ -714,6 +729,9 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
}
const userInfo = (await response.json()) as Record<string, unknown>;
const roleClaimValue = config.roleClaim
? (claims[config.roleClaim] ?? userInfo[config.roleClaim])
: undefined;
const subjectClaimValues = Object.fromEntries(
getSubjectClaimOrder()
.filter((claim) => claim !== "sub")
@@ -725,6 +743,9 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
return {
...claims,
...subjectClaimValues,
...(config.roleClaim && roleClaimValue !== undefined
? { [config.roleClaim]: roleClaimValue }
: {}),
name: claims.name ?? (userInfo.name as string | undefined),
given_name: claims.given_name ?? (userInfo.given_name as string | undefined),
family_name: claims.family_name ?? (userInfo.family_name as string | undefined),
@@ -776,6 +797,7 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
username,
email: claims.email,
picture,
role: config.roleClaim ? resolveRoleClaim(claims, config.roleClaim) : undefined,
idToken,
};
}
@@ -830,6 +852,24 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
return undefined;
}
function resolveRoleClaim(claims: OidcClaims, claimName: string): string | undefined {
const value = claims[claimName];
if (typeof value === "string") {
return value.trim() || undefined;
}
if (Array.isArray(value)) {
const roles = new Set(value.filter((v): v is string => typeof v === "string"));
for (const role of ["admin", "network_admin", "it_admin", "auditor", "viewer", "member"]) {
if (roles.has(role)) {
return role;
}
}
}
return undefined;
}
return {
status,
discover,
+351 -79
View File
@@ -1,4 +1,5 @@
import { createHash, createHmac } from "node:crypto";
import { isIP } from "node:net";
import { eq, lt, sql } from "drizzle-orm";
import { NodeSQLiteDatabase } from "drizzle-orm/node-sqlite";
@@ -17,23 +18,36 @@ export type Principal =
displayName: string;
apiKey: string;
}
| {
kind: "oidc";
sessionId: string;
idToken?: string;
user: {
id: string;
subject: string;
role: Role;
headscaleUserId: string | undefined;
};
profile: {
name: string;
email?: string;
username?: string;
picture?: string;
};
};
| UserPrincipal;
export type UserPrincipal = {
kind: "oidc" | "proxy";
sessionId: string;
idToken?: string;
user: {
id: string;
subject: string;
role: Role;
headscaleUserId: string | undefined;
};
profile: {
name: string;
email?: string;
username?: string;
picture?: string;
};
};
interface ProxyAuthOptions {
enabled: boolean;
allowedCidrs?: string[];
trustedProxyCidrs?: string[];
ipHeader?: string;
userHeader?: string;
emailHeader?: string;
nameHeader?: string;
pictureHeader?: string;
}
interface CookiePayload {
sid: string;
@@ -48,6 +62,7 @@ interface CookiePayload {
export interface AuthServiceOptions {
secret: string;
headscaleApiKey?: string;
proxyAuth?: ProxyAuthOptions;
db: NodeSQLiteDatabase;
cookie: {
name: string;
@@ -58,6 +73,7 @@ export interface AuthServiceOptions {
}
export interface AuthService {
registerRequestClientAddress(request: Request, address: string | undefined): void;
require(request: Request): Promise<Principal>;
can(principal: Principal, capabilities: Capabilities): boolean;
canManageNode(principal: Principal, node: Machine): boolean;
@@ -73,24 +89,168 @@ export interface AuthService {
findOrCreateUser(
subject: string,
profile?: { name?: string; email?: string; picture?: string },
options?: { initialRole?: string; syncRole?: string },
): Promise<string>;
linkHeadscaleUser(userId: string, headscaleUserId: string): Promise<boolean>;
unlinkHeadscaleUser(userId: string): Promise<void>;
linkHeadscaleUserBySubject(subject: string, headscaleUserId: string): Promise<boolean>;
listUsers(): Promise<HeadplaneUser[]>;
claimedHeadscaleUserIds(): Promise<Set<string>>;
roleForSubject(subject: string): Promise<Role | undefined>;
roleForHeadscaleUser(headscaleUserId: string): Promise<Role | undefined>;
transferOwnership(currentOwnerSubject: string, newOwnerSubject: string): Promise<boolean>;
reassignSubject(subject: string, role: Role): Promise<boolean>;
transferOwnership(currentOwnerUserId: string, newOwnerUserId: string): Promise<boolean>;
reassignUser(userId: string, role: Role): Promise<boolean>;
pruneExpiredSessions(): Promise<void>;
start(): void;
stop(): void;
}
export function isUserPrincipal(principal: Principal): principal is UserPrincipal {
return principal.kind === "oidc" || principal.kind === "proxy";
}
interface CidrRange {
family: 4 | 6;
base: bigint;
mask: bigint;
}
const DEFAULT_PROXY_AUTH_CIDRS = ["127.0.0.1/32", "::1/128"];
const DEFAULT_PROXY_AUTH_USER_HEADER = "Remote-User";
function normalizeIpAddress(address: string): string {
if (address.startsWith("::ffff:")) {
const mapped = address.slice("::ffff:".length);
if (isIP(mapped) === 4) {
return mapped;
}
}
return address;
}
function parseIpv4(address: string): bigint | undefined {
const parts = address.split(".");
if (parts.length !== 4) {
return;
}
let value = 0n;
for (const part of parts) {
if (!/^\d+$/.test(part)) {
return;
}
const byte = Number(part);
if (byte < 0 || byte > 255) {
return;
}
value = (value << 8n) + BigInt(byte);
}
return value;
}
function parseIpv6(address: string): bigint | undefined {
const sections = address.split("::");
if (sections.length > 2) {
return;
}
const head = sections[0] ? sections[0].split(":") : [];
const tail = sections.length === 2 && sections[1] ? sections[1].split(":") : [];
const missing = 8 - head.length - tail.length;
if (missing < 0 || (sections.length === 1 && missing !== 0)) {
return;
}
const groups = [...head, ...Array<string>(missing).fill("0"), ...tail];
if (groups.length !== 8) {
return;
}
let value = 0n;
for (const group of groups) {
if (!/^[0-9a-fA-F]{1,4}$/.test(group)) {
return;
}
value = (value << 16n) + BigInt(parseInt(group, 16));
}
return value;
}
function parseIpAddress(address: string): { family: 4 | 6; value: bigint } | undefined {
const normalized = normalizeIpAddress(address);
const family = isIP(normalized);
if (family === 4) {
const value = parseIpv4(normalized);
return value === undefined ? undefined : { family, value };
}
if (family === 6) {
const value = parseIpv6(normalized);
return value === undefined ? undefined : { family, value };
}
return;
}
function parseCidr(cidr: string): CidrRange {
const parts = cidr.trim().split("/");
if (parts.length > 2) {
throw new Error(`Invalid proxy auth CIDR: ${cidr}`);
}
const [rawAddress, rawPrefix] = parts;
const address = parseIpAddress(rawAddress);
if (!address) {
throw new Error(`Invalid proxy auth CIDR address: ${cidr}`);
}
const maxBits = address.family === 4 ? 32 : 128;
const prefix = rawPrefix === undefined ? maxBits : Number(rawPrefix);
if (!Number.isInteger(prefix) || prefix < 0 || prefix > maxBits) {
throw new Error(`Invalid proxy auth CIDR prefix: ${cidr}`);
}
const bits = BigInt(maxBits);
const hostBits = BigInt(maxBits - prefix);
const allOnes = (1n << bits) - 1n;
const mask = prefix === 0 ? 0n : (allOnes << hostBits) & allOnes;
return {
family: address.family,
base: address.value & mask,
mask,
};
}
function cidrContains(range: CidrRange, address: string): boolean {
const parsed = parseIpAddress(address);
if (!parsed || parsed.family !== range.family) {
return false;
}
return (parsed.value & range.mask) === range.base;
}
export function createAuthService(opts: AuthServiceOptions): AuthService {
const requestCache = new WeakMap<Request, Promise<Principal>>();
const clientAddresses = new WeakMap<Request, string>();
const proxyAuthCidrs = opts.proxyAuth?.enabled
? (opts.proxyAuth.allowedCidrs?.length
? opts.proxyAuth.allowedCidrs
: DEFAULT_PROXY_AUTH_CIDRS
).map(parseCidr)
: [];
const trustedProxyCidrs = opts.proxyAuth?.enabled
? (opts.proxyAuth.trustedProxyCidrs?.length
? opts.proxyAuth.trustedProxyCidrs
: DEFAULT_PROXY_AUTH_CIDRS
).map(parseCidr)
: [];
let pruneTimer: ReturnType<typeof setInterval> | undefined;
async function encodeCookie(payload: CookiePayload, maxAge: number): Promise<string> {
@@ -141,7 +301,139 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
return createHash("sha256").update(key).digest("hex");
}
function registerRequestClientAddress(request: Request, address: string | undefined): void {
if (address) {
clientAddresses.set(request, address);
}
}
function getForwardedClientAddress(request: Request): string | undefined {
const headerName = opts.proxyAuth?.ipHeader;
if (!headerName) {
return;
}
const value = request.headers.get(headerName)?.trim();
if (!value) {
return;
}
const first = value.split(",")[0]?.trim();
if (!first) {
return;
}
return parseIpAddress(first) ? first : undefined;
}
function getProxyAuthClientAddress(request: Request): string | undefined {
const directAddress = clientAddresses.get(request);
if (!directAddress) {
return;
}
if (!opts.proxyAuth?.ipHeader) {
return directAddress;
}
const directPeerTrusted = trustedProxyCidrs.some((cidr) => cidrContains(cidr, directAddress));
if (!directPeerTrusted) {
return;
}
return getForwardedClientAddress(request);
}
async function resolveUserPrincipal(options: {
kind: UserPrincipal["kind"];
sessionId: string;
userId: string;
idToken?: string;
profile?: {
name?: string;
email?: string;
username?: string;
};
}): Promise<UserPrincipal> {
const [user] = await opts.db.select().from(users).where(eq(users.id, options.userId)).limit(1);
if (!user) {
throw new Error("User record not found");
}
const role = (user.role in Roles ? user.role : "member") as Role;
return {
kind: options.kind,
sessionId: options.sessionId,
idToken: options.idToken,
user: {
id: user.id,
subject: user.sub,
role,
headscaleUserId: user.headscale_user_id ?? undefined,
},
profile: {
name: options.profile?.name ?? user.name ?? user.sub,
email: options.profile?.email ?? user.email ?? undefined,
username: options.profile?.username,
picture: user.picture ?? undefined,
},
};
}
async function resolveProxyAuthPrincipal(request: Request): Promise<Principal | undefined> {
if (!opts.proxyAuth?.enabled) {
return;
}
if (!opts.headscaleApiKey) {
throw new Error("Proxy authentication requires headscale.api_key to be configured");
}
const clientAddress = getProxyAuthClientAddress(request);
if (!clientAddress || !proxyAuthCidrs.some((cidr) => cidrContains(cidr, clientAddress))) {
return;
}
const userHeader = opts.proxyAuth.userHeader ?? DEFAULT_PROXY_AUTH_USER_HEADER;
const proxyUser = request.headers.get(userHeader)?.trim();
if (!proxyUser) {
return;
}
const email = opts.proxyAuth.emailHeader
? request.headers.get(opts.proxyAuth.emailHeader)?.trim()
: undefined;
const name = opts.proxyAuth.nameHeader
? request.headers.get(opts.proxyAuth.nameHeader)?.trim()
: undefined;
const picture = opts.proxyAuth.pictureHeader
? request.headers.get(opts.proxyAuth.pictureHeader)?.trim()
: undefined;
const subject = `proxy:${proxyUser}`;
const userId = await findOrCreateUser(subject, {
name: name || proxyUser,
email: email || undefined,
picture: picture || undefined,
});
return resolveUserPrincipal({
kind: "proxy",
sessionId: "proxy-auth",
userId,
profile: {
name: name || proxyUser,
email: email || undefined,
username: proxyUser,
},
});
}
async function resolve(request: Request): Promise<Principal> {
const proxyPrincipal = await resolveProxyAuthPrincipal(request);
if (proxyPrincipal) {
return proxyPrincipal;
}
const payload = await decodeCookie(request);
const [session] = await opts.db
@@ -176,30 +468,17 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
throw new Error("OIDC session missing user_id");
}
const [user] = await opts.db.select().from(users).where(eq(users.id, session.user_id)).limit(1);
if (!user) {
throw new Error("User record not found");
}
const role = (user.role in Roles ? user.role : "member") as Role;
return {
return resolveUserPrincipal({
kind: "oidc",
sessionId: session.id,
idToken: session.oidc_id_token ?? undefined,
user: {
id: user.id,
subject: user.sub,
role,
headscaleUserId: user.headscale_user_id ?? undefined,
},
userId: session.user_id,
profile: {
name: payload.profile?.name ?? user.name ?? user.sub,
email: payload.profile?.email ?? user.email ?? undefined,
name: payload.profile?.name,
email: payload.profile?.email,
username: payload.profile?.username,
picture: user.picture ?? undefined,
},
};
});
}
function require(request: Request): Promise<Principal> {
@@ -242,7 +521,7 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
}
if (!opts.headscaleApiKey) {
throw new Error("OIDC sessions require headscale.api_key to be configured");
throw new Error("User sessions require headscale.api_key to be configured");
}
return opts.headscaleApiKey;
@@ -304,16 +583,21 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
async function findOrCreateUser(
subject: string,
profile?: { name?: string; email?: string; picture?: string },
options?: { initialRole?: string; syncRole?: string },
): Promise<string> {
const [existing] = await opts.db.select().from(users).where(eq(users.sub, subject)).limit(1);
if (existing) {
const syncedRole = normalizeInitialRole(options?.syncRole);
await opts.db
.update(users)
.set({
name: profile?.name,
email: profile?.email,
picture: profile?.picture,
...(syncedRole && existing.role !== "owner"
? { role: syncedRole, caps: capsForRole(syncedRole) }
: {}),
last_login_at: new Date(),
updated_at: new Date(),
})
@@ -321,6 +605,7 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
return existing.id;
}
const initialRole = normalizeInitialRole(options?.initialRole) ?? "member";
const id = ulid();
await opts.db.insert(users).values({
id,
@@ -328,8 +613,8 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
name: profile?.name,
email: profile?.email,
picture: profile?.picture,
role: "member",
caps: capsForRole("member"),
role: initialRole,
caps: capsForRole(initialRole),
});
const [{ count }] = await opts.db.select({ count: sql<number>`count(*)` }).from(users);
@@ -344,6 +629,14 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
return id;
}
function normalizeInitialRole(role: string | undefined): Exclude<Role, "owner"> | undefined {
if (role && role !== "owner" && role in Roles) {
return role as Exclude<Role, "owner">;
}
return undefined;
}
async function linkHeadscaleUser(userId: string, headscaleUserId: string): Promise<boolean> {
const [existing] = await opts.db
.select({ id: users.id })
@@ -370,23 +663,6 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
.where(eq(users.id, userId));
}
async function linkHeadscaleUserBySubject(
subject: string,
headscaleUserId: string,
): Promise<boolean> {
const [user] = await opts.db
.select({ id: users.id })
.from(users)
.where(eq(users.sub, subject))
.limit(1);
if (!user) {
return false;
}
return linkHeadscaleUser(user.id, headscaleUserId);
}
async function listUsers(): Promise<HeadplaneUser[]> {
return opts.db.select().from(users);
}
@@ -428,13 +704,17 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
}
async function transferOwnership(
currentOwnerSubject: string,
newOwnerSubject: string,
currentOwnerUserId: string,
newOwnerUserId: string,
): Promise<boolean> {
if (currentOwnerUserId === newOwnerUserId) {
return false;
}
const [current] = await opts.db
.select()
.from(users)
.where(eq(users.sub, currentOwnerSubject))
.where(eq(users.id, currentOwnerUserId))
.limit(1);
if (!current || current.role !== "owner") {
@@ -444,10 +724,10 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
const [target] = await opts.db
.select()
.from(users)
.where(eq(users.sub, newOwnerSubject))
.where(eq(users.id, newOwnerUserId))
.limit(1);
if (!target || target.id === current.id) {
if (!target) {
return false;
}
@@ -464,24 +744,16 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
return true;
}
async function reassignSubject(subject: string, role: Role): Promise<boolean> {
const currentRole = await roleForSubject(subject);
if (currentRole === "owner") {
async function reassignUser(userId: string, role: Role): Promise<boolean> {
const [user] = await opts.db.select().from(users).where(eq(users.id, userId)).limit(1);
if (!user || user.role === "owner") {
return false;
}
await opts.db
.insert(users)
.values({
id: ulid(),
sub: subject,
role,
caps: capsForRole(role),
})
.onConflictDoUpdate({
target: users.sub,
set: { role, caps: capsForRole(role), updated_at: new Date() },
});
.update(users)
.set({ role, caps: capsForRole(role), updated_at: new Date() })
.where(eq(users.id, userId));
return true;
}
@@ -502,6 +774,7 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
}
return {
registerRequestClientAddress,
require: require,
can,
canManageNode,
@@ -512,13 +785,12 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
findOrCreateUser,
linkHeadscaleUser,
unlinkHeadscaleUser,
linkHeadscaleUserBySubject,
listUsers,
claimedHeadscaleUserIds,
roleForSubject,
roleForHeadscaleUser,
transferOwnership,
reassignSubject,
reassignUser,
pruneExpiredSessions,
start,
stop,
+34 -20
View File
@@ -1,44 +1,58 @@
// MARK: Side-Effects
// This module contains a side-effect because everything running here
// is static and logger is later modified in `app/server/index.ts` to
// disable debug logging if the `HEADPLANE_DEBUG_LOG` specifies as such.
// This module contains a side-effect because log levels are read from
// the environment once at module initialization.
import pino from "pino";
const levels = ["info", "warn", "error", "debug"] as const;
type Category = "server" | "config" | "agent" | "api" | "auth" | "sse";
type Level = (typeof levels)[number];
export interface Logger extends Record<
(typeof levels)[number],
Level,
(category: Category, message: string, ...args: unknown[]) => void
> {
debugEnabled: boolean;
}
const logLevels = getLogLevels();
const rootLogger = createRootLogger();
export default {
debugEnabled: logLevels.includes("debug"),
debug: (..._: Parameters<Logger["debug"]>) => {},
...Object.fromEntries(
logLevels.map((level) => [
level,
(category: Category, message: string, ...args: unknown[]) => {
const date = new Date().toISOString();
console.log(`${date} [${category}] ${level.toUpperCase()}: ${message}`, ...args);
},
]),
),
debugEnabled: rootLogger.isLevelEnabled("debug"),
info: (category, msg, ...args) => rootLogger.info({ component: category }, msg, ...args),
warn: (category, msg, ...args) => rootLogger.warn({ component: category }, msg, ...args),
error: (category, msg, ...args) => rootLogger.error({ component: category }, msg, ...args),
debug: (category, msg, ...args) => rootLogger.debug({ component: category }, msg, ...args),
} as Logger;
function getLogLevels() {
function createRootLogger() {
const options = {
level: getLogLevel(),
timestamp: () => `,"timestamp":"${new Date().toISOString()}"`,
formatters: {
level: (label: string) => ({ level: label }),
},
} satisfies pino.LoggerOptions;
if (process.env.NODE_ENV === "test") {
return pino(options);
}
const destination = pino.destination({ dest: 1, sync: false });
process.on("exit", () => destination.flushSync());
return pino(options, destination);
}
function getLogLevel(): Level {
const debugLog = process.env.HEADPLANE_DEBUG_LOG;
if (debugLog == null) {
return ["info", "warn", "error"];
return "info";
}
const normalized = debugLog.trim().toLowerCase();
const truthyValues = ["1", "true", "yes", "on"];
if (!truthyValues.includes(normalized)) {
return ["info", "warn", "error"];
return "info";
}
return ["info", "warn", "error", "debug"];
return "debug";
}
+91
View File
@@ -48,3 +48,94 @@ export function mapNodes(
export function sortNodeTags(nodes: Machine[]): string[] {
return Array.from(new Set(nodes.flatMap((node) => node.tags))).sort();
}
export function sortAssignableTags(nodes: Machine[], policy?: string): string[] {
return Array.from(new Set([...sortNodeTags(nodes), ...extractTagOwnerTags(policy)])).sort();
}
export function extractTagOwnerTags(policy: string | undefined): string[] {
if (!policy) {
return [];
}
try {
const parsed = JSON.parse(stripJsonCommentsAndTrailingCommas(policy)) as unknown;
if (parsed == null || typeof parsed !== "object" || !("tagOwners" in parsed)) {
return [];
}
const tagOwners = (parsed as { tagOwners?: unknown }).tagOwners;
if (tagOwners == null || typeof tagOwners !== "object" || Array.isArray(tagOwners)) {
return [];
}
return Object.keys(tagOwners)
.filter((tag) => tag.startsWith("tag:"))
.sort();
} catch {
return [];
}
}
function stripJsonCommentsAndTrailingCommas(input: string): string {
let output = "";
let inString = false;
let escaped = false;
let inLineComment = false;
let inBlockComment = false;
for (let i = 0; i < input.length; i++) {
const char = input[i];
const next = input[i + 1];
if (inLineComment) {
if (char === "\n" || char === "\r") {
inLineComment = false;
output += char;
}
continue;
}
if (inBlockComment) {
if (char === "*" && next === "/") {
inBlockComment = false;
i++;
}
continue;
}
if (inString) {
output += char;
if (escaped) {
escaped = false;
} else if (char === "\\") {
escaped = true;
} else if (char === '"') {
inString = false;
}
continue;
}
if (char === '"') {
inString = true;
output += char;
continue;
}
if (char === "/" && next === "/") {
inLineComment = true;
i++;
continue;
}
if (char === "/" && next === "*") {
inBlockComment = true;
i++;
continue;
}
output += char;
}
return output.replace(/,\s*([}\]])/g, "$1");
}
+46
View File
@@ -0,0 +1,46 @@
const authRequestKeyPattern = /^hskey-authreq-[A-Za-z0-9_-]{16,}$/;
const authRequestKeySearchPattern = /hskey-authreq-[A-Za-z0-9_-]{16,}/;
const legacyMachineKeyPattern = /^mkey:[A-Za-z0-9_-]{16,}$/;
const registerUrlPattern = /\/register\/([^\s?#]+)/;
const registrationKeySuffixPattern = /^[A-Za-z0-9_-]{24,}$/;
export function normalizeRegistrationKey(value: string): string | null {
const trimmed = value.trim();
if (trimmed.length === 0) {
return null;
}
const registerUrlMatch = trimmed.match(registerUrlPattern);
if (registerUrlMatch) {
const token = normalizeRegistrationToken(registerUrlMatch[1]);
if (token) {
return token;
}
}
const authRequestKeyMatch = trimmed.match(authRequestKeySearchPattern);
if (authRequestKeyMatch) {
return authRequestKeyMatch[0];
}
return normalizeRegistrationToken(trimmed);
}
function normalizeRegistrationToken(value: string): string | null {
let token: string;
try {
token = decodeURIComponent(value.trim());
} catch {
return null;
}
if (authRequestKeyPattern.test(token) || legacyMachineKeyPattern.test(token)) {
return token;
}
if (registrationKeySuffixPattern.test(token)) {
return `hskey-authreq-${token}`;
}
return null;
}
+2 -2
View File
@@ -138,8 +138,8 @@ build_wasm() {
"$(dirname "$WASM_OUTPUT")/wasm_exec.js"
# Vendor dependencies and apply the DERP port patch.
# Tailscale's derphttp WebSocket URL builder ignores DERPPort,
# which breaks WASM connections to non-443 DERP servers.
# Tailscale's browser WebSocket and netcheck URL builders ignore
# DERPPort, which breaks WASM connections to non-443 DERP servers.
echo "==> Vendoring Go dependencies for WASM patch"
go mod vendor
+11
View File
@@ -30,6 +30,17 @@ func main() {
}
log.SetDebug(cfg.Debug)
if cfg.TSAuthKey == "" {
log.Info("No TS_AUTHKEY provided; tsnet will use existing state or prompt for interactive login")
} else {
prefix := cfg.TSAuthKey
if len(prefix) > 16 {
prefix = prefix[:16]
}
log.Info("TS_AUTHKEY provided (prefix: %s); connecting with pre-auth key", prefix)
}
agent := tsnet.NewAgent(cfg)
defer agent.Shutdown()
+41 -2
View File
@@ -1,18 +1,46 @@
package main
import (
"crypto/tls"
"fmt"
"net/http"
"os"
"strings"
"time"
)
// hp_healthcheck pings the Headplane healthz endpoint. It's invoked
// from the Docker HEALTHCHECK directive inside the same container
// as Headplane itself.
//
// To stay fully zero-config, Headplane writes the exact URL the
// healthcheck should hit — scheme, port, and basename included — to
// listenFile when it starts accepting connections (see
// runtime/http.ts and app/server/main.ts). This binary just reads
// that file and GETs the URL verbatim. No env vars, no YAML
// parsing, no path-joining, no compile-time knowledge of the
// basename.
//
// If the file is missing (e.g. the server hasn't finished booting
// on the very first probe, or this is an old image being run with
// a new healthcheck) we fall back to the historical default.
const (
listenFile = "/tmp/headplane-listen"
defaultURL = "http://localhost:3000/admin/healthz"
)
func main() {
url := readListenFile()
client := http.Client{
Timeout: 5 * time.Second,
Transport: &http.Transport{
// Self-signed certs are normal for in-process TLS termination
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
resp, err := client.Get("http://localhost:3000/admin/healthz")
resp, err := client.Get(url)
if err != nil {
fmt.Fprintf(os.Stderr, "Health check failed: %v\n", err)
os.Exit(1)
@@ -25,5 +53,16 @@ func main() {
}
fmt.Println("Health check passed.")
os.Exit(0)
}
func readListenFile() string {
data, err := os.ReadFile(listenFile)
if err != nil {
return defaultURL
}
url := strings.TrimSpace(string(data))
if url == "" {
return defaultURL
}
return url
}
+1 -1
View File
@@ -24,7 +24,7 @@ services:
- "./test/caddy/config:/config"
- "./test/caddy/certs:/certs"
headscale:
image: "headscale/headscale:0.28.0"
image: "headscale/headscale:0.29.0"
container_name: "headscale"
labels:
me.tale.headplane.target: headscale
+66 -14
View File
@@ -17,8 +17,19 @@ server:
# Whether cookies should be marked as Secure
# * Should be false if running without HTTPs
# * Should be true if running behind a reverse proxy with HTTPs
# When `tls_cert_path`/`tls_key_path` are set below this is forced
# to true automatically.
cookie_secure: true
# Optional TLS termination. When both `tls_cert_path` and
# `tls_key_path` are set, Headplane serves HTTPS on `server.port`.
# Both files must be PEM encoded.
#
# If you are terminating TLS at a reverse proxy (recommended for most
# deployments) leave these unset.
# tls_cert_path: "/var/lib/headplane/tls/fullchain.pem"
# tls_key_path: "/var/lib/headplane/tls/privkey.pem"
# The maximum age of the session cookie in seconds
cookie_max_age: 86400 # 1 day in seconds
@@ -27,6 +38,36 @@ server:
# This may not work as expected if not using a reverse proxy.
# cookie_domain: ""
# Optional proxy authentication mode. When enabled, Headplane trusts identity
# headers from requests that come directly from one of `allowed_cidrs` and
# uses `headscale.api_key` for Headscale API access.
#
# Only enable this when a trusted reverse proxy in front of Headplane already
# performs authentication (for example, nginx basic auth, Authelia, Authentik,
# etc.). The source IP check uses the direct client address connected to
# Headplane by default, not X-Forwarded-For headers.
#
# If `allowed_cidrs` is omitted, only localhost is trusted.
# proxy_auth:
# enabled: false
#
# # Optional. If set, Headplane checks allowed_cidrs against the first IP in
# # this header (for example, "X-Forwarded-For" or "X-Real-IP") instead of
# # the direct client address. The direct client must still match
# # trusted_proxy_cidrs before this header is used.
# ip_header: "X-Forwarded-For"
# trusted_proxy_cidrs:
# - "127.0.0.1/32"
# - "::1/128"
#
# user_header: "Remote-User"
# email_header: "Remote-Email"
# name_header: "Remote-Name"
# picture_header: "Remote-Picture"
# allowed_cidrs:
# - "127.0.0.1/32"
# - "::1/128"
# The path to persist Headplane specific data. All data going forward
# is stored in this directory, including the internal database and
# any cache related files.
@@ -58,6 +99,11 @@ headscale:
# If you use the TLS configuration in Headscale, and you are not using
# Let's Encrypt for your certificate, pass in the path to the certificate.
# (This has no effect if `url` does not start with `https://`)
#
# Note: this pins Headscale's connection to exactly this one cert. If you
# have a private CA you want trusted everywhere (Headscale, OIDC, Docker,
# etc.), set `NODE_EXTRA_CA_CERTS=/path/to/ca.pem` on the Headplane
# process instead — see https://headplane.net/configuration/tls#custom-certificate-authorities
# tls_cert_path: "/var/lib/headplane/tls.crt"
# Optional, public URL if its different from the `headscale.url`
@@ -71,25 +117,17 @@ headscale:
config_path: "/etc/headscale/config.yaml"
# The API key used by Headplane for server-side operations.
# This is required for OIDC authentication and the Headplane agent.
# This is required for OIDC authentication, proxy authentication, and the
# Headplane agent.
# Generate one with: headscale apikeys create
# api_key: "<your-api-key>"
# Whether the Headscale configuration should be strictly validated
# when reading from `config_path`. If true, Headplane will not interact
# with Headscale if there are any issues with the configuration file.
#
# This is recommended to be true for production deployments to, however it
# may not work if you are using a version of Headscale that has configuration
# options unknown to Headplane.
config_strict: true
# If you are using `dns.extra_records_path` in your Headscale
# configuration, you need to set this to the path for Headplane
# to be able to read the DNS records.
# configuration, Headplane will read that path automatically. Set
# this only when Headplane needs to access the same file at a
# different path, such as in Docker bind mounts.
#
# Pass it in if using Docker and ensure that the file is both
# readable and writable to the Headplane process.
# Ensure that the file is both readable and writable to the Headplane process.
# When using this, Headplane will no longer need to automatically
# restart Headscale for DNS record changes.
# dns_records_path: "/var/lib/headscale/extra_records.json"
@@ -243,6 +281,20 @@ integration:
# - "open_id"
# - "email"
#
# # Role assigned to new OIDC users after the first owner is bootstrapped.
# # This is useful with Headscale's OIDC allowed_domains / allowed_groups
# # restrictions when every permitted SSO user should receive the same
# # Headplane permissions. Valid values: admin, network_admin, it_admin,
# # auditor, viewer, member. The owner role is only granted to the first user.
# default_role: "member"
#
# # Optional OIDC claim to read a Headplane role from when creating or syncing a user.
# # This can be a string claim, or an array claim containing one of the valid
# # roles above. If present and valid, it takes precedence over default_role.
# # Configure your IdP to map groups or client roles into this claim. Existing
# # non-owner users are updated on their next OIDC sign-in when this claim changes.
# role_claim: "headplane_role"
#
# # Allow ID token verification with legacy RSA keys smaller than 2048 bits.
# # This is disabled by default because it lowers token verification security and
# # should only be used as a temporary compatibility workaround.
+6 -1
View File
@@ -38,6 +38,7 @@ export default defineConfig({
link: "/configuration",
items: [
{ text: "Common Issues", link: "/configuration/common-issues" },
{ text: "TLS & Certificates", link: "/configuration/tls" },
{
text: "Sensitive Values",
link: "/configuration#sensitive-values",
@@ -49,7 +50,11 @@ export default defineConfig({
{
text: "Features",
items: [
{ text: "Single Sign-On (SSO)", link: "/features/sso" },
{
text: "Single Sign-On (SSO)",
link: "/features/sso",
items: [{ text: "Proxy Authentication", link: "/features/proxy-auth" }],
},
{ text: "Headplane Agent", link: "/features/agent" },
{ text: "Browser SSH", link: "/features/ssh" },
],
+1 -9
View File
@@ -85,15 +85,7 @@ The full (generated by `mise run nixos-docs`) list of `services.headplane.settin
{config, pkgs, ...}:
let
format = pkgs.formats.yaml {};
# A workaround generate a valid Headscale config accepted by Headplane when `config_strict == true`.
settings = lib.recursiveUpdate config.services.headscale.settings {
tls_cert_path = "/dev/null";
tls_key_path = "/dev/null";
policy.path = "/dev/null";
};
headscaleConfig = format.generate "headscale.yml" settings;
headscaleConfig = format.generate "headscale.yml" config.services.headscale.settings;
in {
services.headplane = {
enable = true;
+101 -4
View File
@@ -73,9 +73,7 @@ _Example:_ `"/etc/headscale/config.yaml"`
## settings.headscale.config_strict
_Description:_ Headplane internally validates the Headscale configuration
to ensure that it changes the configuration in a safe way.
If you want to disable this validation, set this to false.
_Description:_ Deprecated. Headplane no longer validates the complete Headscale configuration and this option has no effect.
_Type:_ boolean
@@ -83,7 +81,7 @@ _Default:_ `true`
## settings.headscale.dns_records_path
_Description:_ If you are using `dns.extra_records_path` in your Headscale configuration, you need to set this to the path for Headplane to be able to read the DNS records.
_Description:_ If you are using `dns.extra_records_path` in your Headscale configuration, Headplane reads that path automatically. Set this only when Headplane needs to access the same file at a different path.
Ensure that the file is both readable and writable by the Headplane process.
When using this, Headplane will no longer need to automatically restart Headscale for DNS record changes.
@@ -252,6 +250,15 @@ _Type:_ boolean
_Default:_ `false`
## settings.oidc.default_role
_Description:_ Role assigned to newly created OIDC users after the first owner is bootstrapped.
The owner role is reserved for the first-login bootstrap.
_Type:_ one of "admin", "network_admin", "it_admin", "auditor", "viewer", "member"
_Default:_ `"member"`
## settings.oidc.headscale_api_key_path
_Description:_ DEPRECATED: Use `headscale.api_key_path` instead.
@@ -284,6 +291,17 @@ _Default:_ `""`
_Example:_ `"https://headscale.example.com/admin/oidc/callback"`
## settings.oidc.role_claim
_Description:_ Optional OIDC claim containing the Headplane role to assign to newly created users.
A valid role claim takes precedence over default_role.
_Type:_ null or string
_Default:_ `null`
_Example:_ `"headplane_role"`
## settings.oidc.token_endpoint_auth_method
_Description:_ The token endpoint authentication method.
@@ -350,3 +368,82 @@ _Description:_ The port to listen on.
_Type:_ 16 bit unsigned integer; between 0 and 65535 (both inclusive)
_Default:_ `3000`
## settings.server.proxy_auth
_Description:_ Proxy authentication configuration.
_Type:_ submodule
_Default:_ `{ }`
## settings.server.proxy_auth.allowed_cidrs
_Description:_ Direct client CIDR ranges allowed to bypass Headplane's login flow.
These should be the addresses your trusted reverse proxy uses to connect
to Headplane. Requires headscale.api_key_path.
_Type:_ list of string
_Default:_ `[ "127.0.0.1/32" "::1/128" ]`
_Example:_ `[ "10.0.0.0/24" ]`
## settings.server.proxy_auth.email_header
_Description:_ Optional header containing the authenticated user's email address.
_Type:_ null or string
_Default:_ `null`
## settings.server.proxy_auth.enabled
_Description:_ Whether to trust reverse proxy authentication for allowed client CIDRs.
_Type:_ boolean
_Default:_ `false`
## settings.server.proxy_auth.ip_header
_Description:_ Optional header containing the original client IP, such as X-Forwarded-For or X-Real-IP.
_Type:_ null or string
_Default:_ `null`
## settings.server.proxy_auth.name_header
_Description:_ Optional header containing the authenticated user's display name.
_Type:_ null or string
_Default:_ `null`
## settings.server.proxy_auth.picture_header
_Description:_ Optional header containing the authenticated user's profile picture URL.
_Type:_ null or string
_Default:_ `null`
## settings.server.proxy_auth.user_header
_Description:_ Header containing the stable authenticated proxy user identity.
_Type:_ string
_Default:_ `"Remote-User"`
## settings.server.proxy_auth.trusted_proxy_cidrs
_Description:_ Direct proxy CIDR ranges trusted to supply ip_header.
Only used when ip_header is set.
_Type:_ list of string
_Default:_ `[ "127.0.0.1/32" "::1/128" ]`
_Example:_ `[ "127.0.0.1/32" ]`
+13 -2
View File
@@ -58,8 +58,8 @@ The following configuration options in Headplane are treated as secret paths:
- _Note:_ Either `cookie_secret` or `cookie_secret_path` must be provided for web session security.
- **Headscale Connection Settings (`headscale.*`):**
- `api_key_path` (Headscale API key for server-side operations like OIDC and agent sync)
- _Note:_ Either `api_key` or `api_key_path` must be provided when using OIDC or the agent.
- `api_key_path` (Headscale API key for server-side operations like OIDC, proxy authentication, and agent sync)
- _Note:_ Either `api_key` or `api_key_path` must be provided when using OIDC, proxy authentication, or the agent.
- `tls_cert_path` (custom TLS certificate for connecting to Headscale)
- _Note:_ This is treated as a regular path, not a secret path, so it will not have its content loaded.
@@ -82,10 +82,21 @@ The path-based secret loading mechanism also works with environment variables. F
## Debugging
Headplane writes server logs as newline-delimited JSON using Pino. Each entry
includes a timestamp, level, component, and message (`msg`) so log aggregation
tools can filter and index it directly.
To enable debug logging, set the **`HEADPLANE_DEBUG_LOG=true`** environment variable.
This will enable all debug logs for Headplane, which could fill up log space very quickly.
This is not recommended in production environments.
## TLS & Certificates
Anything to do with TLS — terminating HTTPS in-process, trusting a private
CA for outbound connections to Headscale or your OIDC provider, the
interaction with `cookie_secure` and the Docker healthcheck — lives on its
own page: [TLS & Certificates](./tls).
## Reverse Proxying
Reverse proxying is very common when deploying web applications. Headscale and
+80
View File
@@ -0,0 +1,80 @@
# TLS & Certificates
Everything Headplane does with TLS lives on this page: terminating HTTPS
in-process, trusting private certificate authorities for outbound
connections, and the interactions with cookies and the bundled Docker
healthcheck.
## Custom Certificate Authorities
If you front any of the services Headplane talks to — your Headscale server,
your OIDC provider, an HTTPS Docker daemon, etc. — with a private or
self-signed certificate authority, Headplane needs to trust that CA. The
cleanest way to do that is the standard Node.js `NODE_EXTRA_CA_CERTS`
environment variable, which is honoured for **every** outbound TLS
connection in the process (OIDC discovery, Headscale API, Docker, anything
else that uses `fetch` or `https`).
Provide it as a path to a PEM-encoded bundle of one or more CA certificates:
```bash
NODE_EXTRA_CA_CERTS=/etc/headplane/extra-cas.pem
```
In Docker, bind-mount the bundle and pass the variable through:
```yaml
services:
headplane:
image: ghcr.io/tale/headplane:latest
environment:
NODE_EXTRA_CA_CERTS: /etc/headplane/extra-cas.pem
volumes:
- "./internal-ca.pem:/etc/headplane/extra-cas.pem:ro"
- "./config.yaml:/etc/headplane/config.yaml"
- "./headplane-data:/var/lib/headplane"
```
The bundle is added **on top of** the system trust store, so public
certificates (Let's Encrypt, ZeroSSL, etc.) keep working.
> `headscale.tls_cert_path` is a narrower knob that pins Headscale's API
> connection to exactly one certificate, bypassing the rest of the trust
> store. It still has its place if you want Headplane to refuse anything
> other than that specific cert for Headscale, but `NODE_EXTRA_CA_CERTS` is
> the right tool whenever you simply want to add a CA to the trusted set.
## TLS Termination
Headplane can terminate TLS itself when both `server.tls_cert_path` and
`server.tls_key_path` are set in the configuration file. Both must point to
PEM-encoded files that Headplane can read.
```yaml
server:
port: 443
tls_cert_path: "/var/lib/headplane/tls/fullchain.pem"
tls_key_path: "/var/lib/headplane/tls/privkey.pem"
```
When TLS is configured Headplane serves HTTPS/1.1 on `server.port`. HTTP/2
and HTTP/3 are intentionally not supported in-process — terminate those at a
reverse proxy (e.g. Caddy or Traefik) and forward to Headplane over HTTP/1.1
if you need them today.
`server.cookie_secure` is forced to `true` whenever TLS is enabled (browsers
refuse `Secure`-less cookies over HTTPS); a warning is logged if your config
had it set to `false`.
For most deployments we still recommend terminating TLS at a reverse proxy
(see [Reverse Proxying](./#reverse-proxying)) so you can share certificates
with Headscale and other services. Built-in TLS is meant for the simpler
"Headplane on a single box" scenarios.
## Healthcheck
The bundled Docker healthcheck picks up the right scheme and port
automatically — Headplane writes its loopback URL to `/tmp/headplane-listen`
when it starts, and the healthcheck reads it from there. So flipping TLS on
or off requires no healthcheck-specific configuration; everything just
works.
+2 -2
View File
@@ -171,7 +171,7 @@ export interface AppRuntime {
db: DbClient;
auth: AuthService;
oidc?: OidcService;
hsApi: HeadscaleInterface;
headscale: Headscale;
agents?: AgentManager;
stop(): Promise<void>;
}
@@ -300,7 +300,7 @@ function createTestRuntime(overrides: Partial<AppRuntime> = {}): AppRuntime {
config: testConfig,
db: createTestDb(),
auth: createTestAuth(),
hsApi: createTestHsApi(),
headscale: createTestHeadscale(),
stop: async () => {},
...overrides,
};
+20 -4
View File
@@ -7,8 +7,9 @@ description: Configure the Headplane Agent for enhanced functionality.
The Headplane Agent is an optional component that periodically syncs node
information (such as version and OS details) from the Tailnet. Unlike previous
versions, the agent no longer runs as a persistent Tailnet node — it
auto-generates pre-auth keys and performs periodic syncs instead.
versions, the agent does not require you to manually create or manage pre-auth
keys — Headplane generates a fresh key for each agent startup and reuses the
agent's existing Tailnet state across restarts.
## Prerequisites
@@ -18,8 +19,9 @@ Before enabling the agent, ensure the following:
keys which are only available in Headscale 0.28+.
2. **`headscale.api_key`** must be set in your Headplane configuration file.
The agent uses this key to auto-generate ephemeral pre-auth keys for
connecting to the Tailnet.
The agent uses this key to auto-generate pre-auth keys for connecting to the
Tailnet and to auto-approve its own registration when Headscale is configured
to require manual approval.
## Configuration
@@ -55,6 +57,20 @@ default. You can change this location by defining
that the specified directory exists and is writable by the user running
Headplane.
Headplane preserves the agent's `tailscaled.state` in this directory. This lets
the agent retain its Tailnet identity across Headplane restarts instead of
registering as a new host each time. If the agent's state is lost or unusable,
Headplane falls back to the pre-auth key and registers a new agent node.
## Interactive approval
Under normal circumstances, the agent connects headlessly using the auto-generated
pre-auth key and no manual interaction is required. If your Headscale server is
configured to require interactive approval, Headplane detects the auth URL the
agent prints and automatically approves the request using the configured
`headscale.api_key`. The Settings page still shows the approval link as a
fallback in case auto-approval fails.
## Usage
<figure>

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