Compare commits

..

22 Commits

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

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

Closes #525

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

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

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

Send user.name instead.

Closes #532

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

Closes #508

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

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

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

Closes #535

Amp-Thread-ID: https://ampcode.com/threads/T-019e7ae4-6862-760c-a3e7-239350eab71d
Co-authored-by: Amp <amp@ampcode.com>
2026-05-30 18:47:57 -04:00
Aarnav Tale d5acba88c7 chore: changelog 2026-05-30 17:43:24 -04:00
Aarnav Tale d7f1d665a4 feat: bump headscale minimum to 0.27 2026-05-30 17:32:00 -04:00
Aarnav Tale 7901f37002 fix: correctly handle user id passthrough on headscale actions 2026-05-30 17:14:28 -04:00
Aarnav Tale 7a62359b6a chore: add a resilience test 2026-05-30 16:25:00 -04:00
Vitalij Dovhanyc 2e38a1d5e3 fix: constrain dialog panel height and make content scrollable (#556)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-05-30 16:21:17 -04:00
github-actions[bot] 7221f1c2c3 chore: update flake.lock (#545) 2026-05-27 20:37:47 -04:00
Aarnav Tale 56c5e5ac8c feat: make api calls more resilient and stuff 2026-05-25 17:14:26 -04:00
github-actions[bot] 09be95c7bc chore: update nix pnpm deps hash 2026-05-25 15:53:44 +00:00
Aarnav Tale 0512565f8e feat: replace openapi hashing system with /version
Apparently I didn't use my brain cells and rely on the /version
endpoint that Headscale has exposed since 0.26 (our lowest supported
version). Switching to that significantly simplifies the API surface.
2026-05-25 11:51:02 -04:00
Aarnav Tale d4eee702e9 refactor(server): replace AppContext undefined sentinels with Feature<T>
Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019e5550-4435-7118-8393-cdcc97042178
2026-05-23 16:56:34 -04:00
100 changed files with 2014 additions and 1940 deletions
+21
View File
@@ -1,3 +1,24 @@
# 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",
+3 -6
View File
@@ -31,10 +31,7 @@ export const shouldRevalidate: ShouldRevalidateFunction = ({
export async function loader({ request, context }: Route.LoaderArgs) {
try {
const principal = await context.auth.require(request);
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
const { principal, api } = await context.apiForRequest(request);
const user =
principal.kind === "oidc"
@@ -48,10 +45,10 @@ export async function loader({ request, context }: Route.LoaderArgs) {
: { name: principal.displayName, subject: "api_key" };
// MARK: The session should stay valid if Headscale isn't healthy
const isHealthy = await api.isHealthy();
const isHealthy = await context.headscale.health();
if (isHealthy) {
try {
await api.getApiKeys();
await api.apiKeys.list();
} catch (error) {
if (isDataUnauthorizedError(error)) {
const displayName =
-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"
}
}
+22 -65
View File
@@ -26,10 +26,9 @@ export async function aclAction({ request, context }: Route.ActionArgs) {
});
}
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
const { api } = await context.apiForRequest(request);
try {
const { policy, updatedAt } = await api.setPolicy(policyData);
const { policy, updatedAt } = await api.policy.set(policyData);
return data({
success: true,
error: undefined,
@@ -55,71 +54,29 @@ export async function aclAction({ request, context }: Route.ActionArgs) {
throw error;
}
// Starting in Headscale 0.27.0 the ACLs parsing was changed meaning
// we need to reference other error messages based on API version.
if (context.hsApi.clientHelpers.isAtleast("0.27.0")) {
if (message.includes("parsing HuJSON:")) {
const cutIndex = message.indexOf("parsing HuJSON:");
const trimmed =
cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 16).trim()}` : message;
// Policy parse errors carry one of two prefixes (HuJSON syntax vs.
// structural unmarshal). Headscale 0.27.0+ uses these forms; older
// releases are no longer supported.
if (message.includes("parsing HuJSON:")) {
const cutIndex = message.indexOf("parsing HuJSON:");
const trimmed =
cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 16).trim()}` : message;
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
return data(
{ success: false, error: trimmed, policy: undefined, updatedAt: undefined },
400,
);
}
if (message.includes("parsing policy from bytes:")) {
const cutIndex = message.indexOf("parsing policy from bytes:");
const trimmed =
cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 26).trim()}` : message;
if (message.includes("parsing policy from bytes:")) {
const cutIndex = message.indexOf("parsing policy from bytes:");
const trimmed =
cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 26).trim()}` : message;
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
} else {
// Pre-0.27.0 error messages
if (message.includes("parsing hujson")) {
const cutIndex = message.indexOf("err: hujson:");
const trimmed = cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 12)}` : message;
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
if (message.includes("unmarshalling policy")) {
const cutIndex = message.indexOf("err:");
const trimmed = cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 5)}` : message;
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
return data(
{ success: false, error: trimmed, policy: undefined, updatedAt: undefined },
400,
);
}
}
+2 -3
View File
@@ -29,10 +29,9 @@ export async function aclLoader({ request, context }: Route.LoaderArgs) {
};
// Try to load the ACL policy from the API.
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
const { api } = await context.apiForRequest(request);
try {
const { policy, updatedAt } = await api.getPolicy();
const { policy, updatedAt } = await api.policy.get();
flags.writable = updatedAt !== null;
flags.policy = policy;
return flags;
+4 -2
View File
@@ -33,9 +33,11 @@ export async function loginAction({ request, context }: Route.LoaderArgs) {
};
}
const api = context.hsApi.getRuntimeClient(apiKey);
// Build a client with the candidate API key the user just submitted, so the
// GET /api/v1/apikey call below validates the key against Headscale itself.
const api = context.headscale.client(apiKey);
try {
const apiKeys = await api.getApiKeys();
const apiKeys = await api.apiKeys.list();
// We don't need to check for 0 API keys because this request cannot
// be authenticated correctly without an API key
+7 -2
View File
@@ -24,7 +24,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
const qp = new URL(request.url).searchParams;
const urlState = qp.get("s") ?? undefined;
const oidcService = context.oidc?.service;
const oidcService = context.oidc.state === "enabled" ? context.oidc.value : undefined;
const oidcStatus = oidcService
? await oidcService.discover().then(
(r) => (r.ok ? oidcService.status() : oidcService.status()),
@@ -32,7 +32,12 @@ export async function loader({ request, context }: Route.LoaderArgs) {
)
: undefined;
if (context.oidc?.disableApiKeyLogin && oidcStatus?.state === "ready" && urlState !== "logout") {
if (
oidcService &&
context.config.oidc?.disable_api_key_login &&
oidcStatus?.state === "ready" &&
urlState !== "logout"
) {
return redirect("/oidc/start");
}
+9 -4
View File
@@ -23,15 +23,20 @@ export async function action({ request, context }: ActionFunctionArgs<AppContext
// 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" &&
context.oidc.state === "enabled" &&
context.config.oidc?.use_end_session
) {
const service = context.oidc.value;
const status = service.status();
if (status.state !== "ready") {
// Trigger discovery if it hasn't happened yet so we can find the
// end_session_endpoint without forcing a re-login.
await 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;
}
+9 -6
View File
@@ -7,10 +7,10 @@ import { createOidcStateCookie } from "~/utils/oidc-state";
import type { Route } from "./+types/oidc-callback";
export async function loader({ request, context }: Route.LoaderArgs) {
const service = context.oidc?.service;
if (!service) {
throw data("OIDC is not enabled or misconfigured", { status: 501 });
if (context.oidc.state !== "enabled") {
throw data(`OIDC is unavailable: ${context.oidc.reason}`, { status: 501 });
}
const service = context.oidc.value;
const url = new URL(request.url);
if (url.searchParams.toString().length === 0) {
@@ -56,8 +56,11 @@ export async function loader({ request, context }: Route.LoaderArgs) {
});
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 = context.headscale.client(context.headscaleApiKey!);
const hsUsers = await hsApi.users.list();
const hsUser = findHeadscaleUserBySubject(hsUsers, identity.subject, identity.email);
if (hsUser) {
await context.auth.linkHeadscaleUser(userId, hsUser.id);
@@ -68,7 +71,7 @@ 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 = context.config.oidc?.use_end_session ? identity.idToken : undefined;
return redirect("/", {
headers: {
+3 -3
View File
@@ -10,10 +10,10 @@ export async function loader({ request, context }: Route.LoaderArgs) {
return redirect("/");
} catch {}
const service = context.oidc?.service;
if (!service) {
throw data("OIDC is not enabled or misconfigured", { status: 501 });
if (context.oidc.state !== "enabled") {
throw data(`OIDC is unavailable: ${context.oidc.reason}`, { status: 501 });
}
const service = context.oidc.value;
const result = await service.startFlow();
if (!result.ok) {
+9 -12
View File
@@ -16,9 +16,6 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
return data({ success: false }, 403);
}
// We only need it for health checks which don't require auth
const api = context.hsApi.getRuntimeClient("fake-api-key");
const formData = await request.formData();
const action = formData.get("action_id")?.toString();
if (!action) {
@@ -39,7 +36,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
},
]);
await context.integration?.onConfigChange(api);
await context.integration?.onConfigChange(context.headscale);
return { message: "Tailnet renamed successfully" };
}
case "toggle_magic": {
@@ -55,7 +52,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
},
]);
await context.integration?.onConfigChange(api);
await context.integration?.onConfigChange(context.headscale);
return { message: "Magic DNS state updated successfully" };
}
case "remove_ns": {
@@ -88,7 +85,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
]);
}
await context.integration?.onConfigChange(api);
await context.integration?.onConfigChange(context.headscale);
return { message: "Nameserver removed successfully" };
}
case "add_ns": {
@@ -123,7 +120,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
]);
}
await context.integration?.onConfigChange(api);
await context.integration?.onConfigChange(context.headscale);
return { message: "Nameserver added successfully" };
}
case "remove_domain": {
@@ -141,7 +138,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
},
]);
await context.integration?.onConfigChange(api);
await context.integration?.onConfigChange(context.headscale);
return { message: "Domain removed successfully" };
}
case "add_domain": {
@@ -161,7 +158,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
},
]);
await context.integration?.onConfigChange(api);
await context.integration?.onConfigChange(context.headscale);
return { message: "Domain added successfully" };
}
case "remove_record": {
@@ -183,7 +180,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
return;
}
await context.integration?.onConfigChange(api);
await context.integration?.onConfigChange(context.headscale);
return { message: "DNS record removed successfully" };
}
case "add_record": {
@@ -205,7 +202,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
return;
}
await context.integration?.onConfigChange(api);
await context.integration?.onConfigChange(context.headscale);
return { message: "DNS record added successfully" };
}
case "override_dns": {
@@ -222,7 +219,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
},
]);
await context.integration?.onConfigChange(api);
await context.integration?.onConfigChange(context.headscale);
return { message: "DNS override updated successfully" };
}
default:
+3 -5
View File
@@ -24,12 +24,11 @@ export async function loader({ request, context }: Route.LoaderArgs) {
// Unclaimed users they can pick from before anything else.
let unlinked = false;
if (
typeof context.oidc === "object" &&
context.oidc.state === "enabled" &&
principal.kind === "oidc" &&
!principal.user.headscaleUserId
) {
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
const { api } = await context.apiForRequest(request);
let headscaleUsers: { id: string; name: string }[] = [];
try {
@@ -64,8 +63,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
}
// No UI access — show the download/connect page
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
const { api } = await context.apiForRequest(request);
let linkedUserName: string | undefined;
if (principal.kind === "oidc" && principal.user.headscaleUserId) {
+3 -1
View File
@@ -52,7 +52,9 @@ export default function NewMachine(data: NewMachineProps) {
onValueChange={(v) => form.setValue("user", v)}
placeholder="Select a user"
items={data.users.map((user) => ({
value: user.id,
// Headscale's v1/node/register endpoint resolves the owner by
// username via GetUserByName, so we must pass user.name (not id).
value: user.name,
label: getUserDisplayName(user),
}))}
/>
+14 -10
View File
@@ -7,10 +7,9 @@ import { Capabilities } from "~/server/web/roles";
import type { Route } from "./+types/machine";
export async function machineAction({ request, context }: Route.ActionArgs) {
const principal = await context.auth.require(request);
const { principal, api } = await context.apiForRequest(request);
const formData = await request.formData();
const api = context.hsApi.getRuntimeClient(context.auth.getHeadscaleApiKey(principal));
const action = formData.get("action_id")?.toString();
if (!action) {
@@ -41,7 +40,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
});
}
const node = await api.registerNode(user, registrationKey);
const node = await api.nodes.register(user, registrationKey);
await context.hsLive.refresh(nodesResource, api);
return redirect(`/machines/${node.id}`);
}
@@ -54,7 +53,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
});
}
const node = await api.getNode(nodeId);
const node = await api.nodes.get(nodeId);
if (!node) {
throw data(`Machine with ID ${nodeId} not found`, {
status: 404,
@@ -77,19 +76,19 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
}
const name = String(formData.get("name"));
await api.renameNode(nodeId, name);
await api.nodes.rename(nodeId, name);
await context.hsLive.refresh(nodesResource, api);
return { message: "Machine renamed" };
}
case "delete": {
await api.deleteNode(nodeId);
await api.nodes.delete(nodeId);
await context.hsLive.refresh(nodesResource, api);
return redirect("/machines");
}
case "expire": {
await api.expireNode(nodeId);
await api.nodes.expire(nodeId);
await context.hsLive.refresh(nodesResource, api);
return { message: "Machine expired" };
}
@@ -103,7 +102,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
}
try {
await api.setNodeTags(
await api.nodes.setTags(
nodeId,
tags.map((tag) => tag.trim()).filter((tag) => tag !== ""),
);
@@ -172,7 +171,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
}
}
await api.approveNodeRoutes(nodeId, newApproved);
await api.nodes.approveRoutes(nodeId, newApproved);
await context.hsLive.refresh(nodesResource, api);
return { message: "Routes updated" };
}
@@ -185,7 +184,12 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
});
}
await api.setNodeUser(nodeId, user);
if (!api.nodes.reassignUser) {
throw data("Reassigning a node owner is no longer supported on this Headscale version.", {
status: 400,
});
}
await api.nodes.reassignUser(nodeId, user);
await context.hsLive.refresh(nodesResource, api);
return { message: "Machine reassigned" };
}
+6 -6
View File
@@ -22,7 +22,6 @@ import Routes from "./dialogs/routes";
import { machineAction } from "./machine-actions";
export async function loader({ request, params, context }: Route.LoaderArgs) {
const principal = await context.auth.require(request);
if (!params.id) {
throw new Error("No machine ID provided");
}
@@ -38,7 +37,7 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
}
}
const api = context.hsApi.getRuntimeClient(context.auth.getHeadscaleApiKey(principal));
const { api } = await context.apiForRequest(request);
const [nodesSnap, usersSnap] = await Promise.all([
context.hsLive.get(nodesResource, api),
context.hsLive.get(usersResource, api),
@@ -50,18 +49,19 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
throw data(null, { status: 404 });
}
const lookup = await context.agents?.lookup([node.nodeKey]);
const agents = context.agents.state === "enabled" ? context.agents.value : undefined;
const lookup = await agents?.lookup([node.nodeKey]);
const [enhancedNode] = mapNodes([node], lookup);
const tags = [...node.tags].toSorted();
const supportsNodeOwnerChange = !context.hsApi.clientHelpers.isAtleast("0.28.0");
const agentSync = context.agents?.lastSync();
const supportsNodeOwnerChange = !context.headscale.capabilities.nodeOwnerIsImmutable;
const agentSync = agents?.lastSync();
return {
agent: agentSync
? {
syncedAt: agentSync.syncedAt?.toISOString() ?? null,
nodeCount: agentSync.nodeCount,
nodeKey: context.agents?.agentNodeKey(),
nodeKey: agents?.agentNodeKey(),
}
: undefined,
existingTags: sortNodeTags(nodes),
+6 -5
View File
@@ -30,7 +30,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
const writablePermission = context.auth.can(principal, Capabilities.write_machines);
const api = context.hsApi.getRuntimeClient(context.auth.getHeadscaleApiKey(principal));
const { api } = await context.apiForRequest(request);
const [nodesSnap, usersSnap] = await Promise.all([
context.hsLive.get(nodesResource, api),
context.hsLive.get(usersResource, api),
@@ -45,17 +45,18 @@ export async function loader({ request, context }: Route.LoaderArgs) {
}
}
const stats = await context.agents?.lookup(nodes.map((node) => node.nodeKey));
const agents = context.agents.state === "enabled" ? context.agents.value : undefined;
const stats = await agents?.lookup(nodes.map((node) => node.nodeKey));
const populatedNodes = mapNodes(nodes, stats);
const supportsNodeOwnerChange = !context.hsApi.clientHelpers.isAtleast("0.28.0");
const agentSync = context.agents?.lastSync();
const supportsNodeOwnerChange = !context.headscale.capabilities.nodeOwnerIsImmutable;
const agentSync = agents?.lastSync();
return {
agent: agentSync
? {
syncedAt: agentSync.syncedAt?.toISOString() ?? null,
nodeCount: agentSync.nodeCount,
nodeKey: context.agents?.agentNodeKey(),
nodeKey: agents?.agentNodeKey(),
}
: undefined,
headscaleUserId: principal.kind === "oidc" ? principal.user.headscaleUserId : undefined,
+9 -9
View File
@@ -11,13 +11,13 @@ import { formatTimeDelta } from "~/utils/time";
import type { Route } from "./+types/agent";
export async function loader({ request, context }: Route.LoaderArgs) {
const principal = await context.auth.require(request);
await context.auth.require(request);
if (!context.agents) {
return { enabled: false as const };
if (context.agents.state !== "enabled") {
return { enabled: false as const, reason: context.agents.reason };
}
const sync = context.agents.lastSync();
const sync = context.agents.value.lastSync();
return {
enabled: true as const,
syncedAt: sync.syncedAt?.toISOString() ?? null,
@@ -29,12 +29,12 @@ export async function loader({ request, context }: Route.LoaderArgs) {
export async function action({ request, context }: Route.ActionArgs) {
await context.auth.require(request);
if (!context.agents) {
return { success: false, error: "Agent is not enabled" };
if (context.agents.state !== "enabled") {
return { success: false, error: context.agents.reason };
}
await context.agents.triggerSync();
const sync = context.agents.lastSync();
await context.agents.value.triggerSync();
const sync = context.agents.value.lastSync();
return { success: !sync.error, error: sync.error };
}
@@ -47,7 +47,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
<div className="flex max-w-(--breakpoint-lg) flex-col gap-8">
<Title>Headplane Agent</Title>
<Notice title="Agent Not Enabled">
The Headplane Agent is not enabled. To learn how to set up the agent, visit the{" "}
{loaderData.reason}. To learn how to set up the agent, visit the{" "}
<Link external styled to="https://headplane.dev/docs/agent">
documentation
</Link>
+17 -11
View File
@@ -7,9 +7,7 @@ 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 { principal, api } = await context.apiForRequest(request);
const canGenerateAny = context.auth.can(principal, Capabilities.generate_authkeys);
const canGenerateOwn = context.auth.can(principal, Capabilities.generate_own_authkeys);
@@ -22,7 +20,7 @@ export async function authKeysAction({ request, context }: Route.ActionArgs) {
async function checkSelfServiceOwnership(userId: string) {
if (canGenerateAny || !canGenerateOwn) return;
const [targetUser] = await api.getUsers(userId);
const [targetUser] = await api.users.list({ id: userId });
if (!targetUser) {
throw data("User not found.", { status: 404 });
}
@@ -86,13 +84,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 +112,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");
}
+8 -8
View File
@@ -19,9 +19,7 @@ import AuthKeyRow from "./auth-key-row";
import AddAuthKey from "./dialogs/add-auth-key";
export async function loader({ request, context }: Route.LoaderArgs) {
const principal = await context.auth.require(request);
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
const { principal, api } = await context.apiForRequest(request);
const usersSnap = await context.hsLive.get(usersResource, api);
const users = usersSnap.data;
@@ -31,10 +29,12 @@ export async function loader({ request, context }: Route.LoaderArgs) {
// Try fetching all keys at once (Headscale 0.28+), fall back to per-user
let allKeys: PreAuthKey[] | null = null;
try {
allKeys = await api.getAllPreAuthKeys();
} catch {
// Older versions don't support this endpoint
if (api.preAuthKeys.listAll) {
try {
allKeys = await api.preAuthKeys.listAll();
} catch {
// Treat any failure as "no global list available" and fall through.
}
}
if (allKeys !== null) {
@@ -67,7 +67,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
.filter((u) => u.id?.length > 0)
.map(async (user) => {
try {
const preAuthKeys = await api.getPreAuthKeys(user.id);
const preAuthKeys = await api.preAuthKeys.listForUser(user.id);
return { preAuthKeys, success: true as const, user };
} catch (error) {
log.error("api", "GET /v1/preauthkey for %s: %o", user.name, error);
+2 -1
View File
@@ -8,7 +8,8 @@ import type { Route } from "./+types/overview";
export async function loader({ context }: Route.LoaderArgs) {
return {
config: context.hs.writable(),
isOidcEnabled: context.oidc?.service.status().state === "ready",
isOidcEnabled:
context.oidc.state === "enabled" && context.oidc.value.status().state === "ready",
};
}
+6 -8
View File
@@ -28,8 +28,6 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
});
}
// We only need healthchecks which don't rely on an API key
const api = context.hsApi.getRuntimeClient("fake-api-key");
switch (action) {
case "add_domain": {
const domain = formData.get("domain")?.toString()?.trim();
@@ -48,7 +46,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
},
]);
context.integration?.onConfigChange(api);
context.integration?.onConfigChange(context.headscale);
return data("Domain added successfully.");
}
@@ -76,7 +74,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
value: domains,
},
]);
context.integration?.onConfigChange(api);
context.integration?.onConfigChange(context.headscale);
return data("Domain removed successfully.");
}
@@ -97,7 +95,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
},
]);
context.integration?.onConfigChange(api);
context.integration?.onConfigChange(context.headscale);
return data("Group added successfully.");
}
@@ -126,7 +124,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
},
]);
context.integration?.onConfigChange(api);
context.integration?.onConfigChange(context.headscale);
return data("Group removed successfully.");
}
@@ -147,7 +145,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
},
]);
context.integration?.onConfigChange(api);
context.integration?.onConfigChange(context.headscale);
return data("User added successfully.");
}
@@ -176,7 +174,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
},
]);
context.integration?.onConfigChange(api);
context.integration?.onConfigChange(context.headscale);
return data("User removed successfully.");
}
+11 -14
View File
@@ -37,22 +37,19 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
throw data(sshErrors.wasm_missing, 405);
}
if (context.agents == null) {
if (context.agents.state !== "enabled") {
throw data(sshErrors.agent_required, 400);
}
const principal = await context.auth.require(request);
const { principal, api } = await context.apiForRequest(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 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);
@@ -67,20 +64,20 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
}
// The user must exist within Headscale to generate a pre-auth key
const users = await api.getUsers();
const users = await api.users.list();
const hsUser = findHeadscaleUserBySubject(users, principal.user.subject, principal.profile.email);
if (!hsUser) {
throw data(sshErrors.user_not_linked, 404);
}
const preAuthKey = await api.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() + 60 * 1000), // 1 minute expiry
aclTags: null,
});
const controlURL = context.config.headscale.public_url ?? context.config.headscale.url;
return {
@@ -0,0 +1,58 @@
import { Ellipsis } from "lucide-react";
import { useState } from "react";
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "~/components/menu";
import Delete from "../dialogs/delete-user";
import Rename from "../dialogs/rename-user";
import type { UnlinkedHeadscaleUser } from "../overview";
interface HeadscaleUserMenuProps {
user: UnlinkedHeadscaleUser;
}
type Modal = "rename" | "delete" | null;
export default function HeadscaleUserMenu({ user }: HeadscaleUserMenuProps) {
const [modal, setModal] = useState<Modal>(null);
// Headscale-managed OIDC users cannot be renamed via the API.
const canRename = user.provider !== "oidc";
return (
<>
{modal === "rename" && canRename && (
<Rename
isOpen={modal === "rename"}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
user={user}
/>
)}
{modal === "delete" && (
<Delete
isOpen={modal === "delete"}
machines={user.machines}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
user={user}
/>
)}
<Menu>
<MenuTrigger className="w-10 rounded-full bg-transparent p-1 py-0.5 hover:bg-mist-100 dark:hover:bg-mist-800">
<Ellipsis className="h-5" />
</MenuTrigger>
<MenuContent>
{canRename && <MenuItem onClick={() => setModal("rename")}>Rename</MenuItem>}
{canRename && <MenuSeparator />}
<MenuItem variant="danger" onClick={() => setModal("delete")}>
Delete
</MenuItem>
</MenuContent>
</Menu>
</>
);
}
@@ -4,12 +4,14 @@ import StatusCircle from "~/components/status-circle";
import cn from "~/utils/cn";
import type { UnlinkedHeadscaleUser } from "../overview";
import HeadscaleUserMenu from "./headscale-user-menu";
interface HeadscaleUserRowProps {
user: UnlinkedHeadscaleUser;
writable?: boolean;
}
export default function HeadscaleUserRow({ user }: HeadscaleUserRowProps) {
export default function HeadscaleUserRow({ user, writable }: HeadscaleUserRowProps) {
const isOnline = user.machines.some((machine) => machine.online);
const lastSeen = user.machines.reduce(
(acc, machine) => Math.max(acc, new Date(machine.lastSeen).getTime()),
@@ -54,9 +56,7 @@ export default function HeadscaleUserRow({ user }: HeadscaleUserRowProps) {
<p className="text-sm text-mist-600 dark:text-mist-300">No machines</p>
)}
</td>
<td className="py-2 pr-0.5">
{/* Unlinked users only get basic Headscale operations (rename, delete) */}
</td>
<td className="py-2 pr-0.5">{writable ? <HeadscaleUserMenu user={user} /> : null}</td>
</tr>
);
}
+3 -3
View File
@@ -54,24 +54,24 @@ export default function UserMenu({
{modal === "reassign" && (
<Reassign
displayName={displayName}
headplaneUserId={user.id}
isOpen={modal === "reassign"}
role={user.role}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
userId={user.linkedHeadscaleUser?.id ?? user.id}
/>
)}
{modal === "link" && (
<LinkUser
currentLink={currentLink}
displayName={displayName}
headplaneUserId={user.id}
headscaleUsers={linkableUsers}
isOpen={modal === "link"}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
userId={user.linkedHeadscaleUser?.id ?? user.id}
/>
)}
{modal === "transfer" && (
@@ -81,7 +81,7 @@ export default function UserMenu({
if (!isOpen) setModal(null);
}}
targetDisplayName={displayName}
targetUserId={user.linkedHeadscaleUser?.id ?? user.id}
targetHeadplaneUserId={user.id}
/>
)}
+1 -1
View File
@@ -34,7 +34,7 @@ export default function DeleteUser({ user, machines, isOpen, setIsOpen }: Delete
</Text>
)}
<input name="action_id" type="hidden" value="delete_user" />
<input name="user_id" type="hidden" value={user.id} />
<input name="headscale_user_id" type="hidden" value={user.id} />
</DialogPanel>
</Dialog>
);
+3 -3
View File
@@ -5,7 +5,7 @@ import Title from "~/components/title";
import cn from "~/utils/cn";
interface LinkUserProps {
userId: string;
headplaneUserId: string;
displayName: string;
headscaleUsers: { id: string; name: string }[];
currentLink?: string;
@@ -14,7 +14,7 @@ interface LinkUserProps {
}
export default function LinkUser({
userId,
headplaneUserId,
displayName,
headscaleUsers,
currentLink,
@@ -34,7 +34,7 @@ export default function LinkUser({
) : (
<>
<input name="action_id" type="hidden" value="link_user" />
<input name="user_id" type="hidden" value={userId} />
<input name="headplane_user_id" type="hidden" value={headplaneUserId} />
<select
className={cn(
"w-full rounded-lg border p-2",
+3 -3
View File
@@ -8,7 +8,7 @@ import { Roles } from "~/server/web/roles";
import type { Role } from "~/server/web/roles";
interface ReassignProps {
userId: string;
headplaneUserId: string;
displayName: string;
role: Role;
isOpen: boolean;
@@ -16,7 +16,7 @@ interface ReassignProps {
}
export default function ReassignUser({
userId,
headplaneUserId,
displayName,
role,
isOpen,
@@ -38,7 +38,7 @@ export default function ReassignUser({
) : (
<>
<input name="action_id" type="hidden" value="reassign_user" />
<input name="user_id" type="hidden" value={userId} />
<input name="headplane_user_id" type="hidden" value={headplaneUserId} />
<RadioGroup className="gap-4" defaultValue={role} label="Role" name="new_role">
{Object.keys(Roles)
.filter((r) => r !== "owner")
+1 -1
View File
@@ -21,7 +21,7 @@ export default function RenameUser({ user, isOpen, setIsOpen }: RenameProps) {
update any ACL policies that may refer to this user by their old username.
</Text>
<input name="action_id" type="hidden" value="rename_user" />
<input name="user_id" type="hidden" value={user.id} />
<input name="headscale_user_id" type="hidden" value={user.id} />
<Input
defaultValue={user.name}
required
@@ -4,14 +4,14 @@ import Text from "~/components/text";
import Title from "~/components/title";
interface TransferOwnershipProps {
targetUserId: string;
targetHeadplaneUserId: string;
targetDisplayName: string;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
export default function TransferOwnership({
targetUserId,
targetHeadplaneUserId,
targetDisplayName,
isOpen,
setIsOpen,
@@ -29,7 +29,7 @@ export default function TransferOwnership({
ownership.
</Notice>
<input name="action_id" type="hidden" value="transfer_ownership" />
<input name="user_id" type="hidden" value={targetUserId} />
<input name="headplane_user_id" type="hidden" value={targetHeadplaneUserId} />
</DialogPanel>
</Dialog>
);
+2 -3
View File
@@ -54,8 +54,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
let apiError: string | undefined;
try {
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
const { api } = await context.apiForRequest(request);
const [nodesSnap, usersSnap] = await Promise.all([
context.hsLive.get(nodesResource, api),
context.hsLive.get(usersResource, api),
@@ -235,7 +234,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
)}
>
{loaderData.unlinkedHeadscaleUsers.map((user) => (
<HeadscaleUserRow key={user.id} user={user} />
<HeadscaleUserRow key={user.id} user={user} writable={loaderData.writable} />
))}
</tbody>
</table>
+24 -62
View File
@@ -1,7 +1,6 @@
import { data } from "react-router";
import { usersResource } from "~/server/headscale/live-store";
import { getOidcSubject } from "~/server/web/headscale-identity";
import { Capabilities } from "~/server/web/roles";
import type { Role } from "~/server/web/roles";
@@ -24,8 +23,7 @@ export async function userAction({ request, context }: Route.ActionArgs) {
});
}
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
const { api } = await context.apiForRequest(request);
switch (action) {
case "create_user": {
const name = formData.get("username")?.toString();
@@ -38,33 +36,33 @@ export async function userAction({ request, context }: Route.ActionArgs) {
});
}
await api.createUser(name, email, displayName);
await api.users.create({ name, email, displayName });
await context.hsLive.refresh(usersResource, api);
return { message: "User created successfully" };
}
case "delete_user": {
const userId = formData.get("user_id")?.toString();
if (!userId) {
throw data("Missing `user_id` in the form data.", {
const headscaleUserId = formData.get("headscale_user_id")?.toString();
if (!headscaleUserId) {
throw data("Missing `headscale_user_id` in the form data.", {
status: 400,
});
}
await api.deleteUser(userId);
await api.users.delete(headscaleUserId);
await context.hsLive.refresh(usersResource, api);
return { message: "User deleted successfully" };
}
case "rename_user": {
const userId = formData.get("user_id")?.toString();
const headscaleUserId = formData.get("headscale_user_id")?.toString();
const newName = formData.get("new_name")?.toString();
if (!userId || !newName) {
if (!headscaleUserId || !newName) {
return data({ success: false }, 400);
}
const users = await api.getUsers(userId);
const user = users.find((user) => user.id === userId);
const users = await api.users.list({ id: headscaleUserId });
const user = users.find((user) => user.id === headscaleUserId);
if (!user) {
throw data(`No user found with id: ${userId}`, { status: 400 });
throw data(`No user found with id: ${headscaleUserId}`, { status: 400 });
}
if (user.provider === "oidc") {
@@ -74,34 +72,20 @@ export async function userAction({ request, context }: Route.ActionArgs) {
});
}
await api.renameUser(userId, newName);
await api.users.rename(headscaleUserId, newName);
await context.hsLive.refresh(usersResource, api);
return { message: "User renamed successfully" };
}
case "reassign_user": {
const userId = formData.get("user_id")?.toString();
const headplaneUserId = formData.get("headplane_user_id")?.toString();
const newRole = formData.get("new_role")?.toString();
if (!userId || !newRole) {
throw data("Missing `user_id` or `new_role` in the form data.", {
if (!headplaneUserId || !newRole) {
throw data("Missing `headplane_user_id` or `new_role` in the form data.", {
status: 400,
});
}
const users = await api.getUsers(userId);
const user = users.find((user) => user.id === userId);
if (!user) {
throw data("Specified user not found", {
status: 400,
});
}
const subject = getOidcSubject(user);
if (!subject) {
throw data("Specified user is not an OIDC user or has no subject.", { status: 400 });
}
const result = await context.auth.reassignSubject(subject, newRole as Role);
const result = await context.auth.reassignUser(headplaneUserId, newRole as Role);
if (!result) {
throw data("Failed to reassign user role.", { status: 500 });
}
@@ -113,23 +97,12 @@ export async function userAction({ request, context }: Route.ActionArgs) {
throw data("Only the owner can transfer ownership.", { status: 403 });
}
const userId = formData.get("user_id")?.toString();
if (!userId) {
throw data("Missing `user_id` in the form data.", { status: 400 });
const headplaneUserId = formData.get("headplane_user_id")?.toString();
if (!headplaneUserId) {
throw data("Missing `headplane_user_id` in the form data.", { status: 400 });
}
const users = await api.getUsers(userId);
const user = users.find((user) => user.id === userId);
if (!user) {
throw data("Specified user not found", { status: 400 });
}
const targetSubject = getOidcSubject(user);
if (!targetSubject) {
throw data("Target user is not an OIDC user or has no subject.", { status: 400 });
}
const result = await context.auth.transferOwnership(principal.user.subject, targetSubject);
const result = await context.auth.transferOwnership(principal.user.id, headplaneUserId);
if (!result) {
throw data("Failed to transfer ownership.", { status: 500 });
}
@@ -137,26 +110,15 @@ export async function userAction({ request, context }: Route.ActionArgs) {
return { message: "Ownership transferred successfully" };
}
case "link_user": {
const userId = formData.get("user_id")?.toString();
const headplaneUserId = formData.get("headplane_user_id")?.toString();
const headscaleUserId = formData.get("headscale_user_id")?.toString();
if (!userId || !headscaleUserId) {
throw data("Missing `user_id` or `headscale_user_id` in the form data.", {
if (!headplaneUserId || !headscaleUserId) {
throw data("Missing `headplane_user_id` or `headscale_user_id` in the form data.", {
status: 400,
});
}
const users = await api.getUsers(userId);
const user = users.find((user) => user.id === userId);
if (!user) {
throw data("Specified user not found", { status: 400 });
}
const subject = getOidcSubject(user);
if (!subject) {
throw data("Specified user is not an OIDC user or has no subject.", { status: 400 });
}
const linked = await context.auth.linkHeadscaleUserBySubject(subject, headscaleUserId);
const linked = await context.auth.linkHeadscaleUser(headplaneUserId, headscaleUserId);
if (!linked) {
throw data("That Headscale user is already linked to another account.", { status: 409 });
}
+1 -3
View File
@@ -1,9 +1,7 @@
import type { Route } from "./+types/healthz";
export async function loader({ context }: Route.LoaderArgs) {
// Use a fake API key for healthcheck
const api = context.hsApi.getRuntimeClient("fake-api-key");
const healthy = await api.isHealthy();
const healthy = await context.headscale.health();
return new Response(JSON.stringify({ status: healthy ? "OK" : "ERROR" }), {
status: healthy ? 200 : 500,
+2 -4
View File
@@ -34,14 +34,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 context.headscale.health();
const body = {
status: healthy ? "healthy" : "unhealthy",
headplane_version: __VERSION__,
headscale_canonical_version: healthy ? context.hsApi.apiVersion : "unknown",
headscale_canonical_version: healthy ? context.headscale.version.raw : "unknown",
internal_versions: {
node: versions.node,
v8: versions.v8,
+1 -3
View File
@@ -4,9 +4,7 @@ import log from "~/utils/log";
import type { Route } from "./+types/live";
export async function loader({ request, context }: Route.LoaderArgs) {
const principal = await context.auth.require(request);
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
const { api } = await context.apiForRequest(request);
// Ensure resources are loaded before streaming
await Promise.all([
+1 -1
View File
@@ -63,7 +63,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`)
+18 -1
View File
@@ -35,11 +35,28 @@ 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.
+9
View File
@@ -42,6 +42,12 @@ const serverConfig = type({
cookie_secure: "boolean = true",
cookie_domain: "string.lower?",
cookie_max_age: "number.integer = 86400",
// TLS termination. When both `tls_cert_path` and `tls_key_path`
// are provided, Headplane serves HTTPS on `server.port`. When
// either is set, `cookie_secure` is forced to `true`.
tls_cert_path: "string?",
tls_key_path: "string?",
});
const partialServerConfig = type({
@@ -55,6 +61,9 @@ const partialServerConfig = type({
cookie_secure: "boolean?",
cookie_domain: "string.lower?",
cookie_max_age: "number.integer?",
tls_cert_path: "string?",
tls_key_path: "string?",
});
const headscaleConfig = type({
+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",
});
+132 -56
View File
@@ -5,12 +5,13 @@ 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>>;
@@ -20,27 +21,21 @@ declare module "react-router" {
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,
@@ -54,49 +49,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.config_strict,
config.headscale.dns_records_path,
);
const integration = await loadIntegration(config.integration);
// Disposers run in reverse-registration order on shutdown.
const disposers: Array<() => Promise<void> | void> = [
() => auth.stop(),
() => hsLive.dispose(),
() => headscale.dispose(),
];
if (agents.state === "enabled") {
disposers.push(() => agents.value.dispose());
}
async function apiForRequest(
request: Request,
): Promise<{ principal: Principal; api: HeadscaleClient }> {
const principal = await auth.require(request);
const apiKey = auth.getHeadscaleApiKey(principal);
return { principal, api: headscale.client(apiKey) };
}
function startServices() {
auth.start();
}
async function dispose() {
for (const d of [...disposers].reverse()) {
try {
await d();
} catch (error) {
log.warn("server", "Error during shutdown: %s", String(error));
}
}
}
return {
config,
db,
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,
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);
}
+20
View File
@@ -0,0 +1,20 @@
// MARK: Feature<T>
//
// A two-state tagged union used in place of `T | undefined` for
// optional features on the AppContext. Carries a human-readable
// reason when the feature is disabled so loaders can surface it (or
// choose to ignore it).
//
// This is deliberately *not* a Result/Either. It does not represent
// async readiness or retryable failure — it represents "is this
// feature wired up at all." Services that have their own runtime
// status (e.g. OIDC discovery) keep that on the service itself.
export type Feature<T> = { state: "enabled"; value: T } | { state: "disabled"; reason: string };
export const enabled = <T>(value: T): Feature<T> => ({ state: "enabled", value });
export const disabled = (reason: string): Feature<never> => ({
state: "disabled",
reason,
});
+47
View File
@@ -0,0 +1,47 @@
// MARK: Headscale Capabilities
//
// Behavioural facts about the connected Headscale server, derived
// once from `ServerVersion` at boot. Capabilities are named for *what
// changed* — not for the version number it changed in — so a reader
// who has never seen Headscale can tell what each flag controls
// without consulting a release note.
//
// Add a capability here when you find yourself reaching for a raw
// version comparison in endpoint or route code. Adding a capability
// is also the right answer when a new Headscale release changes wire
// format or removes an endpoint.
import { gte, type ServerVersion } from "./server-version";
export interface Capabilities {
/**
* Pre-auth keys have stable IDs. `GET /api/v1/preauthkey` (no
* user filter) returns every key in the system, and
* `POST /api/v1/preauthkey/expire` takes `{ id }` instead of
* `{ user, key }`. Tag-only pre-auth keys (no owning user) are
* supported. Introduced in 0.28.0.
*/
readonly preAuthKeysHaveStableIds: boolean;
/**
* Node tags are a flat `tags: string[]` field on the wire.
* Pre-0.28 returned `forcedTags` / `validTags` / `invalidTags`
* that the client had to union itself. Introduced in 0.28.0.
*/
readonly nodeTagsAreFlat: boolean;
/**
* A node's owning user is immutable after creation;
* `POST /api/v1/node/{id}/user` no longer reassigns. Effective in
* 0.28.0+.
*/
readonly nodeOwnerIsImmutable: boolean;
}
export function capabilitiesFor(version: ServerVersion): Capabilities {
return {
preAuthKeysHaveStableIds: gte(version, "0.28.0"),
nodeTagsAreFlat: gte(version, "0.28.0"),
nodeOwnerIsImmutable: gte(version, "0.28.0"),
};
}
@@ -1,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;
}
+141 -306
View File
@@ -1,328 +1,163 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { dereference } from "@readme/openapi-parser";
import type { OpenAPIV2 } from "openapi-types";
import { data } from "react-router";
import { Agent, type Dispatcher, request } from "undici";
// MARK: Headscale API
//
// The public entry point for talking to a Headscale server. At boot
// we try `GET /version` (unauthenticated, present since Headscale
// 0.27.0 — the minimum version Headplane supports) to derive a
// typed `Capabilities` object. Boot outcomes:
//
// - success: parse the response, derive capabilities, done.
// - 404: Headscale is reachable but predates 0.27.0 and is no
// longer supported. Log an error and keep retrying so an
// upgrade is picked up without a Headplane restart.
// - any other failure (network, 5xx, parse): Headplane still
// boots with `version = unknown` (capabilities-permissive) and
// a background retry. This handles docker-compose start-order
// races without making the whole process unhappy.
//
// Capabilities are always derived from `version`; once detection
// finishes there's no further state to track.
import log from "~/utils/log";
import endpointSets, { RuntimeApiClient } from "./endpoints";
import { undiciToFriendlyError } from "./error";
import { HeadscaleAPIError, isApiError } from "./error-client";
import { detectApiVersion, isAtLeast, type Version } from "./version";
import { type Capabilities, capabilitiesFor } from "./capabilities";
import { isDataWithApiError } from "./error-client";
import { type ApiKeyApi, makeApiKeyApi } from "./resources/api-keys";
import { makeNodeApi, type NodeApi } from "./resources/nodes";
import { makePolicyApi, type PolicyApi } from "./resources/policy";
import { makePreAuthKeyApi, type PreAuthKeyApi } from "./resources/pre-auth-keys";
import { makeUserApi, type UserApi } from "./resources/users";
import { formatServerVersion, parseServerVersion, type ServerVersion } from "./server-version";
import { createTransport } from "./transport";
/**
* A low-level composed interface for interacting with the Headscale API.
* This interface provides direct access to the underlying Undici agent
* and methods for making API requests.
*
* It is also responsible for handling OpenAPI spec polling and hashing to
* determine the implementations of API methods when requested for use.
*/
export interface HeadscaleApiInterface {
/**
* The underlying Undici agent used for making requests.
*/
undiciAgent: Agent;
const MIN_SUPPORTED_VERSION = "0.27.0";
/**
* The base URL of the Headscale API.
*/
baseUrl: string;
/**
* The OpenAPI hashes retrieved from the Headscale instance at runtime.
* This is used to determine which implementations of API methods to use.
*/
openapiHashes: Record<string, string> | null;
/**
* The detected API version of the connected Headscale instance.
*/
apiVersion: Version;
/**
* Retrieves a runtime API client for the given API key.
*
* @param apiKey The API key to use for authentication.
* @returns A `RuntimeApiClient` instance for interacting with the API.
*/
getRuntimeClient(apiKey: string): RuntimeApiClient;
/**
* A set of helper methods made available to API method implementations.
* The idea is to make interacting with the API easier by providing
* common functionality that can be reused across multiple methods.
*/
clientHelpers: {
/**
* Checks if the connected Headscale instance's API version
* is at least the specified version.
*
* @param version The version to check against.
* @returns `true` if the API version is at least the specified version, `false` otherwise.
*/
isAtleast(version: Version): boolean;
/**
* Makes a raw fetch request to the Headscale API via the Undici agent.
* This method is used internally by API method implementations
* to make requests to the Headscale API.
*
* @param path The API path to request.
* @param options Optional request options.
* @returns A promise that resolves to the response data.
*/
rawFetch(
path: string,
options?: Partial<Dispatcher.RequestOptions>,
): Promise<Dispatcher.ResponseData>;
/**
* Makes a typed API fetch request to the Headscale API.
* This method is used internally by API method implementations
* to make requests to the Headscale API and parse the response.
*
* @param method The HTTP method to use.
* @param apiPath The API path to request.
* @param apiKey The API key to use for authentication.
* @param bodyOrQuery Optional body or query parameters.
* @returns A promise that resolves to the typed response data.
*/
apiFetch<T>(
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH",
apiPath: `v1/${string}`,
apiKey: string,
bodyOrQuery?: Record<string, unknown>,
): Promise<T>;
};
export interface Headscale {
readonly version: ServerVersion;
readonly capabilities: Capabilities;
/** True if the Headscale server's `/health` endpoint returns 200. */
health(): Promise<boolean>;
/** Build an API client bound to a specific Headscale API key. */
client(apiKey: string): HeadscaleClient;
/** Stop background work and close the underlying HTTP agent. */
dispose(): Promise<void>;
}
/**
* Creates a new Headscale API client interface.
*
* @param baseUrl The base URL of the Headscale API.
* @param certPath Optional path to a custom TLS certificate for secure connections.
* @returns A promise that resolves to a `HeadscaleApiClient` instance.
*/
export async function createHeadscaleInterface(
baseUrl: string,
certPath?: string,
): Promise<HeadscaleApiInterface> {
const undiciAgent = await createUndiciAgent(certPath);
let openapiHashes: Record<string, string> | null = null;
let apiVersion: Version;
const rawFetch = async (
url: string,
options?: Partial<Dispatcher.RequestOptions>,
): Promise<Dispatcher.ResponseData> => {
const method = options?.method ?? "GET";
log.debug("api", "%s %s", method, url);
try {
const res = await request(new URL(url, baseUrl), {
dispatcher: undiciAgent,
headers: {
...options?.headers,
Accept: "application/json",
"User-Agent": `Headplane/${__VERSION__}`,
},
body: options?.body,
method,
});
return res;
} catch (error) {
const errorBody = undiciToFriendlyError(error, `${method} ${url}`);
throw data(errorBody, {
status: 502,
statusText: "Bad Gateway",
});
}
};
const apiFetch = async <T>(
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH",
apiPath: `v1/${string}`,
apiKey: string,
bodyOrQuery?: Record<string, unknown>,
): Promise<T> => {
let url = `/api/${apiPath}`;
const options: Partial<Dispatcher.RequestOptions> = {
method: method,
headers: {
Authorization: `Bearer ${apiKey}`,
},
};
if (bodyOrQuery) {
if (method === "GET" || method === "DELETE") {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(bodyOrQuery)) {
if (value !== undefined) {
params.append(key, String(value));
}
}
if ([...params.keys()].length > 0) {
url += `?${params.toString()}`;
}
} else {
options.body = JSON.stringify(bodyOrQuery);
options.headers = {
...options.headers,
"Content-Type": "application/json",
};
}
}
const res = await rawFetch(url, options);
if (res.statusCode >= 400) {
log.debug("api", "%s %s failed with status %d", method, apiPath, res.statusCode);
const rawData = await res.body.text();
const jsonData = (() => {
try {
return JSON.parse(rawData) as Record<string, unknown>;
} catch {
return null;
}
})();
throw data(
{
requestUrl: `${method} ${apiPath}`,
statusCode: res.statusCode,
rawData,
data: jsonData,
} satisfies HeadscaleAPIError,
{ status: 502, statusText: "Bad Gateway" },
);
}
return res.body.json() as Promise<T>;
};
export interface HeadscaleClient {
nodes: NodeApi;
users: UserApi;
policy: PolicyApi;
preAuthKeys: PreAuthKeyApi;
apiKeys: ApiKeyApi;
}
export interface CreateHeadscaleOptions {
url: string;
certPath?: string;
/**
* Polls the OpenAPI spec endpoint and generates operation hashes.
* This is used to determine which implementations of API methods to use.
*
* @returns A promise that resolves to the OpenAPI operation hashes.
* How often to retry `/version` while Headscale is unreachable.
* Defaults to 30 seconds. Exposed for tests.
*/
async function fetchAndHashOpenapi(): Promise<Record<string, string> | null> {
try {
const res = await rawFetch("/swagger/v1/openapiv2.json");
if (res.statusCode !== 200) {
log.error("api", "Failed to fetch OpenAPI spec: %d", res.statusCode);
return null;
}
retryIntervalMs?: number;
}
const body = await res.body.json();
const spec = await dereference(body as OpenAPIV2.Document);
const hashes = generateSpecHashes(spec);
log.debug("api", "OpenAPI hashes updated (%d endpoints)", Object.keys(hashes).length);
return hashes;
} catch (error) {
if (isApiError(error)) {
log.debug("api", "Failed to fetch OpenAPI spec: %d", error.statusCode);
}
const DEFAULT_RETRY_INTERVAL_MS = 30_000;
return null;
export async function createHeadscale(opts: CreateHeadscaleOptions): Promise<Headscale> {
const transport = await createTransport({ url: opts.url, certPath: opts.certPath });
const retryIntervalMs = opts.retryIntervalMs ?? DEFAULT_RETRY_INTERVAL_MS;
let version: ServerVersion = parseServerVersion("unreachable");
let capabilities: Capabilities = capabilitiesFor(version);
let detected = false;
let retryTimer: ReturnType<typeof setTimeout> | undefined;
let disposed = false;
function settle(parsed: ServerVersion) {
version = parsed;
capabilities = capabilitiesFor(parsed);
detected = true;
if (parsed.unknown) {
log.warn(
"api",
"Could not parse Headscale version %s, assuming newest known capabilities",
parsed.raw,
);
} else {
log.info("api", "Connected to Headscale %s", formatServerVersion(parsed));
}
}
const isAtleast = (version: Version): boolean => {
return isAtLeast(apiVersion, version);
};
openapiHashes = await fetchAndHashOpenapi();
apiVersion = detectApiVersion(openapiHashes);
setInterval(async () => {
const hashes = await fetchAndHashOpenapi();
if (hashes) {
openapiHashes = hashes;
apiVersion = detectApiVersion(openapiHashes);
async function detectOnce(): Promise<boolean> {
try {
const { version: raw } = await transport.getPublic<{ version: string }>("/version");
settle(parseServerVersion(raw));
return true;
} catch (error) {
// 404 means Headscale is reachable but predates 0.27.0 (where
// /version was introduced). That server is below the supported
// floor, so we don't settle — leave capabilities permissive and
// keep retrying in case the operator upgrades in place.
if (isDataWithApiError(error) && error.data.statusCode === 404) {
log.error(
"api",
"Headscale /version returned 404; Headplane requires Headscale %s or newer",
MIN_SUPPORTED_VERSION,
);
return false;
}
log.debug("api", "Headscale /version probe failed: %s", String(error));
return false;
}
}, 60_000); // every 60 seconds
}
function scheduleRetry() {
if (disposed || detected) return;
retryTimer = setTimeout(async () => {
retryTimer = undefined;
if (disposed) return;
if (await detectOnce()) return;
scheduleRetry();
}, retryIntervalMs);
// Don't keep the event loop alive on this timer alone — Headplane
// should still shut down cleanly while we're waiting to retry.
retryTimer.unref?.();
}
if (!(await detectOnce())) {
log.warn(
"api",
"Headscale unreachable at boot; defaulting to newest-known capabilities and retrying every %dms",
retryIntervalMs,
);
scheduleRetry();
}
return {
undiciAgent,
baseUrl,
openapiHashes,
apiVersion,
getRuntimeClient: (apiKey: string) => {
return endpointSets(
{
rawFetch,
apiFetch,
isAtleast,
},
apiKey,
);
// Getters so callers always observe the latest detected values
// without having to know about the retry loop.
get version() {
return version;
},
clientHelpers: {
rawFetch,
apiFetch,
isAtleast,
get capabilities() {
return capabilities;
},
health: () => transport.health(),
client(apiKey) {
return {
nodes: makeNodeApi(transport, capabilities, apiKey),
users: makeUserApi(transport, capabilities, apiKey),
policy: makePolicyApi(transport, capabilities, apiKey),
preAuthKeys: makePreAuthKeyApi(transport, capabilities, apiKey),
apiKeys: makeApiKeyApi(transport, capabilities, apiKey),
};
},
async dispose() {
disposed = true;
if (retryTimer) {
clearTimeout(retryTimer);
retryTimer = undefined;
}
await transport.dispose();
},
};
}
/**
* Creates a new Undici agent for making HTTP requests.
*
* @param certPath Optional path to a custom TLS certificate for secure connections.
* @returns A promise that resolves to an `Agent` instance.
*/
async function createUndiciAgent(certPath?: string): Promise<Agent> {
if (!certPath) {
return new Agent();
}
try {
log.debug("config", "Loading certificate from %s", certPath);
const data = await readFile(certPath, "utf8");
log.info("config", "Using certificate from %s", certPath);
return new Agent({ connect: { ca: data.trim() } });
} catch (error) {
log.error("config", "Failed to load Headscale TLS cert: %s", error);
log.debug("config", "Error Details: %o", error);
return new Agent();
}
}
function generateSpecHashes(spec: OpenAPIV2.Document) {
const hashes: Record<string, string> = {};
const seen = new Set<string>();
for (const [path, item] of Object.entries(spec.paths)) {
for (const [method, operation] of Object.entries(item)) {
if (typeof operation !== "object") {
continue;
}
const { parameters, responses } = operation as OpenAPIV2.OperationObject;
const raw = JSON.stringify(
{
path,
method: method.toUpperCase(),
parameters,
responses,
},
Object.keys({ path, method, parameters, responses }).sort(),
);
const hash = createHash("md5").update(raw).digest("hex").slice(0, 16);
const final = seen.has(hash) ? `${hash}_${seen.size}` : hash;
seen.add(final);
hashes[`${method.toUpperCase()} ${path}`] = final;
}
}
return hashes;
}
@@ -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;
},
};
}
+116
View File
@@ -0,0 +1,116 @@
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.
const qp = new URLSearchParams();
qp.append("user", user);
qp.append("key", key);
const { node } = await transport.request<{ node: RawMachine }>({
method: "POST",
path: `v1/node/register?${qp.toString()}`,
apiKey,
body: { user, key },
});
return normalize(node);
},
approveRoutes: async (id, routes) => {
await transport.request({
method: "POST",
path: `v1/node/${id}/approve_routes`,
apiKey,
body: { routes },
});
},
expire: async (id) => {
await transport.request({ method: "POST", path: `v1/node/${id}/expire`, apiKey });
},
rename: async (id, newName) => {
await transport.request({
method: "POST",
path: `v1/node/${id}/rename/${encodeURIComponent(newName)}`,
apiKey,
});
},
setTags: async (id, tags) => {
await transport.request({
method: "POST",
path: `v1/node/${id}/tags`,
apiKey,
body: { tags },
});
},
};
if (!capabilities.nodeOwnerIsImmutable) {
api.reassignUser = async (id, user) => {
await transport.request({
method: "POST",
path: `v1/node/${id}/user`,
apiKey,
body: { user },
});
};
}
return api;
}
@@ -0,0 +1,33 @@
import type { Capabilities } from "../capabilities";
import type { Transport } from "../transport";
export interface PolicyApi {
get(): Promise<{ policy: string; updatedAt: Date | null }>;
set(policy: string): Promise<{ policy: string; updatedAt: Date }>;
}
export function makePolicyApi(
transport: Transport,
_capabilities: Capabilities,
apiKey: string,
): PolicyApi {
return {
get: async () => {
const { policy, updatedAt } = await transport.request<{
policy: string;
updatedAt: string;
}>({ method: "GET", path: "v1/policy", apiKey });
return {
policy,
updatedAt: updatedAt !== null ? new Date(updatedAt) : null,
};
},
set: async (policy) => {
const { policy: newPolicy, updatedAt } = await transport.request<{
policy: string;
updatedAt: string;
}>({ method: "PUT", path: "v1/policy", apiKey, body: { policy } });
return { policy: newPolicy, updatedAt: new Date(updatedAt) };
},
};
}
@@ -0,0 +1,90 @@
import type { PreAuthKey } from "~/types";
import type { Capabilities } from "../capabilities";
import type { Transport } from "../transport";
export interface CreatePreAuthKeyOptions {
/** Owning user ID, or `null` for tag-only keys (0.28+). */
user: string | null;
ephemeral: boolean;
reusable: boolean;
expiration: Date | null;
aclTags: string[] | null;
}
export interface PreAuthKeyApi {
/**
* List every pre-auth key on the server. Only present when
* `capabilities.preAuthKeysHaveStableIds` is true (Headscale 0.28+).
* Pre-0.28 callers must use {@link listForUser}.
*/
listAll?: () => Promise<PreAuthKey[]>;
listForUser(userId: string): Promise<PreAuthKey[]>;
create(opts: CreatePreAuthKeyOptions): Promise<PreAuthKey>;
expire(key: PreAuthKey): Promise<void>;
}
export function makePreAuthKeyApi(
transport: Transport,
capabilities: Capabilities,
apiKey: string,
): PreAuthKeyApi {
const api: PreAuthKeyApi = {
listForUser: async (userId) => {
const { preAuthKeys } = await transport.request<{
preAuthKeys: PreAuthKey[];
}>({ method: "GET", path: "v1/preauthkey", apiKey, query: { user: userId } });
return preAuthKeys;
},
create: async ({ user, ephemeral, reusable, expiration, aclTags }) => {
const body: Record<string, unknown> = {
ephemeral,
reusable,
expiration: expiration ? expiration.toISOString() : null,
};
if (user) body.user = user;
if (aclTags && aclTags.length > 0) body.aclTags = aclTags;
const { preAuthKey } = await transport.request<{
preAuthKey: PreAuthKey;
}>({ method: "POST", path: "v1/preauthkey", apiKey, body });
return preAuthKey;
},
expire: async (key) => {
if (capabilities.preAuthKeysHaveStableIds) {
await transport.request({
method: "POST",
path: "v1/preauthkey/expire",
apiKey,
body: { id: key.id },
});
return;
}
// Pre-0.28: expire takes the owning user's ID (a uint64 — Headscale
// rejects names with `proto: invalid value for uint64 field user`)
// plus the key string.
await transport.request({
method: "POST",
path: "v1/preauthkey/expire",
apiKey,
body: { user: key.user?.id ?? "", key: key.key },
});
},
};
if (capabilities.preAuthKeysHaveStableIds) {
api.listAll = async () => {
const { preAuthKeys } = await transport.request<{
preAuthKeys: PreAuthKey[];
}>({ method: "GET", path: "v1/preauthkey", apiKey });
return preAuthKeys;
};
}
return api;
}
@@ -0,0 +1,66 @@
import type { User } from "~/types";
import type { Capabilities } from "../capabilities";
import type { Transport } from "../transport";
export interface CreateUserOptions {
name: string;
email?: string;
displayName?: string;
pictureUrl?: string;
}
export interface ListUsersFilter {
id?: string;
name?: string;
email?: string;
}
export interface UserApi {
list(filter?: ListUsersFilter): Promise<User[]>;
create(opts: CreateUserOptions): Promise<User>;
delete(id: string): Promise<void>;
rename(id: string, newName: string): Promise<void>;
}
export function makeUserApi(
transport: Transport,
_capabilities: Capabilities,
apiKey: string,
): UserApi {
return {
list: async (filter) => {
const { id, name, email } = filter ?? {};
const moreThanOneFilter = [id, name, email].filter((v) => v !== undefined).length > 1;
if (moreThanOneFilter) {
throw new Error("Only one of id, name, or email filters can be provided");
}
const { users } = await transport.request<{ users: User[] }>({
method: "GET",
path: "v1/user",
apiKey,
query: { id, name, email },
});
return users;
},
create: async ({ name, email, displayName, pictureUrl }) => {
const { user } = await transport.request<{ user: User }>({
method: "POST",
path: "v1/user",
apiKey,
body: { name, email, displayName, pictureUrl },
});
return user;
},
delete: async (id) => {
await transport.request({ method: "DELETE", path: `v1/user/${id}`, apiKey });
},
rename: async (id, newName) => {
await transport.request({
method: "POST",
path: `v1/user/${id}/rename/${encodeURIComponent(newName)}`,
apiKey,
});
},
};
}
@@ -0,0 +1,98 @@
// 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.-]+))?$/;
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;
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);
}
+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) {
+12 -8
View File
@@ -11,7 +11,7 @@ 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>>;
@@ -46,7 +46,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> {
@@ -101,9 +101,13 @@ export async function createAgentManager(
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;
}
@@ -272,7 +276,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 +302,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) {
+44 -2
View File
@@ -8,23 +8,65 @@
// 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;
startHttpServer({
host: config.server.host,
port: config.server.port,
tls,
listenFile,
listener: composeListener({
basename: __PREFIX__,
staticRoot: clientDir,
immutableAssets: true,
requestListener,
}),
onShutdown: dispose,
});
+18 -41
View File
@@ -77,13 +77,12 @@ export interface AuthService {
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;
@@ -370,23 +369,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 +410,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 +430,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 +450,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;
}
@@ -512,13 +490,12 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
findOrCreateUser,
linkHeadscaleUser,
unlinkHeadscaleUser,
linkHeadscaleUserBySubject,
listUsers,
claimedHeadscaleUserIds,
roleForSubject,
roleForHeadscaleUser,
transferOwnership,
reassignSubject,
reassignUser,
pruneExpiredSessions,
start,
stop,
+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
}
+16
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
@@ -58,6 +69,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`
+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",
+7
View File
@@ -86,6 +86,13 @@ To enable debug logging, set the **`HEADPLANE_DEBUG_LOG=true`** environment vari
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,
};
+4 -4
View File
@@ -18,7 +18,7 @@ with Docker.
## Prerequisites
- Docker and Docker Compose
- Headscale version 0.26.0 or later installed and running
- Headscale version 0.27.0 or later installed and running
- A [completed configuration file](./index.md#configuration) for Headplane.
## Installation
@@ -57,7 +57,7 @@ services:
headplane:
image: ghcr.io/tale/headplane:latest
healthcheck:
test: ["/bin/hp_healthcheck"]
test: ["CMD", "/bin/hp_healthcheck"]
interval: 30s
timeout: 5s
start_period: 5s
@@ -132,7 +132,7 @@ services:
# Read-only access to the Docker socket (or a proxy)
- "/var/run/docker.sock:/var/run/docker.sock:ro"
headscale:
image: headscale/headscale:0.26.0
image: headscale/headscale:0.27.1
container_name: headscale
restart: unless-stopped
command: serve
@@ -239,7 +239,7 @@ services:
- "traefik.http.routers.headplane.entrypoints=websecure"
- "traefik.http.routers.headplane.tls=true"
headscale:
image: headscale/headscale:0.26.0
image: headscale/headscale:0.27.1
container_name: headscale
restart: unless-stopped
command: serve
+1 -1
View File
@@ -19,7 +19,7 @@ advanced features, making it suitable for local testing and development.
## Prerequisites
- Docker (and optionally Docker Compose)
- Headscale version 0.26.0 or later installed and running
- Headscale version 0.27.0 or later installed and running
- A [completed configuration file](/index.md#configuration) for Headplane.
## Installation
+1 -1
View File
@@ -20,7 +20,7 @@ or prefer to avoid containers.
- A Linux-based operating system (e.g, Ubuntu, Debian, CentOS, Fedora)
- Go version 1.25.1 installed (only needed to build Headplane)
- Node.js version 22.16.x and [pnpm](https://pnpm.io/) version 10.4.x installed
- Headscale version 0.26.0 or later installed and running
- Headscale version 0.27.0 or later installed and running
- A [completed configuration file](./index.md#configuration) for Headplane.
Before building and running Headplane, ensure that the directory defined in
Generated
+3 -3
View File
@@ -40,11 +40,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1776949667,
"narHash": "sha256-GMSVw35Q+294GlrTUKlx087E31z7KurReQ1YHSKp5iw=",
"lastModified": 1779536132,
"narHash": "sha256-q+fF42iv/geEbHfgSzy3tS0FF/EyD6XTZ98E6yxiBO8=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "01fbdeef22b76df85ea168fbfe1bfd9e63681b30",
"rev": "3d8f0f3f72a6cd4d93d0ad13203f2ea1cb7e1456",
"type": "github"
},
"original": {
+1 -1
View File
@@ -33,7 +33,7 @@ in
inherit (finalAttrs) pname version src;
fetcherVersion = 3;
pnpm = pnpm_10;
hash = "sha256-NGIeboj/2kXuWsmTVl1fv4LgU1VYRdO+qSnNLVuneC8=";
hash = "sha256-msrc7poYMCAdhny0qyOUYo1Qt6FKIobtxLJ479E5O0g=";
};
buildPhase = ''
+1 -4
View File
@@ -1,6 +1,6 @@
{
"name": "headplane",
"version": "0.7.0-beta.3",
"version": "0.7.0-beta.4",
"private": true,
"type": "module",
"sideEffects": false,
@@ -15,7 +15,6 @@
"docs:dev": "vitepress dev docs",
"docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs",
"gen:hashes": "tsx tests/generate-openapi-hashes.ts",
"lint": "oxlint",
"format": "oxfmt"
},
@@ -32,7 +31,6 @@
"@kubernetes/client-node": "^1.4.0",
"@lezer/highlight": "^1.2.3",
"@react-router/node": "^7.14.0",
"@readme/openapi-parser": "^6.0.1",
"@uiw/react-codemirror": "4.25.9",
"arktype": "^2.2.0",
"clsx": "^2.1.1",
@@ -42,7 +40,6 @@
"js-yaml": "^4.1.1",
"lucide-react": "^1.8.0",
"mime": "^4.1.0",
"openapi-types": "^12.1.3",
"react": "19.2.5",
"react-codemirror-merge": "4.25.9",
"react-dom": "19.2.5",
+1 -126
View File
@@ -47,9 +47,6 @@ importers:
'@react-router/node':
specifier: ^7.14.0
version: 7.14.0(react-router@7.14.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2)
'@readme/openapi-parser':
specifier: ^6.0.1
version: 6.0.1(openapi-types@12.1.3)
'@uiw/react-codemirror':
specifier: 4.25.9
version: 4.25.9(@babel/runtime@7.29.2)(@codemirror/autocomplete@6.18.2(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.41.0)(@lezer/common@1.5.2))(@codemirror/language@6.12.3)(@codemirror/lint@6.8.2)(@codemirror/search@6.5.7)(@codemirror/state@6.6.0)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.41.0)(codemirror@6.0.1(@lezer/common@1.5.2))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
@@ -77,9 +74,6 @@ importers:
mime:
specifier: ^4.1.0
version: 4.1.0
openapi-types:
specifier: ^12.1.3
version: 12.1.3
react:
specifier: 19.2.5
version: 19.2.5
@@ -180,12 +174,6 @@ importers:
packages:
'@apidevtools/json-schema-ref-parser@14.2.1':
resolution: {integrity: sha512-HmdFw9CDYqM6B25pqGBpNeLCKvGPlIx1EbLrVL0zPvj50CJQUHyBNBw45Muk0kEIkogo1VZvOKHajdMuAzSxRg==}
engines: {node: '>= 20'}
peerDependencies:
'@types/json-schema': ^7.0.15
'@ark/schema@0.56.0':
resolution: {integrity: sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA==}
@@ -998,10 +986,6 @@ packages:
engines: {node: '>=6'}
hasBin: true
'@humanwhocodes/momoa@2.0.4':
resolution: {integrity: sha512-RE815I4arJFtt+FVeU1Tgp9/Xvecacji8w/V6XtXsWWH/wz/eNkNbhb+ny/+PlVZjV0rxQpRSQKNKE3lcktHEA==}
engines: {node: '>=10.10.0'}
'@iconify-json/simple-icons@1.2.48':
resolution: {integrity: sha512-EACOtZMoPJtERiAbX1De0asrrCtlwI27+03c9OJlYWsly9w1O5vcD8rTzh+kDPjo+K8FOVnq2Qy+h/CzljSKDA==}
@@ -1480,22 +1464,6 @@ packages:
typescript:
optional: true
'@readme/better-ajv-errors@2.4.0':
resolution: {integrity: sha512-9WODaOAKSl/mU+MYNZ2aHCrkoRSvmQ+1YkLj589OEqqjOAhbn8j7Z+ilYoiTu/he6X63/clsxxAB4qny9/dDzg==}
engines: {node: '>=18'}
peerDependencies:
ajv: 4.11.8 - 8
'@readme/openapi-parser@6.0.1':
resolution: {integrity: sha512-uMtwMPVv86Xr5Y6A+QFrxLwWW1irJp3TIz7R3Zs2WaX383MPYX5kaQemPBsaHCu58ZULd+bosNIGUoFW8KM5Ew==}
engines: {node: '>=20'}
peerDependencies:
openapi-types: '>=7'
'@readme/openapi-schemas@3.1.0':
resolution: {integrity: sha512-9FC/6ho8uFa8fV50+FPy/ngWN53jaUu4GRXlAjcxIRrzhltJnpKkBG2Tp0IDraFJeWrOpk84RJ9EMEEYzaI1Bw==}
engines: {node: '>=18'}
'@remix-run/node-fetch-server@0.13.0':
resolution: {integrity: sha512-1EsNo0ZpgXu/90AWoRZf/oE3RVTUS80tiTUpt+hv5pjtAkw7icN4WskDwz/KdAw5ARbJLMhZBrO1NqThmy/McA==}
@@ -1844,9 +1812,6 @@ packages:
'@types/js-yaml@4.0.9':
resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==}
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/linkify-it@5.0.0':
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
@@ -2116,17 +2081,6 @@ packages:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'}
ajv-draft-04@1.0.0:
resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==}
peerDependencies:
ajv: ^8.5.0
peerDependenciesMeta:
ajv:
optional: true
ajv@8.18.0:
resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==}
ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
@@ -2738,15 +2692,9 @@ packages:
exsolve@1.0.8:
resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
fast-fifo@1.3.2:
resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==}
fast-uri@3.1.0:
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@@ -2963,9 +2911,6 @@ packages:
engines: {node: '>=6'}
hasBin: true
json-schema-traverse@1.0.0:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
json5@2.2.3:
resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
engines: {node: '>=6'}
@@ -2976,10 +2921,6 @@ packages:
engines: {node: '>=18.0.0'}
hasBin: true
jsonpointer@5.0.1:
resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==}
engines: {node: '>=0.10.0'}
jsonwebtoken@9.0.3:
resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==}
engines: {node: '>=12', npm: '>=6'}
@@ -3051,10 +2992,6 @@ packages:
resolution: {integrity: sha512-yB9IFWurFllusbPZqvG0EavTmpNXPya2MuO7Li7YT78xAj3uCQ3AgmW9TVUbTTsSMhsegbiAMRpwfEk2TP1P0A==}
hasBin: true
leven@3.1.0:
resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
engines: {node: '>=6'}
libsql@0.5.29:
resolution: {integrity: sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg==}
cpu: [x64, arm64, wasm32, arm]
@@ -3322,9 +3259,6 @@ packages:
resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==}
engines: {node: '>=18'}
openapi-types@12.1.3:
resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==}
openid-client@6.8.2:
resolution: {integrity: sha512-uOvTCndr4udZsKihJ68H9bUICrriHdUVJ6Az+4Ns6cW55rwM5h0bjVIzDz2SxgOI84LKjFyjOFvERLzdTUROGA==}
@@ -3507,10 +3441,6 @@ packages:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
require-from-string@2.0.2:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
reselect@5.1.1:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
@@ -3873,6 +3803,7 @@ packages:
uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
valibot@1.3.1:
@@ -4132,11 +4063,6 @@ packages:
snapshots:
'@apidevtools/json-schema-ref-parser@14.2.1(@types/json-schema@7.0.15)':
dependencies:
'@types/json-schema': 7.0.15
js-yaml: 4.1.1
'@ark/schema@0.56.0':
dependencies:
'@ark/util': 0.56.0
@@ -4888,8 +4814,6 @@ snapshots:
protobufjs: 7.5.4
yargs: 17.7.2
'@humanwhocodes/momoa@2.0.4': {}
'@iconify-json/simple-icons@1.2.48':
dependencies:
'@iconify/types': 2.0.0
@@ -5296,28 +5220,6 @@ snapshots:
optionalDependencies:
typescript: 6.0.2
'@readme/better-ajv-errors@2.4.0(ajv@8.18.0)':
dependencies:
'@babel/code-frame': 7.29.0
'@babel/runtime': 7.29.2
'@humanwhocodes/momoa': 2.0.4
ajv: 8.18.0
jsonpointer: 5.0.1
leven: 3.1.0
picocolors: 1.1.1
'@readme/openapi-parser@6.0.1(openapi-types@12.1.3)':
dependencies:
'@apidevtools/json-schema-ref-parser': 14.2.1(@types/json-schema@7.0.15)
'@readme/better-ajv-errors': 2.4.0(ajv@8.18.0)
'@readme/openapi-schemas': 3.1.0
'@types/json-schema': 7.0.15
ajv: 8.18.0
ajv-draft-04: 1.0.0(ajv@8.18.0)
openapi-types: 12.1.3
'@readme/openapi-schemas@3.1.0': {}
'@remix-run/node-fetch-server@0.13.0': {}
'@rolldown/binding-android-arm64@1.0.0-rc.15':
@@ -5584,8 +5486,6 @@ snapshots:
'@types/js-yaml@4.0.9': {}
'@types/json-schema@7.0.15': {}
'@types/linkify-it@5.0.0': {}
'@types/markdown-it@14.1.2':
@@ -5880,17 +5780,6 @@ snapshots:
agent-base@7.1.4: {}
ajv-draft-04@1.0.0(ajv@8.18.0):
optionalDependencies:
ajv: 8.18.0
ajv@8.18.0:
dependencies:
fast-deep-equal: 3.1.3
fast-uri: 3.1.0
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
ansi-regex@5.0.1: {}
ansi-regex@6.2.2: {}
@@ -6459,12 +6348,8 @@ snapshots:
exsolve@1.0.8: {}
fast-deep-equal@3.1.3: {}
fast-fifo@1.3.2: {}
fast-uri@3.1.0: {}
fdir@6.5.0(picomatch@4.0.4):
optionalDependencies:
picomatch: 4.0.4
@@ -6673,8 +6558,6 @@ snapshots:
jsesc@3.0.2: {}
json-schema-traverse@1.0.0: {}
json5@2.2.3: {}
jsonpath-plus@10.3.0:
@@ -6683,8 +6566,6 @@ snapshots:
'@jsep-plugin/regex': 1.0.4(jsep@1.4.0)
jsep: 1.4.0
jsonpointer@5.0.1: {}
jsonwebtoken@9.0.3:
dependencies:
jws: 4.0.1
@@ -6758,8 +6639,6 @@ snapshots:
lefthook-windows-arm64: 2.1.5
lefthook-windows-x64: 2.1.5
leven@3.1.0: {}
libsql@0.5.29:
dependencies:
'@neon-rs/load': 0.0.4
@@ -7001,8 +6880,6 @@ snapshots:
is-inside-container: 1.0.0
wsl-utils: 0.1.0
openapi-types@12.1.3: {}
openid-client@6.8.2:
dependencies:
jose: 6.2.2
@@ -7256,8 +7133,6 @@ snapshots:
require-directory@2.1.1: {}
require-from-string@2.0.2: {}
reselect@5.1.1: {}
resolve-pkg-maps@1.0.0: {}
+37 -5
View File
@@ -4,7 +4,7 @@
// React Router request listener from `@react-router/node`) and serves
// static assets out of a directory.
import { createReadStream } from "node:fs";
import { stat } from "node:fs/promises";
import { stat, writeFile } from "node:fs/promises";
import {
type IncomingMessage,
type RequestListener,
@@ -172,6 +172,21 @@ export interface StartOptions {
listener: RequestListener;
tls?: HttpsServerOptions;
logger?: Logger;
/**
* If set, writes `url` to `path` once the server is accepting
* connections. Used by the bundled Docker healthcheck binary to
* discover the right scheme, port, and basename without any
* duplicate configuration. The caller assembles the URL so that
* the runtime stays generic.
*/
listenFile?: { path: string; url: string };
/**
* Optional async hook invoked on SIGINT/SIGTERM after the HTTP
* server stops accepting new connections but before the process
* exits. Use this to dispose long-lived resources (timers,
* subprocesses, DB handles).
*/
onShutdown?: () => Promise<void> | void;
}
/**
@@ -187,17 +202,34 @@ export function startHttpServer(opts: StartOptions): Server {
server.listen(opts.port, opts.host, () => {
const proto = opts.tls ? "https" : "http";
log.info("Listening on %s://%s:%s", proto, opts.host, opts.port);
if (opts.listenFile) {
const { path, url } = opts.listenFile;
writeFile(path, `${url}\n`, "utf8").catch((err) => {
log.error("Failed to write listen file %s: %s", path, err);
});
}
});
const shutdown = (signal: string) => {
const shutdown = async (signal: string) => {
log.info("Received %s, shutting down...", signal);
server.close(() => process.exit(0));
server.close(async () => {
if (opts.onShutdown) {
try {
await opts.onShutdown();
} catch (err) {
log.error("Error during shutdown hook: %o", err);
}
}
process.exit(0);
});
// Force exit if connections don't drain in time.
setTimeout(() => process.exit(0), 5_000).unref();
};
process.once("SIGINT", () => shutdown("SIGINT"));
process.once("SIGTERM", () => shutdown("SIGTERM"));
process.once("SIGINT", () => void shutdown("SIGINT"));
process.once("SIGTERM", () => void shutdown("SIGTERM"));
return server;
}
+20 -3
View File
@@ -18,6 +18,11 @@ export interface DevServerOptions {
publicDir: string;
}
interface AppModule {
default: RequestListener;
dispose?: () => Promise<void> | void;
}
export function headplaneDevServer(options: DevServerOptions): Plugin {
return {
name: "headplane:dev-server",
@@ -30,6 +35,12 @@ export function headplaneDevServer(options: DevServerOptions): Plugin {
res.end("Server entry not loaded yet");
};
// Track the last-loaded module identity so we can call its
// `dispose` when Vite swaps in a new one on HMR. Without this
// the LiveStore and other long-lived services leak intervals
// and subprocesses on every reload.
let currentModule: AppModule | null = null;
const composed = composeListener({
basename: options.basename,
staticRoot: options.publicDir,
@@ -43,9 +54,15 @@ export function headplaneDevServer(options: DevServerOptions): Plugin {
return () => {
server.middlewares.use(async (req, res, next) => {
try {
const mod = (await server.ssrLoadModule(options.entry)) as {
default: RequestListener;
};
const mod = (await server.ssrLoadModule(options.entry)) as AppModule;
if (currentModule && currentModule !== mod) {
try {
await currentModule.dispose?.();
} catch (err) {
server.config.logger.warn(`[headplane:dev-server] dispose failed: ${String(err)}`);
}
}
currentModule = mod;
appListener = mod.default;
composed(req, res);
} catch (err) {
-101
View File
@@ -1,101 +0,0 @@
import { writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { cwd } from "node:process";
import type { OpenAPIV2 } from "openapi-types";
import { request } from "undici";
import { hashOpenApiDocument } from "~/server/headscale/api/hasher";
const HASH_FILE_LOCATION = "app/openapi-operation-hashes.json";
const CANONICAL_LOCATION = "app/openapi-canonical-families.json";
const SPEC_MAP = {
// '0.25.0': '/v0.25.0/gen/openapiv2/headscale/v1/headscale.swagger.json',
// '0.25.1': '/v0.25.1/gen/openapiv2/headscale/v1/headscale.swagger.json',
"0.26.0": "/v0.26.0/gen/openapiv2/headscale/v1/headscale.swagger.json",
"0.26.1": "/v0.26.1/gen/openapiv2/headscale/v1/headscale.swagger.json",
"0.27.0": "/v0.27.0/gen/openapiv2/headscale/v1/headscale.swagger.json",
"0.27.1": "/v0.27.1/gen/openapiv2/headscale/v1/headscale.swagger.json",
"0.28.0": "/v0.28.0/gen/openapiv2/headscale/v1/headscale.swagger.json",
} as const;
async function hashOpenApiOperations(specUrl: string) {
const url = `https://raw.githubusercontent.com/juanfont/headscale${specUrl}`;
const res = await request(url);
if (res.statusCode !== 200) {
console.error("Failed to fetch OpenAPI spec:", res.statusCode);
process.exit(1);
}
const body = (await res.body.json()) as OpenAPIV2.Document;
return hashOpenApiDocument(body);
}
async function collectCanonicalizedFamilies(
newHashes: readonly (readonly [string, Record<string, string>])[],
) {
const canonicalizedFamilies: Record<string, string[]> = {};
for (const [version, hashes] of newHashes) {
const signature = JSON.stringify(hashes);
let canonical: string | null = null;
for (const existingCanonical of Object.keys(canonicalizedFamilies)) {
const existingSignature = JSON.stringify(
newHashes.find(([v]) => v === existingCanonical)![1],
);
if (existingSignature === signature) {
canonical = existingCanonical;
break;
}
}
if (!canonical) {
canonicalizedFamilies[version] = [version];
continue;
}
canonicalizedFamilies[canonical].push(version);
if (
version.localeCompare(canonical, undefined, {
numeric: true,
sensitivity: "base",
}) > 0
) {
canonicalizedFamilies[version] = canonicalizedFamilies[canonical];
delete canonicalizedFamilies[canonical];
}
}
for (const [canonical, family] of Object.entries(canonicalizedFamilies)) {
canonicalizedFamilies[canonical] = family.sort((a, b) =>
a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }),
);
}
return canonicalizedFamilies;
}
async function writeHashes(hashes: Record<string, Record<string, string>>) {
const path = resolve(cwd(), HASH_FILE_LOCATION);
await writeFile(path, `${JSON.stringify(hashes, null, 2)}\n`, "utf-8");
}
async function writeCanonicalizedFamilies(families: Record<string, string[]>) {
const path = resolve(cwd(), CANONICAL_LOCATION);
await writeFile(path, `${JSON.stringify(families, null, 2)}\n`, "utf-8");
}
const newHashes = await Promise.all(
Object.entries(SPEC_MAP).map(async ([version, specUrl]) => {
const hashes = await hashOpenApiOperations(specUrl);
return [version, hashes] as const;
}),
);
const canonicalizedFamilies = await collectCanonicalizedFamilies(newHashes);
console.log("Writing new OpenAPI operation hashes to file");
await writeHashes(Object.fromEntries(newHashes));
await writeCanonicalizedFamilies(canonicalizedFamilies);
+1 -1
View File
@@ -5,7 +5,7 @@ import { getRuntimeClient, HS_VERSIONS } from "../setup/env";
describe.for(HS_VERSIONS)("Headscale %s: API Keys", (version) => {
test("api keys can be fetched", async () => {
const client = await getRuntimeClient(version);
const apiKeys = await client.getApiKeys();
const apiKeys = await client.apiKeys.list();
expect(Array.isArray(apiKeys)).toBe(true);
expect(apiKeys.length).toBe(1);
});
+17 -15
View File
@@ -9,8 +9,8 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Users", (version) => {
const client = await getRuntimeClient(version);
const tailnetNode = await getNode(version);
const user = await client.createUser("node-reg@");
const node = await client.registerNode(user.name, tailnetNode.authCode);
const user = await client.users.create({ name: "node-reg@" });
const node = await client.nodes.register(user.name, tailnetNode.authCode);
expect(node).toBeDefined();
expect(node.registerMethod).toBe("REGISTER_METHOD_CLI");
expect(node.name).toBe(tailnetNode.nodeName);
@@ -19,12 +19,12 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Users", (version) => {
test("nodes can be retrieved", async () => {
const client = await getRuntimeClient(version);
const { nodeName } = await getNode(version);
const nodes = await client.getNodes();
const nodes = await client.nodes.list();
const node = nodes.find((n) => n.name === nodeName);
expect(node).toBeDefined();
expect(node?.name).toBe(nodeName);
const fetchedNode = await client.getNode(node!.id);
const fetchedNode = await client.nodes.get(node!.id);
expect(fetchedNode).toBeDefined();
expect(fetchedNode.id).toBe(node!.id);
workingNodeId = node!.id;
@@ -35,41 +35,43 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Users", (version) => {
const { nodeName } = await getNode(version);
const newName = `${nodeName}-renamed`;
await client.renameNode(workingNodeId, newName);
const renamedNode = await client.getNode(workingNodeId);
await client.nodes.rename(workingNodeId, newName);
const renamedNode = await client.nodes.get(workingNodeId);
expect(renamedNode).toBeDefined();
expect(renamedNode.givenName).toBe(newName);
});
test("nodes can be reassigned to another user", async (context) => {
const bootstrap = await getBootstrapClient(version);
if (bootstrap.clientHelpers.isAtleast("0.28.0")) {
// Reassigning a node owner was removed in 0.28.
if (bootstrap.capabilities.nodeOwnerIsImmutable) {
context.skip();
}
const client = await getRuntimeClient(version);
const user = await client.createUser("node-reassign@");
const user = await client.users.create({ name: "node-reassign@" });
await client.setNodeUser(workingNodeId, user.id);
const reassignedNode = await client.getNode(workingNodeId);
// reassignUser is only defined on pre-0.28 clients, hence the guard above.
await client.nodes.reassignUser!(workingNodeId, user.id);
const reassignedNode = await client.nodes.get(workingNodeId);
expect(reassignedNode).toBeDefined();
expect(reassignedNode.user.name).toBe(user.name);
expect(reassignedNode.user?.name).toBe(user.name);
});
test("nodes can be expired", async () => {
const client = await getRuntimeClient(version);
await client.expireNode(workingNodeId);
await client.nodes.expire(workingNodeId);
const expiredNode = await client.getNode(workingNodeId);
const expiredNode = await client.nodes.get(workingNodeId);
expect(expiredNode).toBeDefined();
expect(expiredNode.expiry).toBeDefined();
});
test("nodes can be deleted", async () => {
const client = await getRuntimeClient(version);
await client.deleteNode(workingNodeId);
await client.nodes.delete(workingNodeId);
const nodes = await client.getNodes();
const nodes = await client.nodes.list();
const node = nodes.find((n) => n.id === workingNodeId);
expect(node).toBeUndefined();
});
+40 -22
View File
@@ -1,16 +1,22 @@
import { describe, expect, test } from "vitest";
import { getBootstrapClient, getIsAtLeast, getRuntimeClient, HS_VERSIONS } from "../setup/env";
import { getBootstrapClient, getRuntimeClient, HS_VERSIONS } from "../setup/env";
describe.sequential.for(HS_VERSIONS)("Headscale %s: Pre-auth Keys", (version) => {
test("pre-auth keys can be created", async () => {
const client = await getRuntimeClient(version);
const preAuthKeyUser = await client.createUser("preauthkeyuser@");
const preAuthKeyUser = await client.users.create({ name: "preauthkeyuser@" });
expect(preAuthKeyUser).toBeDefined();
expect(preAuthKeyUser.name).toBe("preauthkeyuser@");
const expiry = new Date(Date.now() + 3600 * 1000);
const preAuthKey = await client.createPreAuthKey(preAuthKeyUser.id, false, false, expiry, null);
const preAuthKey = await client.preAuthKeys.create({
user: preAuthKeyUser.id,
ephemeral: false,
reusable: false,
expiration: expiry,
aclTags: null,
});
expect(preAuthKey).toBeDefined();
expect(preAuthKey.user?.id).toBe(preAuthKeyUser.id);
@@ -22,12 +28,18 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Pre-auth Keys", (version) =>
test("pre-auth keys can be created with ACL tags", async () => {
const client = await getRuntimeClient(version);
const [preAuthKeyUser] = await client.getUsers(undefined, "preauthkeyuser@");
const [preAuthKeyUser] = await client.users.list({ name: "preauthkeyuser@" });
expect(preAuthKeyUser).toBeDefined();
expect(preAuthKeyUser.name).toBe("preauthkeyuser@");
const aclTags = ["tag:test1", "tag:test2"];
const preAuthKey = await client.createPreAuthKey(preAuthKeyUser.id, true, true, null, aclTags);
const preAuthKey = await client.preAuthKeys.create({
user: preAuthKeyUser.id,
ephemeral: true,
reusable: true,
expiration: null,
aclTags,
});
expect(preAuthKey).toBeDefined();
expect(preAuthKey.user?.id).toBe(preAuthKeyUser.id);
@@ -38,13 +50,19 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Pre-auth Keys", (version) =>
test("tag-only pre-auth keys (0.28+)", async (context) => {
const bootstrap = await getBootstrapClient(version);
if (!bootstrap.clientHelpers.isAtleast("0.28.0")) {
if (!bootstrap.capabilities.preAuthKeysHaveStableIds) {
context.skip();
}
const client = await getRuntimeClient(version);
const aclTags = ["tag:server", "tag:prod"];
const preAuthKey = await client.createPreAuthKey(null, false, true, null, aclTags);
const preAuthKey = await client.preAuthKeys.create({
user: null,
ephemeral: false,
reusable: true,
expiration: null,
aclTags,
});
expect(preAuthKey).toBeDefined();
expect(preAuthKey.user).toBeNull();
@@ -55,44 +73,44 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Pre-auth Keys", (version) =>
test("pre-auth keys can be listed", async () => {
const client = await getRuntimeClient(version);
const [preAuthKeyUser] = await client.getUsers(undefined, "preauthkeyuser@");
const [preAuthKeyUser] = await client.users.list({ name: "preauthkeyuser@" });
expect(preAuthKeyUser).toBeDefined();
expect(preAuthKeyUser.name).toBe("preauthkeyuser@");
const preAuthKeys = await client.getPreAuthKeys(preAuthKeyUser.id);
const preAuthKeys = await client.preAuthKeys.listForUser(preAuthKeyUser.id);
expect(Array.isArray(preAuthKeys)).toBe(true);
expect(preAuthKeys.length).toBeGreaterThanOrEqual(2);
});
test("all pre-auth keys can be listed without user filter (0.28+)", async (context) => {
const isAtLeast = await getIsAtLeast(version);
if (!isAtLeast("0.28.0")) {
const bootstrap = await getBootstrapClient(version);
if (!bootstrap.capabilities.preAuthKeysHaveStableIds) {
context.skip();
}
const client = await getRuntimeClient(version);
const [preAuthKeyUser] = await client.getUsers(undefined, "preauthkeyuser@");
const [preAuthKeyUser] = await client.users.list({ name: "preauthkeyuser@" });
expect(preAuthKeyUser).toBeDefined();
const allKeys = await client.getAllPreAuthKeys();
const allKeys = await client.preAuthKeys.listAll!();
expect(Array.isArray(allKeys)).toBe(true);
expect(allKeys.length).toBeGreaterThanOrEqual(2);
const userSpecificKeys = await client.getPreAuthKeys(preAuthKeyUser.id);
const userSpecificKeys = await client.preAuthKeys.listForUser(preAuthKeyUser.id);
for (const userKey of userSpecificKeys) {
const found = allKeys.find((k) => k.key === userKey.key);
expect(found).toBeDefined();
}
});
test("getAllPreAuthKeys returns keys with correct structure (0.28+)", async (context) => {
const isAtLeast = await getIsAtLeast(version);
if (!isAtLeast("0.28.0")) {
test("listAll returns keys with correct structure (0.28+)", async (context) => {
const bootstrap = await getBootstrapClient(version);
if (!bootstrap.capabilities.preAuthKeysHaveStableIds) {
context.skip();
}
const client = await getRuntimeClient(version);
const allKeys = await client.getAllPreAuthKeys();
const allKeys = await client.preAuthKeys.listAll!();
for (const key of allKeys) {
expect(key.id).toBeDefined();
@@ -107,16 +125,16 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Pre-auth Keys", (version) =>
test("pre-auth keys can be expired", async () => {
const client = await getRuntimeClient(version);
const [preAuthKeyUser] = await client.getUsers(undefined, "preauthkeyuser@");
const [preAuthKeyUser] = await client.users.list({ name: "preauthkeyuser@" });
expect(preAuthKeyUser).toBeDefined();
expect(preAuthKeyUser.name).toBe("preauthkeyuser@");
const preAuthKeys = await client.getPreAuthKeys(preAuthKeyUser.id);
const preAuthKeys = await client.preAuthKeys.listForUser(preAuthKeyUser.id);
expect(preAuthKeys.length).toBeGreaterThanOrEqual(2);
const preAuthKeyToExpire = preAuthKeys[0];
await client.expirePreAuthKey(preAuthKeyUser.id, preAuthKeyToExpire);
await client.preAuthKeys.expire(preAuthKeyToExpire);
const preAuthKeysAfterExpire = await client.getPreAuthKeys(preAuthKeyUser.id);
const preAuthKeysAfterExpire = await client.preAuthKeys.listForUser(preAuthKeyUser.id);
const expiredKey = preAuthKeysAfterExpire.find((key) => key.key === preAuthKeyToExpire.key);
expect(expiredKey).toBeDefined();
});
+16 -16
View File
@@ -5,19 +5,19 @@ import { getRuntimeClient, HS_VERSIONS } from "../setup/env";
describe.sequential.for(HS_VERSIONS)("Headscale %s: Users", (version) => {
test("users can be created", async () => {
const client = await getRuntimeClient(version);
const user = await client.createUser("tale@");
const user = await client.users.create({ name: "tale@" });
expect(user).toBeDefined();
expect(user.name).toBe("tale@");
});
test("users can be created with attributes", async () => {
const client = await getRuntimeClient(version);
const user = await client.createUser(
"test-user@",
"test-user@example.com",
"Test User",
"https://github.com/tale.png",
);
const user = await client.users.create({
name: "test-user@",
email: "test-user@example.com",
displayName: "Test User",
pictureUrl: "https://github.com/tale.png",
});
expect(user).toBeDefined();
expect(user.name).toBe("test-user@");
@@ -28,14 +28,14 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Users", (version) => {
test("users can be listed", async () => {
const client = await getRuntimeClient(version);
const users = await client.getUsers();
const users = await client.users.list();
expect(Array.isArray(users)).toBe(true);
expect(users.length).toBeGreaterThanOrEqual(2);
});
test("users can be listed by name", async () => {
const client = await getRuntimeClient(version);
const users = await client.getUsers(undefined, "tale@");
const users = await client.users.list({ name: "tale@" });
expect(Array.isArray(users)).toBe(true);
expect(users.length).toBe(1);
expect(users[0].name).toBe("tale@");
@@ -43,7 +43,7 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Users", (version) => {
test("users can be listed by email", async () => {
const client = await getRuntimeClient(version);
const users = await client.getUsers(undefined, undefined, "test-user@example.com");
const users = await client.users.list({ email: "test-user@example.com" });
expect(Array.isArray(users)).toBe(true);
expect(users.length).toBe(1);
expect(users[0].email).toBe("test-user@example.com");
@@ -51,24 +51,24 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Users", (version) => {
test("users can be renamed", async () => {
const client = await getRuntimeClient(version);
const usersBefore = await client.getUsers(undefined, "tale@");
const usersBefore = await client.users.list({ name: "tale@" });
expect(usersBefore.length).toBe(1);
const user = usersBefore[0];
await client.renameUser(user.id, "renamed-user@");
const usersAfter = await client.getUsers(undefined, "renamed-user@");
await client.users.rename(user.id, "renamed-user@");
const usersAfter = await client.users.list({ name: "renamed-user@" });
expect(usersAfter.length).toBe(1);
expect(usersAfter[0].id).toBe(user.id);
});
test("users can be deleted", async () => {
const client = await getRuntimeClient(version);
const usersBefore = await client.getUsers(undefined, "test-user@");
const usersBefore = await client.users.list({ name: "test-user@" });
expect(usersBefore.length).toBe(1);
const user = usersBefore[0];
await client.deleteUser(user.id);
const usersAfter = await client.getUsers(undefined, "test-user@");
await client.users.delete(user.id);
const usersAfter = await client.users.list({ name: "test-user@" });
expect(usersAfter.length).toBe(0);
});
});
+23 -19
View File
@@ -1,36 +1,40 @@
import { describe, expect, test } from "vitest";
import canonicals from "~/openapi-canonical-families.json";
import { gte } from "~/server/headscale/api/server-version";
import { getBootstrapClient, getRuntimeClient, HS_VERSIONS, Version } from "../setup/env";
function getCanonicalVersion(version: Version) {
const canonical = Object.entries(canonicals).find(([_, family]) =>
family.includes(version),
)?.[0] as Version | undefined;
if (!canonical) {
return version;
}
return canonical;
}
import { getBootstrapClient, HS_VERSIONS, Version } from "../setup/env";
describe.for(HS_VERSIONS)("Headscale %s: Runtime Client", (version) => {
test("the runtime client is usable", async () => {
const bootstrapper = await getBootstrapClient(version);
const runtimeClient = bootstrapper.getRuntimeClient("test-api-key");
const runtimeClient = bootstrapper.client("test-api-key");
expect(runtimeClient).toBeDefined();
});
test("the runtime client has the correct canonical API version", async () => {
test("the server version reported by /version matches the running container", async () => {
const bootstrapper = await getBootstrapClient(version);
expect(bootstrapper.apiVersion).toBe(getCanonicalVersion(version));
expect(bootstrapper.version.unknown).toBe(false);
const reported = `${bootstrapper.version.major}.${bootstrapper.version.minor}.${bootstrapper.version.patch}`;
expect(reported).toBe(version);
});
test("capabilities are derived correctly from the detected version", async (context) => {
const bootstrapper = await getBootstrapClient(version);
const v = bootstrapper.version;
expect(bootstrapper.capabilities.preAuthKeysHaveStableIds).toBe(gte(v, "0.28.0"));
expect(bootstrapper.capabilities.nodeTagsAreFlat).toBe(gte(v, "0.28.0"));
expect(bootstrapper.capabilities.nodeOwnerIsImmutable).toBe(gte(v, "0.28.0"));
// If a future version is added to HS_VERSIONS before this test is
// updated, surface that explicitly rather than passing silently.
const known: Version[] = ["0.27.0", "0.27.1", "0.28.0"];
if (!known.includes(version)) {
context.skip();
}
});
test("the health check endpoint works", async () => {
const client = await getRuntimeClient(version);
const health = await client.isHealthy();
const bootstrapper = await getBootstrapClient(version);
const health = await bootstrapper.health();
expect(health).toBe(true);
});
});
+4 -4
View File
@@ -81,8 +81,8 @@ describe("DockerIntegration", () => {
socketPath: "/var/run/docker.sock",
});
const mockClient = {
isHealthy: async () => {
const mockHeadscale = {
health: async () => {
try {
const res = await dockerClient.request({
method: "GET",
@@ -96,9 +96,9 @@ describe("DockerIntegration", () => {
},
} as any;
await integration.onConfigChange(mockClient);
await integration.onConfigChange(mockHeadscale);
const healthy = await mockClient.isHealthy();
const healthy = await mockHeadscale.health();
expect(healthy).toBe(true);
}, 60_000);
});
+12 -13
View File
@@ -1,16 +1,18 @@
import hashes from "~/openapi-operation-hashes.json";
import { createHeadscaleInterface, type HeadscaleApiInterface } from "~/server/headscale/api";
import { createHeadscale, type Headscale } from "~/server/headscale/api";
import { type HeadscaleEnv, startHeadscale } from "./start-headscale";
import { startTailscaleNode, TailscaleNodeEnv } from "./start-tailscale";
export type Version = keyof typeof hashes;
export const HS_VERSIONS = Object.keys(hashes) as Version[];
// The set of Headscale versions integration tests run against. Listed
// explicitly (rather than derived from a generated manifest) so the
// supported version matrix lives next to the code that uses it.
export const HS_VERSIONS = ["0.27.0", "0.27.1", "0.28.0"] as const;
export type Version = (typeof HS_VERSIONS)[number];
interface VersionStateEntry {
env: HeadscaleEnv;
tailscaleNode: TailscaleNodeEnv;
bootstrap: HeadscaleApiInterface;
bootstrap: Headscale;
}
const versionState = new Map<Version, VersionStateEntry>();
@@ -21,7 +23,7 @@ async function ensureVersion(version: Version) {
const env = await startHeadscale(version);
const tailscaleNode = await startTailscaleNode(version, env.container.getMappedPort(8080));
const bootstrap = await createHeadscaleInterface(env.apiUrl);
const bootstrap = await createHeadscale({ url: env.apiUrl });
const entry = { env, tailscaleNode, bootstrap };
versionState.set(version, entry);
@@ -35,12 +37,7 @@ export async function getBootstrapClient(version: Version) {
export async function getRuntimeClient(version: Version) {
const { env, bootstrap } = await ensureVersion(version);
return bootstrap.getRuntimeClient(env.apiKey);
}
export async function getIsAtLeast(version: Version) {
const { env, bootstrap } = await ensureVersion(version);
return bootstrap.clientHelpers.isAtleast;
return bootstrap.client(env.apiKey);
}
export async function getNode(version: Version) {
@@ -52,7 +49,9 @@ export async function getNode(version: Version) {
}
export async function stopAllVersions() {
for (const { env, tailscaleNode } of versionState.values()) {
for (const { env, tailscaleNode, bootstrap } of versionState.values()) {
await bootstrap.dispose();
await env.container.stop({
remove: true,
removeVolumes: true,
+3 -3
View File
@@ -2,9 +2,9 @@ import { createInterface } from "node:readline";
import tc from "testcontainers";
import hashes from "~/openapi-operation-hashes.json";
export type Version = keyof typeof hashes;
// Imported by `env.ts` which owns the canonical version list; keep this
// loose to avoid an import cycle.
export type Version = string;
export interface TailscaleNodeEnv {
container: tc.StartedTestContainer;
+18 -27
View File
@@ -78,15 +78,9 @@ describe("linkHeadscaleUser", () => {
const user = users.find((u) => u.id === userId);
expect(user?.headscale_user_id).toBeNull();
});
test("linkHeadscaleUserBySubject works through subject lookup", async () => {
await auth.findOrCreateUser("sub-1");
const result = await auth.linkHeadscaleUserBySubject("sub-1", "hs-1");
expect(result).toBe(true);
});
});
describe("reassignSubject", () => {
describe("reassignUser", () => {
let auth: AuthService;
beforeEach(() => {
@@ -95,9 +89,9 @@ describe("reassignSubject", () => {
test("updates an existing non-owner role", async () => {
await auth.findOrCreateUser("sub-owner");
await auth.findOrCreateUser("sub-user");
const userId = await auth.findOrCreateUser("sub-user");
const result = await auth.reassignSubject("sub-user", "admin");
const result = await auth.reassignUser(userId, "admin");
expect(result).toBe(true);
const role = await auth.roleForSubject("sub-user");
@@ -105,18 +99,15 @@ describe("reassignSubject", () => {
});
test("returns false for owner demotion attempt", async () => {
await auth.findOrCreateUser("sub-owner");
const ownerId = await auth.findOrCreateUser("sub-owner");
const result = await auth.reassignSubject("sub-owner", "member");
const result = await auth.reassignUser(ownerId, "member");
expect(result).toBe(false);
});
test("creates user with role if subject doesn't exist yet (upsert behavior)", async () => {
const result = await auth.reassignSubject("sub-new", "auditor");
expect(result).toBe(true);
const role = await auth.roleForSubject("sub-new");
expect(role).toBe("auditor");
test("returns false when user does not exist", async () => {
const result = await auth.reassignUser("01ARZ3NDEKTSV4RRFFQ69G5FAV", "auditor");
expect(result).toBe(false);
});
});
@@ -128,10 +119,10 @@ describe("transferOwnership", () => {
});
test("swaps roles: owner→admin, target→owner", async () => {
await auth.findOrCreateUser("sub-owner");
await auth.findOrCreateUser("sub-target");
const ownerId = await auth.findOrCreateUser("sub-owner");
const targetId = await auth.findOrCreateUser("sub-target");
const result = await auth.transferOwnership("sub-owner", "sub-target");
const result = await auth.transferOwnership(ownerId, targetId);
expect(result).toBe(true);
expect(await auth.roleForSubject("sub-owner")).toBe("admin");
@@ -139,24 +130,24 @@ describe("transferOwnership", () => {
});
test("returns false if caller is not owner", async () => {
await auth.findOrCreateUser("sub-owner");
await auth.findOrCreateUser("sub-other");
const ownerId = await auth.findOrCreateUser("sub-owner");
const otherId = await auth.findOrCreateUser("sub-other");
const result = await auth.transferOwnership("sub-other", "sub-owner");
const result = await auth.transferOwnership(otherId, ownerId);
expect(result).toBe(false);
});
test("returns false if target doesn't exist", async () => {
await auth.findOrCreateUser("sub-owner");
const ownerId = await auth.findOrCreateUser("sub-owner");
const result = await auth.transferOwnership("sub-owner", "sub-ghost");
const result = await auth.transferOwnership(ownerId, "01ARZ3NDEKTSV4RRFFQ69G5FAV");
expect(result).toBe(false);
});
test("returns false if target is same user as owner", async () => {
await auth.findOrCreateUser("sub-owner");
const ownerId = await auth.findOrCreateUser("sub-owner");
const result = await auth.transferOwnership("sub-owner", "sub-owner");
const result = await auth.transferOwnership(ownerId, ownerId);
expect(result).toBe(false);
});
});
+10 -10
View File
@@ -43,7 +43,7 @@ describe("Login action validation", () => {
const request = mockRequest(formData);
const mockContext = {
hsApi: { getRuntimeClient: vi.fn() },
headscale: { client: vi.fn() },
sessions: { createSession: vi.fn() },
};
@@ -63,7 +63,7 @@ describe("Login action validation", () => {
const request = mockRequest(formData);
const mockContext = {
hsApi: { getRuntimeClient: vi.fn() },
headscale: { client: vi.fn() },
sessions: { createSession: vi.fn() },
};
@@ -87,8 +87,8 @@ describe("Login action validation", () => {
.mockResolvedValue([{ prefix: "other-prefix", expiration: "2030-01-01T00:00:00Z" }]);
const mockContext = {
hsApi: {
getRuntimeClient: () => ({ getApiKeys: mockGetApiKeys }),
headscale: {
client: () => ({ apiKeys: { list: mockGetApiKeys } }),
},
sessions: { createSession: vi.fn() },
};
@@ -114,8 +114,8 @@ describe("Login action validation", () => {
.mockResolvedValue([{ prefix: "expired-key-prefix", expiration: "2020-01-01T00:00:00Z" }]);
const mockContext = {
hsApi: {
getRuntimeClient: () => ({ getApiKeys: mockGetApiKeys }),
headscale: {
client: () => ({ apiKeys: { list: mockGetApiKeys } }),
},
sessions: { createSession: vi.fn() },
};
@@ -141,8 +141,8 @@ describe("Login action validation", () => {
.mockResolvedValue([{ prefix: "malformed-key", expiration: null } as MockApiKey]);
const mockContext = {
hsApi: {
getRuntimeClient: () => ({ getApiKeys: mockGetApiKeys }),
headscale: {
client: () => ({ apiKeys: { list: mockGetApiKeys } }),
},
sessions: { createSession: vi.fn() },
};
@@ -173,8 +173,8 @@ describe("Login action validation", () => {
const mockCreateSession = vi.fn().mockResolvedValue("session-cookie");
const mockContext = {
hsApi: {
getRuntimeClient: () => ({ getApiKeys: mockGetApiKeys }),
headscale: {
client: () => ({ apiKeys: { list: mockGetApiKeys } }),
},
sessions: { createSession: mockCreateSession },
};
+30 -36
View File
@@ -1,65 +1,59 @@
import { describe, expect, test } from "vitest";
import type { HeadscaleApiInterface } from "~/server/headscale/api";
import nodeEndpoints from "~/server/headscale/api/endpoints/nodes";
import userEndpoints from "~/server/headscale/api/endpoints/users";
import { capabilitiesFor } from "~/server/headscale/api/capabilities";
import { makeNodeApi } from "~/server/headscale/api/resources/nodes";
import { makeUserApi } from "~/server/headscale/api/resources/users";
import { parseServerVersion } from "~/server/headscale/api/server-version";
import type { Transport, TransportRequest } from "~/server/headscale/api/transport";
type ApiFetchArgs = Parameters<HeadscaleApiInterface["clientHelpers"]["apiFetch"]>;
function createApiClientRecorder() {
const calls: Array<{
method: ApiFetchArgs[0];
apiPath: ApiFetchArgs[1];
apiKey: ApiFetchArgs[2];
bodyOrQuery: ApiFetchArgs[3];
}> = [];
const client = {
isAtleast: () => false,
rawFetch: async () => {
throw new Error("rawFetch should not be called");
},
apiFetch: async <T>(
method: ApiFetchArgs[0],
apiPath: ApiFetchArgs[1],
apiKey: ApiFetchArgs[2],
bodyOrQuery?: ApiFetchArgs[3],
) => {
calls.push({ method, apiPath, apiKey, bodyOrQuery });
function createTransportRecorder() {
const calls: TransportRequest[] = [];
const transport: Transport = {
async request<T>(opts: TransportRequest): Promise<T> {
calls.push(opts);
return undefined as T;
},
} as HeadscaleApiInterface["clientHelpers"];
return { calls, client };
async getPublic<T>(): Promise<T> {
throw new Error("getPublic should not be called");
},
async health() {
throw new Error("health should not be called");
},
async dispose() {},
};
return { calls, transport };
}
const capabilities = capabilitiesFor(parseServerVersion("0.28.0"));
describe("Headscale API path encoding", () => {
test("encodes node rename names as a single URL segment", async () => {
const { calls, client } = createApiClientRecorder();
const { calls, transport } = createTransportRecorder();
await nodeEndpoints(client, "api-key").renameNode("2", "../../1/expire");
await makeNodeApi(transport, capabilities, "api-key").rename("2", "../../1/expire");
expect(calls).toEqual([
{
method: "POST",
apiPath: "v1/node/2/rename/..%2F..%2F1%2Fexpire",
path: "v1/node/2/rename/..%2F..%2F1%2Fexpire",
apiKey: "api-key",
bodyOrQuery: undefined,
},
]);
});
test("encodes user rename names as a single URL segment", async () => {
const { calls, client } = createApiClientRecorder();
const { calls, transport } = createTransportRecorder();
await userEndpoints(client, "api-key").renameUser("3", "../../../user/4/rename/pwned-user@");
await makeUserApi(transport, capabilities, "api-key").rename(
"3",
"../../../user/4/rename/pwned-user@",
);
expect(calls).toEqual([
{
method: "POST",
apiPath: "v1/user/3/rename/..%2F..%2F..%2Fuser%2F4%2Frename%2Fpwned-user%40",
path: "v1/user/3/rename/..%2F..%2F..%2Fuser%2F4%2Frename%2Fpwned-user%40",
apiKey: "api-key",
bodyOrQuery: undefined,
},
]);
});
@@ -0,0 +1,140 @@
// MARK: createHeadscale resilience
//
// These tests exercise the boot-time behaviour where Headscale may be
// unreachable. They use a Node http server to stand in for Headscale so
// we control whether `/version` resolves at boot, after a retry, or not
// at all.
import { createServer, type Server } from "node:http";
import { AddressInfo } from "node:net";
import { afterEach, describe, expect, test, vi } from "vitest";
import { createHeadscale } from "~/server/headscale/api";
let servers: Server[] = [];
afterEach(async () => {
await Promise.all(
servers.map(
(s) =>
new Promise<void>((resolve) => {
s.close(() => resolve());
}),
),
);
servers = [];
vi.useRealTimers();
});
function startVersionServer(handler: (req: Request) => Response): Promise<{
url: string;
callCount: () => number;
setResponse: (handler: (req: Request) => Response) => void;
}> {
let current = handler;
let count = 0;
const server = createServer((req, res) => {
count++;
const fullUrl = `http://${req.headers.host}${req.url}`;
const request = new Request(fullUrl, { method: req.method });
const response = current(request);
res.writeHead(response.status, Object.fromEntries(response.headers));
response.text().then((body) => res.end(body));
});
servers.push(server);
return new Promise((resolve) => {
server.listen(0, "127.0.0.1", () => {
const port = (server.address() as AddressInfo).port;
resolve({
url: `http://127.0.0.1:${port}`,
callCount: () => count,
setResponse: (h) => {
current = h;
},
});
});
});
}
describe("createHeadscale boot-time resilience", () => {
test("detects version when Headscale is reachable at boot", async () => {
const { url } = await startVersionServer(() =>
Response.json({ version: "v0.28.0", commit: "abc", buildTime: "", go: "", dirty: false }),
);
const headscale = await createHeadscale({ url });
expect(headscale.version.raw).toBe("v0.28.0");
expect(headscale.version.unknown).toBe(false);
expect(headscale.capabilities.preAuthKeysHaveStableIds).toBe(true);
await headscale.dispose();
});
test("boots with permissive defaults when /version fails at boot", async () => {
const { url } = await startVersionServer(() => new Response("nope", { status: 503 }));
const headscale = await createHeadscale({ url, retryIntervalMs: 1_000_000 });
// Should not throw, and should default to "unknown" with permissive caps.
expect(headscale.version.unknown).toBe(true);
expect(headscale.capabilities.preAuthKeysHaveStableIds).toBe(true);
await headscale.dispose();
});
test("a 404 from /version is treated as below the supported floor and keeps retrying", async () => {
const probe = await startVersionServer(() => new Response("not found", { status: 404 }));
const headscale = await createHeadscale({ url: probe.url, retryIntervalMs: 25 });
// 404 = pre-0.27 Headscale. We do NOT settle on an inferred version;
// capabilities stay permissive and the background retry keeps probing
// so an in-place upgrade is picked up without a Headplane restart.
expect(headscale.version.unknown).toBe(true);
probe.setResponse(() =>
Response.json({ version: "v0.28.0", commit: "x", buildTime: "", go: "", dirty: false }),
);
const deadline = Date.now() + 2000;
while (Date.now() < deadline && headscale.version.unknown) {
await new Promise((r) => setTimeout(r, 25));
}
expect(headscale.version.unknown).toBe(false);
expect(headscale.version.raw).toBe("v0.28.0");
await headscale.dispose();
});
test("background retry promotes to detected version once Headscale comes online", async () => {
const probe = await startVersionServer(() => new Response("nope", { status: 503 }));
const headscale = await createHeadscale({ url: probe.url, retryIntervalMs: 20 });
expect(headscale.version.unknown).toBe(true);
probe.setResponse(() =>
Response.json({ version: "v0.27.1", commit: "x", buildTime: "", go: "", dirty: false }),
);
// Poll for up to 2s for the background retry to pick up the new response.
const deadline = Date.now() + 2000;
while (Date.now() < deadline && headscale.version.unknown) {
await new Promise((r) => setTimeout(r, 25));
}
expect(headscale.version.unknown).toBe(false);
expect(headscale.version.raw).toBe("v0.27.1");
expect(headscale.capabilities.preAuthKeysHaveStableIds).toBe(false);
await headscale.dispose();
});
test("dispose cancels the background retry", async () => {
const probe = await startVersionServer(() => new Response("nope", { status: 503 }));
const headscale = await createHeadscale({ url: probe.url, retryIntervalMs: 25 });
await headscale.dispose();
const before = probe.callCount();
await new Promise((r) => setTimeout(r, 150));
// At most a tiny number of additional calls if a retry was already
// queued at dispose time, but the cadence should have stopped.
expect(probe.callCount() - before).toBeLessThanOrEqual(1);
});
});
+100
View File
@@ -0,0 +1,100 @@
import { describe, expect, test } from "vitest";
import { capabilitiesFor } from "~/server/headscale/api/capabilities";
import {
formatServerVersion,
gte,
parseServerVersion,
} from "~/server/headscale/api/server-version";
describe("parseServerVersion", () => {
test("parses release versions with and without a leading v", () => {
expect(parseServerVersion("0.28.0")).toMatchObject({
major: 0,
minor: 28,
patch: 0,
prerelease: undefined,
unknown: false,
});
expect(parseServerVersion("v0.27.1")).toMatchObject({
major: 0,
minor: 27,
patch: 1,
unknown: false,
});
});
test("captures prerelease and build metadata", () => {
const v = parseServerVersion("v0.28.0-beta.1+abcdef");
expect(v.major).toBe(0);
expect(v.minor).toBe(28);
expect(v.patch).toBe(0);
expect(v.prerelease).toBe("beta.1");
expect(v.build).toBe("abcdef");
expect(v.unknown).toBe(false);
});
test("flags unparseable versions (e.g. dev builds)", () => {
const v = parseServerVersion("dev");
expect(v.unknown).toBe(true);
expect(v.raw).toBe("dev");
});
});
describe("gte", () => {
test("compares plain versions correctly", () => {
const v = parseServerVersion("0.28.0");
expect(gte(v, "0.27.0")).toBe(true);
expect(gte(v, "0.28.0")).toBe(true);
expect(gte(v, "0.29.0")).toBe(false);
});
test("ignores prerelease tags so betas get modern capabilities", () => {
// 0.28.0-beta.1 ships every wire format change that 0.28.0 does, so
// strict semver (where 0.28.0-beta.1 < 0.28.0) would lock beta users
// out of features their server actually supports.
const beta = parseServerVersion("0.28.0-beta.1");
expect(gte(beta, "0.28.0")).toBe(true);
expect(gte(beta, "0.27.0")).toBe(true);
});
test("unknown versions are treated as the newest known release", () => {
const dev = parseServerVersion("dev");
expect(gte(dev, "9.9.9")).toBe(true);
});
});
describe("formatServerVersion", () => {
test("renders release and prerelease versions without a leading v", () => {
expect(formatServerVersion(parseServerVersion("v0.28.0"))).toBe("0.28.0");
expect(formatServerVersion(parseServerVersion("0.28.0-beta.1"))).toBe("0.28.0-beta.1");
});
test("falls back to the raw string for unknown versions", () => {
expect(formatServerVersion(parseServerVersion("dev"))).toBe("dev");
});
});
describe("capabilitiesFor", () => {
test("0.28.0 enables every modern flag", () => {
const caps = capabilitiesFor(parseServerVersion("0.28.0"));
expect(caps).toEqual({
preAuthKeysHaveStableIds: true,
nodeTagsAreFlat: true,
nodeOwnerIsImmutable: true,
});
});
test("0.28.0-beta.1 matches 0.28.0 capabilities (prerelease gating)", () => {
expect(capabilitiesFor(parseServerVersion("0.28.0-beta.1"))).toEqual(
capabilitiesFor(parseServerVersion("0.28.0")),
);
});
test("0.27.1 lacks every 0.28-gated capability", () => {
const caps = capabilitiesFor(parseServerVersion("0.27.1"));
expect(caps.preAuthKeysHaveStableIds).toBe(false);
expect(caps.nodeTagsAreFlat).toBe(false);
expect(caps.nodeOwnerIsImmutable).toBe(false);
});
});