mirror of
https://github.com/tale/headplane.git
synced 2026-07-26 15:58:14 +00:00
Compare commits
59 Commits
tale/fate-spa
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b3c0c1c691 | |||
| 3d9b9b0f7e | |||
| f52c4161cc | |||
| fb73181cba | |||
| 17e7b35478 | |||
| 51a4613295 | |||
| 1345b7ca13 | |||
| 948cfad58c | |||
| 990e1e9ff5 | |||
| 18263e4878 | |||
| 12cee9763e | |||
| 4d0c0cacca | |||
| 83671ff3b0 | |||
| 476a0419f1 | |||
| 0439175e73 | |||
| a3cd8444a3 | |||
| 10055541cc | |||
| dc32427cff | |||
| 5aebc6d932 | |||
| e29221e5f7 | |||
| 846c030bc1 | |||
| 5d6eef5843 | |||
| 3f9dcd5eb4 | |||
| 0c4d175eb7 | |||
| 96f2721272 | |||
| c7822e3ec2 | |||
| 4179e3e604 | |||
| f00d8ab87e | |||
| 3252482e0b | |||
| 57c8046f99 | |||
| c8b1a30f34 | |||
| 8017436bb6 | |||
| dea19f9330 | |||
| 21806caa05 | |||
| 2f3a440de5 | |||
| e74e0d4542 | |||
| db39b5f2bd | |||
| 262c7bf82a | |||
| f8b23a5c89 | |||
| 775b81a7fa | |||
| 62817efa6e | |||
| b95d601ff6 | |||
| ea27c846e2 | |||
| 8c508e0602 | |||
| 0a51182eed | |||
| 22a521dff5 | |||
| de07372427 | |||
| 2584a4ef55 | |||
| 30b528c491 | |||
| d5acba88c7 | |||
| d7f1d665a4 | |||
| 7901f37002 | |||
| 7a62359b6a | |||
| 2e38a1d5e3 | |||
| 7221f1c2c3 | |||
| 56c5e5ac8c | |||
| 09be95c7bc | |||
| 0512565f8e | |||
| d4eee702e9 |
@@ -0,0 +1,57 @@
|
||||
# PULLFROG ACTION — DO NOT EDIT EXCEPT WHERE INDICATED
|
||||
name: Pullfrog
|
||||
run-name: ${{ inputs.name || github.workflow }}
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
prompt:
|
||||
type: string
|
||||
description: Agent prompt
|
||||
name:
|
||||
type: string
|
||||
description: Run name
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pullfrog:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- name: Run agent
|
||||
uses: pullfrog/pullfrog@v0
|
||||
with:
|
||||
prompt: ${{ inputs.prompt }}
|
||||
env:
|
||||
# add at least one provider API key
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
GOOGLE_GENERATIVE_AI_API_KEY:
|
||||
${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }}
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
|
||||
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
|
||||
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||
|
||||
# for Amazon Bedrock (https://docs.pullfrog.com/bedrock)
|
||||
# AWS_BEARER_TOKEN_BEDROCK: ${{ secrets.AWS_BEARER_TOKEN_BEDROCK }}
|
||||
# AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
# AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
# AWS_REGION: us-east-1
|
||||
# BEDROCK_MODEL_ID: <bedrock-model-id>
|
||||
|
||||
# for Google Vertex AI (https://docs.pullfrog.com/vertex)
|
||||
# VERTEX_SERVICE_ACCOUNT_JSON: ${{ secrets.VERTEX_SERVICE_ACCOUNT_JSON }}
|
||||
# GOOGLE_CLOUD_PROJECT: my-project
|
||||
# VERTEX_LOCATION: global
|
||||
# VERTEX_MODEL_ID: <vertex-model-id>
|
||||
@@ -1,3 +1,47 @@
|
||||
# Next
|
||||
|
||||
- Fixed the Headplane agent falling back to an interactive Tailscale login. The agent now starts with a pre-auth-key, preserves its existing state across restarts, and auto-approves itself when Headscale requires manual approval (closes [#582](https://github.com/tale/headplane/issues/582)).
|
||||
|
||||
# 0.7.0
|
||||
|
||||
- Switched to structured JSON logging (closes [#279](https://github.com/tale/headplane/issues/279)).
|
||||
- Added suggestions to pick existing tags to the machine tag dialog (closes [#560](https://github.com/tale/headplane/issues/560)).
|
||||
- Headplane correctly handles `dns.extra_records_path` from the Headscale configuration (closes [#543](https://github.com/tale/headplane/issues/543)).
|
||||
- Fixed Headscale PostgreSQL config validation so `pass` is not required when `password_file` is supplied (closes [#528](https://github.com/tale/headplane/issues/528)).
|
||||
- Fixed Browser SSH's WASM DERP probe to account for custom DERP ports (closes [#552](https://github.com/tale/headplane/issues/552)).
|
||||
- Fixed Browser SSH pre-auth key handling by increasing the temporary key expiry window and showing key creation errors in the UI (closes [#565](https://github.com/tale/headplane/issues/565)).
|
||||
- Fixed machine rename submission by validating names before sending the rename request (closes [#564](https://github.com/tale/headplane/issues/564)).
|
||||
- Fixed OIDC token exchange fallback when retrying with `client_secret_basic` (closes [#493](https://github.com/tale/headplane/issues/493)).
|
||||
- Added support for proxy authentication via `server.proxy_auth` (closes [#353](https://github.com/tale/headplane/issues/353)).
|
||||
- Added automatic role assignment for new OIDC users via `oidc.default_role` and IdP-provided role claims via `oidc.role_claim` (closes [#352](https://github.com/tale/headplane/issues/352)).
|
||||
- Fixed the DNS page crashing when Headscale has no Split DNS nameservers configured (closes [#570](https://github.com/tale/headplane/issues/570)).
|
||||
- User lists now show Headscale display names while preserving usernames as secondary text (closes [#571](https://github.com/tale/headplane/issues/571)).
|
||||
- Fixed the Register Machine Key dialog so it accepts registration URLs and full `hskey-authreq-...` registration keys (closes [#579](https://github.com/tale/headplane/issues/579)).
|
||||
- Fixed assigning ACL tags to tag-only (no-user) nodes from the UI. The "Add" and "Remove" tag buttons in the tag dialog lacked `type="button"`, so clicking them submitted the form before the local state update was applied and Headscale received the unchanged tag list. Tag modifications now reach Headscale as intended (closes [#574](https://github.com/tale/headplane/issues/574)).
|
||||
|
||||
---
|
||||
|
||||
# 0.7.0-beta.4 (May 31, 2026)
|
||||
|
||||
> This is a beta release. Please report any issues you encounter.
|
||||
|
||||
- **Headplane now requires Headscale 0.27.0 or newer.** Support for 0.26.x has been dropped. If `/version` returns 404 (the endpoint was added in 0.27.0), Headplane logs an error and keeps retrying so an in-place Headscale upgrade is picked up without a restart.
|
||||
- **Replaced the OpenAPI hash detection with `/version`.** Capabilities are now derived from the version reported by `/version` instead of fingerprinting the OpenAPI schema. This dramatically simplifies version detection and works with every supported release out of the box.
|
||||
- **Made Headscale boot resilient.** Headplane now boots even when Headscale is unreachable; capabilities default permissively and a background retry settles them once Headscale responds. No more cold-start ordering problems with docker-compose.
|
||||
- **Added optional in-process TLS termination.** Setting `server.tls_cert_path` and `server.tls_key_path` makes Headplane serve HTTPS/1.1 on `server.port` directly — no reverse proxy required. `server.cookie_secure` is auto-forced to `true` (with a warning) whenever TLS is enabled, since browsers refuse `Secure`-less cookies over HTTPS. HTTP/2 and HTTP/3 are intentionally not supported in-process; terminate those at a reverse proxy if you need them (closes [#403](https://github.com/tale/headplane/issues/403)).
|
||||
- **Made the bundled Docker healthcheck zero-config across HTTP and HTTPS.** Headplane writes its loopback URL (scheme, port, and basename included) to `/tmp/headplane-listen` on startup, and `hp_healthcheck` reads that file and probes the URL verbatim. Enabling TLS or changing `server.port` no longer requires any healthcheck-specific configuration. Native installs are unaffected — the listen file is only written when `HEADPLANE_LISTEN_FILE` is set, which the Dockerfile does automatically.
|
||||
- Added Rename and Delete actions for unlinked Headscale users on the Users page so admins can manage Headscale users that have no Headplane account (closes [#525](https://github.com/tale/headplane/issues/525)).
|
||||
- Documented [Custom Certificate Authorities](/configuration/tls#custom-certificate-authorities) for trusting private or self-signed CAs across every outbound TLS connection (OIDC, Headscale, Docker, etc.) via Node's `NODE_EXTRA_CA_CERTS`. This replaces the previous workaround of rebuilding the Docker image to extend the system trust store (closes [#313](https://github.com/tale/headplane/issues/313)).
|
||||
- Fixed user-management actions (link, change role, transfer ownership) using the wrong ID type for unlinked Headplane users. Form fields are now explicitly `headplane_user_id` vs. `headscale_user_id`, and the auth layer no longer round-trips through Headscale to recover the OIDC subject.
|
||||
- Fixed the "Register Machine Key" dialog passing the Headscale numeric user id instead of the username. Headscale's `RegisterNodeRequest.user` proto field is a `string` looked up via `GetUserByName` (no numeric fallback), so registration was failing whenever the selected owner's display name differed from their numeric id (closes [#532](https://github.com/tale/headplane/issues/532)).
|
||||
- Fixed pre-auth key expiration on Headscale 0.27.x. The pre-0.28 expire endpoint takes a `uint64 user` field which the API layer reads from `key.user?.id`, but the caller was wrapping the id as `{ name: user }`, causing the request to send an empty user field. Headplane now correctly passes the numeric Headscale user id.
|
||||
- Fixed dialog panels growing beyond the viewport; dialog content is now constrained and scrollable (via [#556](https://github.com/tale/headplane/pull/556)).
|
||||
- Fixed focus rings on inputs and buttons inside dialogs being clipped by the scrollable content container.
|
||||
- Fixed tooltips on the last row of the machines table being clipped by the viewport; tooltips now anchor above the trigger with collision padding (closes [#508](https://github.com/tale/headplane/issues/508)).
|
||||
- Corrected the Docker healthcheck example in the docs to use the required `CMD` prefix so reverse proxies don't see the container as unhealthy (closes [#535](https://github.com/tale/headplane/issues/535)).
|
||||
|
||||
---
|
||||
|
||||
# 0.7.0-beta.3 (May 14, 2026)
|
||||
|
||||
> This is a beta release. Please report any issues you encounter.
|
||||
|
||||
@@ -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" ]
|
||||
|
||||
@@ -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>} />
|
||||
) : (
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -4,16 +4,18 @@ import { createReadableStreamFromReadable } from "@react-router/node";
|
||||
import { isbot } from "isbot";
|
||||
import type { RenderToPipeableStreamOptions } from "react-dom/server";
|
||||
import { renderToPipeableStream } from "react-dom/server";
|
||||
import type { AppLoadContext, EntryContext } from "react-router";
|
||||
import type { EntryContext, RouterContextProvider } from "react-router";
|
||||
import { ServerRouter } from "react-router";
|
||||
|
||||
import log from "~/utils/log";
|
||||
|
||||
export const streamTimeout = 5_000;
|
||||
export default function handleRequest(
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
routerContext: EntryContext,
|
||||
_loadContext: AppLoadContext,
|
||||
_loadContext: RouterContextProvider,
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let shellRendered = false;
|
||||
@@ -52,7 +54,7 @@ export default function handleRequest(
|
||||
// errors encountered during initial shell rendering since they'll
|
||||
// reject and get logged in handleDocumentRequest.
|
||||
if (shellRendered) {
|
||||
console.error(error);
|
||||
log.error("server", "Streaming render error: %o", error);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
+45
-32
@@ -2,8 +2,17 @@ import { Outlet, redirect, type ShouldRevalidateFunction } from "react-router";
|
||||
|
||||
import { ErrorBanner } from "~/components/error-banner";
|
||||
import StatusBanner from "~/components/status-banner";
|
||||
import {
|
||||
appConfigContext,
|
||||
authContext,
|
||||
headscaleConfigContext,
|
||||
headscaleContext,
|
||||
headscaleLiveStoreContext,
|
||||
requestApiContext,
|
||||
} from "~/server/context";
|
||||
import { isDataUnauthorizedError } from "~/server/headscale/api/error-client";
|
||||
import { usersResource } from "~/server/headscale/live-store";
|
||||
import { isUserPrincipal } from "~/server/web/auth";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
import log from "~/utils/log";
|
||||
|
||||
@@ -30,36 +39,40 @@ export const shouldRevalidate: ShouldRevalidateFunction = ({
|
||||
};
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const auth = context.get(authContext);
|
||||
const config = context.get(appConfigContext);
|
||||
const getRequestApi = context.get(requestApiContext);
|
||||
const headscale = context.get(headscaleContext);
|
||||
const headscaleConfig = context.get(headscaleConfigContext);
|
||||
const headscaleLiveStore = context.get(headscaleLiveStoreContext);
|
||||
|
||||
try {
|
||||
const principal = await context.auth.require(request);
|
||||
const { principal, api } = await getRequestApi(request);
|
||||
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
|
||||
const user =
|
||||
principal.kind === "oidc"
|
||||
? {
|
||||
email: principal.profile.email,
|
||||
name: principal.profile.name,
|
||||
picture: principal.profile.picture,
|
||||
subject: principal.user.subject,
|
||||
username: principal.profile.username,
|
||||
}
|
||||
: { name: principal.displayName, subject: "api_key" };
|
||||
const user = isUserPrincipal(principal)
|
||||
? {
|
||||
email: principal.profile.email,
|
||||
name: principal.profile.name,
|
||||
picture: principal.profile.picture,
|
||||
subject: principal.user.subject,
|
||||
username: principal.profile.username,
|
||||
}
|
||||
: { name: principal.displayName, subject: "api_key" };
|
||||
|
||||
// MARK: The session should stay valid if Headscale isn't healthy
|
||||
const isHealthy = await api.isHealthy();
|
||||
const isHealthy = await headscale.health();
|
||||
if (isHealthy) {
|
||||
try {
|
||||
await api.getApiKeys();
|
||||
await api.apiKeys.list();
|
||||
} catch (error) {
|
||||
if (isDataUnauthorizedError(error)) {
|
||||
const displayName =
|
||||
principal.kind === "oidc" ? principal.profile.name : principal.displayName;
|
||||
const displayName = isUserPrincipal(principal)
|
||||
? principal.profile.name
|
||||
: principal.displayName;
|
||||
log.warn("auth", "Logging out %s due to expired API key", displayName);
|
||||
return redirect("/login", {
|
||||
headers: {
|
||||
"Set-Cookie": await context.auth.destroySession(request),
|
||||
"Set-Cookie": await auth.destroySession(request),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -67,11 +80,11 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
|
||||
// Self-heal: if the linked Headscale user was deleted, clear the
|
||||
// stale link so the user gets prompted to re-link.
|
||||
if (principal.kind === "oidc" && principal.user.headscaleUserId) {
|
||||
if (isUserPrincipal(principal) && principal.user.headscaleUserId) {
|
||||
try {
|
||||
const usersSnap = await context.hsLive.get(usersResource, api);
|
||||
const usersSnap = await headscaleLiveStore.get(usersResource, api);
|
||||
if (!usersSnap.data.some((u) => u.id === principal.user.headscaleUserId)) {
|
||||
await context.auth.unlinkHeadscaleUser(principal.user.id);
|
||||
await auth.unlinkHeadscaleUser(principal.user.id);
|
||||
}
|
||||
} catch {
|
||||
// API call failed, skip validation
|
||||
@@ -81,23 +94,23 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
|
||||
return {
|
||||
access: {
|
||||
dns: context.auth.can(principal, Capabilities.read_network),
|
||||
machines: context.auth.can(principal, Capabilities.read_machines),
|
||||
policy: context.auth.can(principal, Capabilities.read_policy),
|
||||
settings: context.auth.can(principal, Capabilities.read_feature),
|
||||
ui: context.auth.can(principal, Capabilities.ui_access),
|
||||
users: context.auth.can(principal, Capabilities.read_users),
|
||||
dns: auth.can(principal, Capabilities.read_network),
|
||||
machines: auth.can(principal, Capabilities.read_machines),
|
||||
policy: auth.can(principal, Capabilities.read_policy),
|
||||
settings: auth.can(principal, Capabilities.read_feature),
|
||||
ui: auth.can(principal, Capabilities.ui_access),
|
||||
users: auth.can(principal, Capabilities.read_users),
|
||||
},
|
||||
baseUrl: context.config.headscale.public_url ?? context.config.headscale.url,
|
||||
configAvailable: context.hs.readable(),
|
||||
isDebug: context.config.debug,
|
||||
baseUrl: config.headscale.public_url ?? config.headscale.url,
|
||||
configAvailable: headscaleConfig.readable(),
|
||||
isDebug: config.debug,
|
||||
isHealthy,
|
||||
user,
|
||||
};
|
||||
} catch {
|
||||
return redirect("/login", {
|
||||
headers: {
|
||||
"Set-Cookie": await context.auth.destroySession(request),
|
||||
"Set-Cookie": await auth.destroySession(request),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"0.26.1": ["0.26.0", "0.26.1"],
|
||||
"0.27.0": ["0.27.0"],
|
||||
"0.27.1": ["0.27.1"],
|
||||
"0.28.0": ["0.28.0"]
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
{
|
||||
"0.26.0": {
|
||||
"GET /api/v1/apikey": "efe31b6dc980e158",
|
||||
"POST /api/v1/apikey": "39953a96c1da5312",
|
||||
"POST /api/v1/apikey/expire": "ca56add866802f17",
|
||||
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
|
||||
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
|
||||
"GET /api/v1/node": "8bb18b8c7cfb4f20",
|
||||
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
|
||||
"POST /api/v1/node/register": "539f7cb3a84d43d4",
|
||||
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
|
||||
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
|
||||
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
|
||||
"POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784",
|
||||
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
|
||||
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
|
||||
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
|
||||
"GET /api/v1/policy": "d6c639be304cd3c0",
|
||||
"PUT /api/v1/policy": "6cbe80bde771a388",
|
||||
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
|
||||
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
|
||||
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
|
||||
"GET /api/v1/user": "228831b58ccc5a17",
|
||||
"POST /api/v1/user": "a4e1d889d7962da5",
|
||||
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
|
||||
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
|
||||
},
|
||||
"0.26.1": {
|
||||
"GET /api/v1/apikey": "efe31b6dc980e158",
|
||||
"POST /api/v1/apikey": "39953a96c1da5312",
|
||||
"POST /api/v1/apikey/expire": "ca56add866802f17",
|
||||
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
|
||||
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
|
||||
"GET /api/v1/node": "8bb18b8c7cfb4f20",
|
||||
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
|
||||
"POST /api/v1/node/register": "539f7cb3a84d43d4",
|
||||
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
|
||||
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
|
||||
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
|
||||
"POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784",
|
||||
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
|
||||
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
|
||||
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
|
||||
"GET /api/v1/policy": "d6c639be304cd3c0",
|
||||
"PUT /api/v1/policy": "6cbe80bde771a388",
|
||||
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
|
||||
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
|
||||
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
|
||||
"GET /api/v1/user": "228831b58ccc5a17",
|
||||
"POST /api/v1/user": "a4e1d889d7962da5",
|
||||
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
|
||||
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
|
||||
},
|
||||
"0.27.0": {
|
||||
"GET /api/v1/apikey": "efe31b6dc980e158",
|
||||
"POST /api/v1/apikey": "39953a96c1da5312",
|
||||
"POST /api/v1/apikey/expire": "ca56add866802f17",
|
||||
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
|
||||
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
|
||||
"GET /api/v1/health": "5e447272e72b2e5f",
|
||||
"GET /api/v1/node": "8bb18b8c7cfb4f20",
|
||||
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
|
||||
"POST /api/v1/node/register": "539f7cb3a84d43d4",
|
||||
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
|
||||
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
|
||||
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
|
||||
"POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784",
|
||||
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
|
||||
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
|
||||
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
|
||||
"GET /api/v1/policy": "d6c639be304cd3c0",
|
||||
"PUT /api/v1/policy": "6cbe80bde771a388",
|
||||
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
|
||||
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
|
||||
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
|
||||
"GET /api/v1/user": "228831b58ccc5a17",
|
||||
"POST /api/v1/user": "a4e1d889d7962da5",
|
||||
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
|
||||
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
|
||||
},
|
||||
"0.27.1": {
|
||||
"GET /api/v1/apikey": "efe31b6dc980e158",
|
||||
"POST /api/v1/apikey": "39953a96c1da5312",
|
||||
"POST /api/v1/apikey/expire": "ca56add866802f17",
|
||||
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
|
||||
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
|
||||
"GET /api/v1/health": "5e447272e72b2e5f",
|
||||
"GET /api/v1/node": "8bb18b8c7cfb4f20",
|
||||
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
|
||||
"POST /api/v1/node/register": "539f7cb3a84d43d4",
|
||||
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
|
||||
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
|
||||
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
|
||||
"POST /api/v1/node/{nodeId}/expire": "53efc8e2017c16ae",
|
||||
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
|
||||
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
|
||||
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
|
||||
"GET /api/v1/policy": "d6c639be304cd3c0",
|
||||
"PUT /api/v1/policy": "6cbe80bde771a388",
|
||||
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
|
||||
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
|
||||
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
|
||||
"GET /api/v1/user": "228831b58ccc5a17",
|
||||
"POST /api/v1/user": "a4e1d889d7962da5",
|
||||
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
|
||||
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
|
||||
},
|
||||
"0.28.0": {
|
||||
"GET /api/v1/apikey": "efe31b6dc980e158",
|
||||
"POST /api/v1/apikey": "39953a96c1da5312",
|
||||
"POST /api/v1/apikey/expire": "ca56add866802f17",
|
||||
"DELETE /api/v1/apikey/{prefix}": "b10ca7d2750405b2",
|
||||
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
|
||||
"GET /api/v1/health": "5e447272e72b2e5f",
|
||||
"GET /api/v1/node": "8bb18b8c7cfb4f20",
|
||||
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
|
||||
"POST /api/v1/node/register": "539f7cb3a84d43d4",
|
||||
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
|
||||
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
|
||||
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
|
||||
"POST /api/v1/node/{nodeId}/expire": "53efc8e2017c16ae",
|
||||
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
|
||||
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
|
||||
"GET /api/v1/policy": "d6c639be304cd3c0",
|
||||
"PUT /api/v1/policy": "6cbe80bde771a388",
|
||||
"GET /api/v1/preauthkey": "8428b44e3a821e9e",
|
||||
"DELETE /api/v1/preauthkey": "f05ea1bc8ad89a09",
|
||||
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
|
||||
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
|
||||
"GET /api/v1/user": "228831b58ccc5a17",
|
||||
"POST /api/v1/user": "a4e1d889d7962da5",
|
||||
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
|
||||
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { data } from "react-router";
|
||||
|
||||
import { authContext, requestApiContext } from "~/server/context";
|
||||
import { isDataWithApiError } from "~/server/headscale/api/error-client";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
|
||||
@@ -9,8 +10,11 @@ import type { Route } from "./+types/overview";
|
||||
// If it isn't, it'll gracefully error anyways, since this means some
|
||||
// fishy client manipulation is happening.
|
||||
export async function aclAction({ request, context }: Route.ActionArgs) {
|
||||
const principal = await context.auth.require(request);
|
||||
const check = context.auth.can(principal, Capabilities.write_policy);
|
||||
const auth = context.get(authContext);
|
||||
const getRequestApi = context.get(requestApiContext);
|
||||
|
||||
const principal = await auth.require(request);
|
||||
const check = auth.can(principal, Capabilities.write_policy);
|
||||
if (!check) {
|
||||
throw data("You do not have permission to write to the ACL policy", {
|
||||
status: 403,
|
||||
@@ -26,10 +30,9 @@ export async function aclAction({ request, context }: Route.ActionArgs) {
|
||||
});
|
||||
}
|
||||
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
const { api } = await getRequestApi(request);
|
||||
try {
|
||||
const { policy, updatedAt } = await api.setPolicy(policyData);
|
||||
const { policy, updatedAt } = await api.policy.set(policyData);
|
||||
return data({
|
||||
success: true,
|
||||
error: undefined,
|
||||
@@ -55,71 +58,29 @@ export async function aclAction({ request, context }: Route.ActionArgs) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Starting in Headscale 0.27.0 the ACLs parsing was changed meaning
|
||||
// we need to reference other error messages based on API version.
|
||||
if (context.hsApi.clientHelpers.isAtleast("0.27.0")) {
|
||||
if (message.includes("parsing HuJSON:")) {
|
||||
const cutIndex = message.indexOf("parsing HuJSON:");
|
||||
const trimmed =
|
||||
cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 16).trim()}` : message;
|
||||
// Policy parse errors carry one of two prefixes (HuJSON syntax vs.
|
||||
// structural unmarshal). Headscale 0.27.0+ uses these forms; older
|
||||
// releases are no longer supported.
|
||||
if (message.includes("parsing HuJSON:")) {
|
||||
const cutIndex = message.indexOf("parsing HuJSON:");
|
||||
const trimmed =
|
||||
cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 16).trim()}` : message;
|
||||
|
||||
return data(
|
||||
{
|
||||
success: false,
|
||||
error: trimmed,
|
||||
policy: undefined,
|
||||
updatedAt: undefined,
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
return data(
|
||||
{ success: false, error: trimmed, policy: undefined, updatedAt: undefined },
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
if (message.includes("parsing policy from bytes:")) {
|
||||
const cutIndex = message.indexOf("parsing policy from bytes:");
|
||||
const trimmed =
|
||||
cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 26).trim()}` : message;
|
||||
if (message.includes("parsing policy from bytes:")) {
|
||||
const cutIndex = message.indexOf("parsing policy from bytes:");
|
||||
const trimmed =
|
||||
cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 26).trim()}` : message;
|
||||
|
||||
return data(
|
||||
{
|
||||
success: false,
|
||||
error: trimmed,
|
||||
policy: undefined,
|
||||
updatedAt: undefined,
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Pre-0.27.0 error messages
|
||||
if (message.includes("parsing hujson")) {
|
||||
const cutIndex = message.indexOf("err: hujson:");
|
||||
const trimmed = cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 12)}` : message;
|
||||
|
||||
return data(
|
||||
{
|
||||
success: false,
|
||||
error: trimmed,
|
||||
policy: undefined,
|
||||
updatedAt: undefined,
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
if (message.includes("unmarshalling policy")) {
|
||||
const cutIndex = message.indexOf("err:");
|
||||
const trimmed = cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 5)}` : message;
|
||||
|
||||
return data(
|
||||
{
|
||||
success: false,
|
||||
error: trimmed,
|
||||
policy: undefined,
|
||||
updatedAt: undefined,
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
return data(
|
||||
{ success: false, error: trimmed, policy: undefined, updatedAt: undefined },
|
||||
400,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { data } from "react-router";
|
||||
|
||||
import { authContext, requestApiContext } from "~/server/context";
|
||||
import { isDataWithApiError } from "~/server/headscale/api/error-client";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
|
||||
@@ -11,10 +12,13 @@ import type { Route } from "./+types/overview";
|
||||
// 2. Does the user have permission to write to the policy?
|
||||
// 3. Is the Headscale policy in file or database mode?
|
||||
// If database, we can read/write easily via the API.
|
||||
// If in file mode, we can only write if context.config is available.
|
||||
// If in file mode, we can only write if the Headscale config is available.
|
||||
export async function aclLoader({ request, context }: Route.LoaderArgs) {
|
||||
const principal = await context.auth.require(request);
|
||||
const check = context.auth.can(principal, Capabilities.read_policy);
|
||||
const auth = context.get(authContext);
|
||||
const getRequestApi = context.get(requestApiContext);
|
||||
|
||||
const principal = await auth.require(request);
|
||||
const check = auth.can(principal, Capabilities.read_policy);
|
||||
if (!check) {
|
||||
throw data("You do not have permission to read the ACL policy.", {
|
||||
status: 403,
|
||||
@@ -23,16 +27,15 @@ export async function aclLoader({ request, context }: Route.LoaderArgs) {
|
||||
|
||||
const flags = {
|
||||
// Can the user write to the ACL policy
|
||||
access: context.auth.can(principal, Capabilities.write_policy),
|
||||
access: auth.can(principal, Capabilities.write_policy),
|
||||
writable: false,
|
||||
policy: "",
|
||||
};
|
||||
|
||||
// Try to load the ACL policy from the API.
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
const { api } = await getRequestApi(request);
|
||||
try {
|
||||
const { policy, updatedAt } = await api.getPolicy();
|
||||
const { policy, updatedAt } = await api.policy.get();
|
||||
flags.writable = updatedAt !== null;
|
||||
flags.policy = policy;
|
||||
return flags;
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { redirect } from "react-router";
|
||||
|
||||
import { authContext, headscaleContext } from "~/server/context";
|
||||
import { isDataWithApiError } from "~/server/headscale/api/error-client";
|
||||
import log from "~/utils/log";
|
||||
|
||||
import type { Route } from "./+types/page";
|
||||
|
||||
export async function loginAction({ request, context }: Route.LoaderArgs) {
|
||||
const auth = context.get(authContext);
|
||||
const headscale = context.get(headscaleContext);
|
||||
|
||||
const formData = await request.formData();
|
||||
const apiKey = formData.has("api_key") ? String(formData.get("api_key")) : undefined;
|
||||
|
||||
@@ -33,9 +37,11 @@ export async function loginAction({ request, context }: Route.LoaderArgs) {
|
||||
};
|
||||
}
|
||||
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
// Build a client with the candidate API key the user just submitted, so the
|
||||
// GET /api/v1/apikey call below validates the key against Headscale itself.
|
||||
const api = headscale.client(apiKey);
|
||||
try {
|
||||
const apiKeys = await api.getApiKeys();
|
||||
const apiKeys = await api.apiKeys.list();
|
||||
|
||||
// We don't need to check for 0 API keys because this request cannot
|
||||
// be authenticated correctly without an API key
|
||||
@@ -68,7 +74,7 @@ export async function loginAction({ request, context }: Route.LoaderArgs) {
|
||||
|
||||
return redirect("/machines", {
|
||||
headers: {
|
||||
"Set-Cookie": await context.auth.createApiKeySession(
|
||||
"Set-Cookie": await auth.createApiKeySession(
|
||||
apiKey,
|
||||
`${lookup.prefix}...`,
|
||||
expiry.getTime() - Date.now(),
|
||||
|
||||
@@ -7,7 +7,10 @@ import Card from "~/components/card";
|
||||
import Code from "~/components/code";
|
||||
import Input from "~/components/input";
|
||||
import Link from "~/components/link";
|
||||
import { appConfigContext, authContext, oidcContext } from "~/server/context";
|
||||
import type { OidcError, OidcService } from "~/server/oidc/provider";
|
||||
import { useLiveData } from "~/utils/live-data";
|
||||
import log from "~/utils/log";
|
||||
|
||||
import type { Route } from "./+types/page";
|
||||
import { loginAction } from "./action";
|
||||
@@ -15,24 +18,41 @@ import { OidcConfigErrorNotice, OidcDiscoveryFailedNotice } from "./config-error
|
||||
import Logout from "./logout";
|
||||
import { OidcErrorNotice } from "./oidc-error";
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
export async function loader({ request, context, url }: Route.LoaderArgs) {
|
||||
const auth = context.get(authContext);
|
||||
const config = context.get(appConfigContext);
|
||||
const oidc = context.get(oidcContext);
|
||||
|
||||
try {
|
||||
await context.auth.require(request);
|
||||
await auth.require(request);
|
||||
return redirect("/machines");
|
||||
} catch {}
|
||||
|
||||
const qp = new URL(request.url).searchParams;
|
||||
const qp = url.searchParams;
|
||||
const urlState = qp.get("s") ?? undefined;
|
||||
|
||||
const oidcService = context.oidc?.service;
|
||||
const oidcStatus = oidcService
|
||||
? await oidcService.discover().then(
|
||||
(r) => (r.ok ? oidcService.status() : oidcService.status()),
|
||||
() => oidcService.status(),
|
||||
)
|
||||
: undefined;
|
||||
const oidcService = oidc.state === "enabled" ? oidc.value : undefined;
|
||||
let oidcStatus: ReturnType<OidcService["status"]> | undefined;
|
||||
if (oidcService) {
|
||||
try {
|
||||
const result = await oidcService.discover();
|
||||
if (!result.ok) {
|
||||
logLoginOidcError("OIDC discovery failed", result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("auth", "OIDC discovery failed unexpectedly: %s", String(error));
|
||||
log.debug("auth", "OIDC discovery error details: %o", error);
|
||||
}
|
||||
|
||||
if (context.oidc?.disableApiKeyLogin && oidcStatus?.state === "ready" && urlState !== "logout") {
|
||||
oidcStatus = oidcService.status();
|
||||
}
|
||||
|
||||
if (
|
||||
oidcService &&
|
||||
config.oidc?.disable_api_key_login &&
|
||||
oidcStatus?.state === "ready" &&
|
||||
urlState !== "logout"
|
||||
) {
|
||||
return redirect("/oidc/start");
|
||||
}
|
||||
|
||||
@@ -40,7 +60,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const oidcErrorCodes = oidcStatus?.state === "error" ? [oidcStatus.error.code] : [];
|
||||
|
||||
return {
|
||||
isCookieSecureEnabled: context.config.server.cookie_secure,
|
||||
isCookieSecureEnabled: config.server.cookie_secure,
|
||||
isOidcConnectorEnabled,
|
||||
oidcErrorCodes,
|
||||
urlState,
|
||||
@@ -49,6 +69,13 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
|
||||
export const action = loginAction;
|
||||
|
||||
function logLoginOidcError(context: string, error: OidcError): void {
|
||||
log.error("auth", "%s [%s]: %s", context, error.code, error.message);
|
||||
if (error.hint) {
|
||||
log.error("auth", "Hint: %s", error.hint);
|
||||
}
|
||||
}
|
||||
|
||||
export default function Page({ loaderData, actionData }: Route.ComponentProps) {
|
||||
const { isCookieSecureEnabled, isOidcConnectorEnabled, oidcErrorCodes, urlState } = loaderData;
|
||||
|
||||
|
||||
+15
-10
@@ -1,37 +1,42 @@
|
||||
import { type ActionFunctionArgs, redirect } from "react-router";
|
||||
|
||||
import type { AppContext } from "~/server/context";
|
||||
import { appConfigContext, authContext, oidcContext } from "~/server/context";
|
||||
|
||||
export async function loader() {
|
||||
return redirect("/machines");
|
||||
}
|
||||
|
||||
export async function action({ request, context }: ActionFunctionArgs<AppContext>) {
|
||||
let principal: Awaited<ReturnType<typeof context.auth.require>> | undefined;
|
||||
export async function action({ request, context }: ActionFunctionArgs) {
|
||||
const auth = context.get(authContext);
|
||||
const config = context.get(appConfigContext);
|
||||
const oidc = context.get(oidcContext);
|
||||
|
||||
let principal: Awaited<ReturnType<typeof auth.require>> | undefined;
|
||||
try {
|
||||
principal = await context.auth.require(request);
|
||||
principal = await auth.require(request);
|
||||
} catch {
|
||||
return redirect("/login");
|
||||
}
|
||||
|
||||
// When API key is disabled, we need to explicitly redirect
|
||||
// with a logout state to prevent auto login again.
|
||||
let url = context.config.oidc?.disable_api_key_login ? "/login?s=logout" : "/login";
|
||||
let url = config.oidc?.disable_api_key_login ? "/login?s=logout" : "/login";
|
||||
|
||||
// For OIDC sessions, redirect to the provider's RP-initiated logout
|
||||
// endpoint when explicitly enabled, so the upstream IdP session is also
|
||||
// ended. Disabled by default because the post_logout_redirect_uri must be
|
||||
// pre-registered on the IdP — turning this on without registering it would
|
||||
// strand users on the IdP's error page.
|
||||
if (principal?.kind === "oidc" && context.oidc?.useEndSession && context.oidc.service) {
|
||||
const status = context.oidc.service.status();
|
||||
if (principal?.kind === "oidc" && oidc.state === "enabled" && config.oidc?.use_end_session) {
|
||||
const service = oidc.value;
|
||||
const status = service.status();
|
||||
if (status.state !== "ready") {
|
||||
// Trigger discovery if it hasn't happened yet so we can find the
|
||||
// end_session_endpoint without forcing a re-login.
|
||||
await context.oidc.service.discover();
|
||||
await service.discover();
|
||||
}
|
||||
|
||||
const endSessionUrl = context.oidc.service.buildEndSessionUrl(principal.idToken);
|
||||
const endSessionUrl = service.buildEndSessionUrl(principal.idToken);
|
||||
if (endSessionUrl) {
|
||||
url = endSessionUrl;
|
||||
}
|
||||
@@ -39,7 +44,7 @@ export async function action({ request, context }: ActionFunctionArgs<AppContext
|
||||
|
||||
return redirect(url, {
|
||||
headers: {
|
||||
"Set-Cookie": await context.auth.destroySession(request),
|
||||
"Set-Cookie": await auth.destroySession(request),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,23 +1,38 @@
|
||||
import { data, redirect } from "react-router";
|
||||
|
||||
import {
|
||||
appConfigContext,
|
||||
authContext,
|
||||
headscaleApiKeyContext,
|
||||
headscaleContext,
|
||||
oidcContext,
|
||||
} from "~/server/context";
|
||||
import { logOidcError } from "~/server/oidc/provider";
|
||||
import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity";
|
||||
import { Roles } from "~/server/web/roles";
|
||||
import log from "~/utils/log";
|
||||
import { createOidcStateCookie } from "~/utils/oidc-state";
|
||||
|
||||
import type { Route } from "./+types/oidc-callback";
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const service = context.oidc?.service;
|
||||
if (!service) {
|
||||
throw data("OIDC is not enabled or misconfigured", { status: 501 });
|
||||
}
|
||||
export async function loader({ request, context, url }: Route.LoaderArgs) {
|
||||
const auth = context.get(authContext);
|
||||
const config = context.get(appConfigContext);
|
||||
const headscale = context.get(headscaleContext);
|
||||
const headscaleApiKey = context.get(headscaleApiKeyContext);
|
||||
const oidc = context.get(oidcContext);
|
||||
|
||||
if (oidc.state !== "enabled") {
|
||||
throw data(`OIDC is unavailable: ${oidc.reason}`, { status: 501 });
|
||||
}
|
||||
const service = oidc.value;
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (url.searchParams.toString().length === 0) {
|
||||
log.warn("auth", "Called OIDC callback without query parameters");
|
||||
return redirect("/login?s=error_no_query");
|
||||
}
|
||||
|
||||
const cookie = createOidcStateCookie(context.config);
|
||||
const cookie = createOidcStateCookie(config);
|
||||
const oidcCookieState = await cookie.parse(request.headers.get("Cookie"));
|
||||
|
||||
if (oidcCookieState == null) {
|
||||
@@ -40,27 +55,38 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
|
||||
const result = await service.handleCallback(url.searchParams, flowState);
|
||||
if (!result.ok) {
|
||||
log.error("auth", "OIDC callback failed [%s]: %s", result.error.code, result.error.message);
|
||||
if (result.error.hint) {
|
||||
log.error("auth", "Hint: %s", result.error.hint);
|
||||
}
|
||||
logOidcError("OIDC callback failed", result.error);
|
||||
return redirect("/login?s=error_auth_failed");
|
||||
}
|
||||
|
||||
const identity = result.value;
|
||||
const claimedRole =
|
||||
identity.role && identity.role !== "owner" && identity.role in Roles
|
||||
? identity.role
|
||||
: undefined;
|
||||
|
||||
const userId = await context.auth.findOrCreateUser(identity.subject, {
|
||||
name: identity.name,
|
||||
email: identity.email,
|
||||
picture: identity.picture,
|
||||
});
|
||||
const userId = await auth.findOrCreateUser(
|
||||
identity.subject,
|
||||
{
|
||||
name: identity.name,
|
||||
email: identity.email,
|
||||
picture: identity.picture,
|
||||
},
|
||||
{
|
||||
initialRole: claimedRole ?? config.oidc?.default_role,
|
||||
syncRole: claimedRole,
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
const hsApi = context.hsApi.getRuntimeClient(context.headscaleApiKey!);
|
||||
const hsUsers = await hsApi.getUsers();
|
||||
// Looks up the Headscale user that matches this OIDC identity. We use
|
||||
// the configured admin API key here — not a per-request one — because
|
||||
// there is no per-request key yet (the session is being created).
|
||||
const hsApi = headscale.client(headscaleApiKey!);
|
||||
const hsUsers = await hsApi.users.list();
|
||||
const hsUser = findHeadscaleUserBySubject(hsUsers, identity.subject, identity.email);
|
||||
if (hsUser) {
|
||||
await context.auth.linkHeadscaleUser(userId, hsUser.id);
|
||||
await auth.linkHeadscaleUser(userId, hsUser.id);
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn("auth", "Failed to link Headscale user: %s", String(error));
|
||||
@@ -68,11 +94,11 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
|
||||
// Only persist the id_token when RP-initiated logout is enabled — otherwise
|
||||
// we'd be storing a credential we never use.
|
||||
const idToken = context.oidc?.useEndSession ? identity.idToken : undefined;
|
||||
const idToken = config.oidc?.use_end_session ? identity.idToken : undefined;
|
||||
|
||||
return redirect("/", {
|
||||
headers: {
|
||||
"Set-Cookie": await context.auth.createOidcSession(
|
||||
"Set-Cookie": await auth.createOidcSession(
|
||||
userId,
|
||||
{
|
||||
name: identity.name,
|
||||
|
||||
@@ -1,27 +1,34 @@
|
||||
import { data, redirect } from "react-router";
|
||||
|
||||
import { appConfigContext, authContext, oidcContext } from "~/server/context";
|
||||
import { logOidcError } from "~/server/oidc/provider";
|
||||
import { createOidcStateCookie } from "~/utils/oidc-state";
|
||||
|
||||
import type { Route } from "./+types/oidc-start";
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const auth = context.get(authContext);
|
||||
const config = context.get(appConfigContext);
|
||||
const oidc = context.get(oidcContext);
|
||||
|
||||
try {
|
||||
await context.auth.require(request);
|
||||
await auth.require(request);
|
||||
return redirect("/");
|
||||
} catch {}
|
||||
|
||||
const service = context.oidc?.service;
|
||||
if (!service) {
|
||||
throw data("OIDC is not enabled or misconfigured", { status: 501 });
|
||||
if (oidc.state !== "enabled") {
|
||||
throw data(`OIDC is unavailable: ${oidc.reason}`, { status: 501 });
|
||||
}
|
||||
const service = oidc.value;
|
||||
|
||||
const result = await service.startFlow();
|
||||
if (!result.ok) {
|
||||
logOidcError("OIDC start failed", result.error);
|
||||
return redirect(`/login?s=${result.error.code}`);
|
||||
}
|
||||
|
||||
const { url, flowState } = result.value;
|
||||
const cookie = createOidcStateCookie(context.config);
|
||||
const cookie = createOidcStateCookie(config);
|
||||
|
||||
return redirect(url, {
|
||||
status: 302,
|
||||
|
||||
@@ -1,24 +1,32 @@
|
||||
import { data } from "react-router";
|
||||
|
||||
import {
|
||||
authContext,
|
||||
headscaleConfigContext,
|
||||
headscaleContext,
|
||||
integrationContext,
|
||||
} from "~/server/context";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
|
||||
import type { Route } from "./+types/overview";
|
||||
|
||||
export async function dnsAction({ request, context }: Route.ActionArgs) {
|
||||
const principal = await context.auth.require(request);
|
||||
const check = context.auth.can(principal, Capabilities.write_network);
|
||||
const auth = context.get(authContext);
|
||||
const headscale = context.get(headscaleContext);
|
||||
const headscaleConfig = context.get(headscaleConfigContext);
|
||||
const integration = context.get(integrationContext);
|
||||
|
||||
const principal = await auth.require(request);
|
||||
const check = auth.can(principal, Capabilities.write_network);
|
||||
|
||||
if (!check) {
|
||||
return data({ success: false }, 403);
|
||||
}
|
||||
|
||||
if (!context.hs.writable()) {
|
||||
if (!headscaleConfig.writable()) {
|
||||
return data({ success: false }, 403);
|
||||
}
|
||||
|
||||
// We only need it for health checks which don't require auth
|
||||
const api = context.hsApi.getRuntimeClient("fake-api-key");
|
||||
|
||||
const formData = await request.formData();
|
||||
const action = formData.get("action_id")?.toString();
|
||||
if (!action) {
|
||||
@@ -32,14 +40,14 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
await context.hs.patch([
|
||||
await headscaleConfig.patch([
|
||||
{
|
||||
path: "dns.base_domain",
|
||||
value: newName,
|
||||
},
|
||||
]);
|
||||
|
||||
await context.integration?.onConfigChange(api);
|
||||
await integration?.onConfigChange(headscale);
|
||||
return { message: "Tailnet renamed successfully" };
|
||||
}
|
||||
case "toggle_magic": {
|
||||
@@ -48,18 +56,18 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
await context.hs.patch([
|
||||
await headscaleConfig.patch([
|
||||
{
|
||||
path: "dns.magic_dns",
|
||||
value: newState === "enabled",
|
||||
},
|
||||
]);
|
||||
|
||||
await context.integration?.onConfigChange(api);
|
||||
await integration?.onConfigChange(headscale);
|
||||
return { message: "Magic DNS state updated successfully" };
|
||||
}
|
||||
case "remove_ns": {
|
||||
const config = context.hs.c!;
|
||||
const config = headscaleConfig.getDNSConfig();
|
||||
const ns = formData.get("ns")?.toString();
|
||||
const splitName = formData.get("split_name")?.toString();
|
||||
|
||||
@@ -68,19 +76,19 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
|
||||
}
|
||||
|
||||
if (splitName === "global") {
|
||||
const servers = config.dns.nameservers.global.filter((i) => i !== ns);
|
||||
const servers = config.nameservers.filter((i) => i !== ns);
|
||||
|
||||
await context.hs.patch([
|
||||
await headscaleConfig.patch([
|
||||
{
|
||||
path: "dns.nameservers.global",
|
||||
value: servers,
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
const splits = config.dns.nameservers.split;
|
||||
const splits = config.splitDns;
|
||||
const servers = splits[splitName].filter((i) => i !== ns);
|
||||
|
||||
await context.hs.patch([
|
||||
await headscaleConfig.patch([
|
||||
{
|
||||
path: `dns.nameservers.split."${splitName}"`,
|
||||
value: servers.length > 0 ? servers : null,
|
||||
@@ -88,11 +96,11 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
|
||||
]);
|
||||
}
|
||||
|
||||
await context.integration?.onConfigChange(api);
|
||||
await integration?.onConfigChange(headscale);
|
||||
return { message: "Nameserver removed successfully" };
|
||||
}
|
||||
case "add_ns": {
|
||||
const config = context.hs.c!;
|
||||
const config = headscaleConfig.getDNSConfig();
|
||||
const ns = formData.get("ns")?.toString();
|
||||
const splitName = formData.get("split_name")?.toString();
|
||||
|
||||
@@ -101,21 +109,19 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
|
||||
}
|
||||
|
||||
if (splitName === "global") {
|
||||
const servers = config.dns.nameservers.global;
|
||||
servers.push(ns);
|
||||
const servers = [...config.nameservers, ns];
|
||||
|
||||
await context.hs.patch([
|
||||
await headscaleConfig.patch([
|
||||
{
|
||||
path: "dns.nameservers.global",
|
||||
value: servers,
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
const splits = config.dns.nameservers.split;
|
||||
const servers = splits[splitName] ?? [];
|
||||
servers.push(ns);
|
||||
const splits = config.splitDns;
|
||||
const servers = [...(splits[splitName] ?? []), ns];
|
||||
|
||||
await context.hs.patch([
|
||||
await headscaleConfig.patch([
|
||||
{
|
||||
path: `dns.nameservers.split."${splitName}"`,
|
||||
value: servers,
|
||||
@@ -123,45 +129,44 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
|
||||
]);
|
||||
}
|
||||
|
||||
await context.integration?.onConfigChange(api);
|
||||
await integration?.onConfigChange(headscale);
|
||||
return { message: "Nameserver added successfully" };
|
||||
}
|
||||
case "remove_domain": {
|
||||
const config = context.hs.c!;
|
||||
const config = headscaleConfig.getDNSConfig();
|
||||
const domain = formData.get("domain")?.toString();
|
||||
if (!domain) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
const domains = config.dns.search_domains.filter((i) => i !== domain);
|
||||
await context.hs.patch([
|
||||
const domains = config.searchDomains.filter((i) => i !== domain);
|
||||
await headscaleConfig.patch([
|
||||
{
|
||||
path: "dns.search_domains",
|
||||
value: domains,
|
||||
},
|
||||
]);
|
||||
|
||||
await context.integration?.onConfigChange(api);
|
||||
await integration?.onConfigChange(headscale);
|
||||
return { message: "Domain removed successfully" };
|
||||
}
|
||||
case "add_domain": {
|
||||
const config = context.hs.c!;
|
||||
const config = headscaleConfig.getDNSConfig();
|
||||
const domain = formData.get("domain")?.toString();
|
||||
if (!domain) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
const domains = config.dns.search_domains;
|
||||
domains.push(domain);
|
||||
const domains = [...config.searchDomains, domain];
|
||||
|
||||
await context.hs.patch([
|
||||
await headscaleConfig.patch([
|
||||
{
|
||||
path: "dns.search_domains",
|
||||
value: domains,
|
||||
},
|
||||
]);
|
||||
|
||||
await context.integration?.onConfigChange(api);
|
||||
await integration?.onConfigChange(headscale);
|
||||
return { message: "Domain added successfully" };
|
||||
}
|
||||
case "remove_record": {
|
||||
@@ -173,7 +178,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
|
||||
}
|
||||
|
||||
// Value is not needed for removal
|
||||
const restart = await context.hs.removeDNS({
|
||||
const restart = await headscaleConfig.removeDNS({
|
||||
name: recordName,
|
||||
type: recordType,
|
||||
value: "",
|
||||
@@ -183,7 +188,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
|
||||
return;
|
||||
}
|
||||
|
||||
await context.integration?.onConfigChange(api);
|
||||
await integration?.onConfigChange(headscale);
|
||||
return { message: "DNS record removed successfully" };
|
||||
}
|
||||
case "add_record": {
|
||||
@@ -195,7 +200,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
const restart = await context.hs.addDNS({
|
||||
const restart = await headscaleConfig.addDNS({
|
||||
name: recordName,
|
||||
type: recordType,
|
||||
value: recordValue,
|
||||
@@ -205,7 +210,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
|
||||
return;
|
||||
}
|
||||
|
||||
await context.integration?.onConfigChange(api);
|
||||
await integration?.onConfigChange(headscale);
|
||||
return { message: "DNS record added successfully" };
|
||||
}
|
||||
case "override_dns": {
|
||||
@@ -215,14 +220,14 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
|
||||
}
|
||||
|
||||
const overrideValue = override === "true";
|
||||
await context.hs.patch([
|
||||
await headscaleConfig.patch([
|
||||
{
|
||||
path: "dns.override_local_dns",
|
||||
value: overrideValue,
|
||||
},
|
||||
]);
|
||||
|
||||
await context.integration?.onConfigChange(api);
|
||||
await integration?.onConfigChange(headscale);
|
||||
return { message: "DNS override updated successfully" };
|
||||
}
|
||||
default:
|
||||
|
||||
+13
-19
@@ -1,12 +1,13 @@
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router";
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
import { useLoaderData } from "react-router";
|
||||
|
||||
import Code from "~/components/code";
|
||||
import Notice from "~/components/notice";
|
||||
import PageError from "~/components/page-error";
|
||||
import type { AppContext } from "~/server/context";
|
||||
import { authContext, headscaleConfigContext } from "~/server/context";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
|
||||
import type { Route } from "./+types/overview";
|
||||
import ManageDomains from "./components/manage-domains";
|
||||
import ManageNS from "./components/manage-ns";
|
||||
import ManageRecords from "./components/manage-records";
|
||||
@@ -15,13 +16,16 @@ import ToggleMagic from "./components/toggle-magic";
|
||||
import { dnsAction } from "./dns-actions";
|
||||
|
||||
// We do not want to expose every config value
|
||||
export async function loader({ request, context }: LoaderFunctionArgs<AppContext>) {
|
||||
if (!context.hs.readable()) {
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const auth = context.get(authContext);
|
||||
const headscaleConfig = context.get(headscaleConfigContext);
|
||||
|
||||
if (!headscaleConfig.readable()) {
|
||||
throw new Error("No configuration is available");
|
||||
}
|
||||
|
||||
const principal = await context.auth.require(request);
|
||||
const check = context.auth.can(principal, Capabilities.read_network);
|
||||
const principal = await auth.require(request);
|
||||
const check = auth.can(principal, Capabilities.read_network);
|
||||
if (!check) {
|
||||
// Not authorized to view this page
|
||||
throw new Error(
|
||||
@@ -29,24 +33,14 @@ export async function loader({ request, context }: LoaderFunctionArgs<AppContext
|
||||
);
|
||||
}
|
||||
|
||||
const writablePermission = context.auth.can(principal, Capabilities.write_network);
|
||||
const writablePermission = auth.can(principal, Capabilities.write_network);
|
||||
|
||||
const config = context.hs.c!;
|
||||
const dns = {
|
||||
prefixes: config.prefixes,
|
||||
magicDns: config.dns.magic_dns,
|
||||
baseDomain: config.dns.base_domain,
|
||||
nameservers: config.dns.nameservers.global,
|
||||
splitDns: config.dns.nameservers.split,
|
||||
searchDomains: config.dns.search_domains,
|
||||
overrideDns: config.dns.override_local_dns,
|
||||
extraRecords: context.hs.d,
|
||||
};
|
||||
const dns = headscaleConfig.getDNSConfig();
|
||||
|
||||
return {
|
||||
...dns,
|
||||
access: writablePermission,
|
||||
writable: context.hs.writable(),
|
||||
writable: headscaleConfig.writable(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+29
-21
@@ -10,7 +10,14 @@ import Card from "~/components/card";
|
||||
import CodeBlock from "~/components/code-block";
|
||||
import Link from "~/components/link";
|
||||
import LinkAccount from "~/layout/link-account";
|
||||
import {
|
||||
authContext,
|
||||
headscaleConfigContext,
|
||||
headscaleLiveStoreContext,
|
||||
requestApiContext,
|
||||
} from "~/server/context";
|
||||
import { usersResource } from "~/server/headscale/live-store";
|
||||
import { isUserPrincipal } from "~/server/web/auth";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
import cn from "~/utils/cn";
|
||||
import { getUserDisplayName } from "~/utils/user";
|
||||
@@ -18,24 +25,24 @@ import { getUserDisplayName } from "~/utils/user";
|
||||
import type { Route } from "./+types/home";
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const principal = await context.auth.require(request);
|
||||
const auth = context.get(authContext);
|
||||
const getRequestApi = context.get(requestApiContext);
|
||||
const headscaleConfig = context.get(headscaleConfigContext);
|
||||
const headscaleLiveStore = context.get(headscaleLiveStoreContext);
|
||||
|
||||
// If the OIDC user has no linked Headscale user, check for
|
||||
// Unclaimed users they can pick from before anything else.
|
||||
const principal = await auth.require(request);
|
||||
|
||||
// If the signed-in Headplane user has no linked Headscale user,
|
||||
// check for unclaimed users they can pick from before anything else.
|
||||
let unlinked = false;
|
||||
if (
|
||||
typeof context.oidc === "object" &&
|
||||
principal.kind === "oidc" &&
|
||||
!principal.user.headscaleUserId
|
||||
) {
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
if (isUserPrincipal(principal) && !principal.user.headscaleUserId) {
|
||||
const { api } = await getRequestApi(request);
|
||||
|
||||
let headscaleUsers: { id: string; name: string }[] = [];
|
||||
try {
|
||||
const [usersSnap, claimed] = await Promise.all([
|
||||
context.hsLive.get(usersResource, api),
|
||||
context.auth.claimedHeadscaleUserIds(),
|
||||
headscaleLiveStore.get(usersResource, api),
|
||||
auth.claimedHeadscaleUserIds(),
|
||||
]);
|
||||
|
||||
const apiUsers = usersSnap.data;
|
||||
@@ -54,23 +61,22 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
// Only warn if Headscale isn't using OIDC — if it is, the user
|
||||
// Just needs to connect a device and Headscale will auto-create
|
||||
// Their account, at which point auto-link will pick it up.
|
||||
if (!context.hs.c?.oidc) {
|
||||
if (!headscaleConfig.hasOIDCConfig()) {
|
||||
unlinked = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (context.auth.can(principal, Capabilities.ui_access)) {
|
||||
if (auth.can(principal, Capabilities.ui_access)) {
|
||||
return redirect("/machines");
|
||||
}
|
||||
|
||||
// No UI access — show the download/connect page
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
const { api } = await getRequestApi(request);
|
||||
|
||||
let linkedUserName: string | undefined;
|
||||
if (principal.kind === "oidc" && principal.user.headscaleUserId) {
|
||||
if (isUserPrincipal(principal) && principal.user.headscaleUserId) {
|
||||
try {
|
||||
const usersSnap = await context.hsLive.get(usersResource, api);
|
||||
const usersSnap = await headscaleLiveStore.get(usersResource, api);
|
||||
const hsUser = usersSnap.data.find((u) => u.id === principal.user.headscaleUserId);
|
||||
linkedUserName = hsUser?.name;
|
||||
} catch {
|
||||
@@ -82,8 +88,10 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
}
|
||||
|
||||
export async function action({ request, context }: Route.ActionArgs) {
|
||||
const principal = await context.auth.require(request);
|
||||
if (principal.kind !== "oidc") {
|
||||
const auth = context.get(authContext);
|
||||
|
||||
const principal = await auth.require(request);
|
||||
if (!isUserPrincipal(principal)) {
|
||||
return redirect("/");
|
||||
}
|
||||
|
||||
@@ -91,7 +99,7 @@ export async function action({ request, context }: Route.ActionArgs) {
|
||||
const headscaleUserId = formData.get("headscale_user_id")?.toString();
|
||||
|
||||
if (headscaleUserId) {
|
||||
await context.auth.linkHeadscaleUser(principal.user.id, headscaleUserId);
|
||||
await auth.linkHeadscaleUser(principal.user.id, headscaleUserId);
|
||||
}
|
||||
|
||||
return redirect("/");
|
||||
|
||||
@@ -12,10 +12,11 @@ import Text from "~/components/text";
|
||||
import Title from "~/components/title";
|
||||
import { useForm } from "~/hooks/use-form";
|
||||
import type { User } from "~/types";
|
||||
import { normalizeRegistrationKey } from "~/utils/register-key";
|
||||
import { getUserDisplayName } from "~/utils/user";
|
||||
|
||||
const registerSchema = type({
|
||||
register_key: "string == 24",
|
||||
register_key: "string > 0",
|
||||
user: "string > 0",
|
||||
});
|
||||
|
||||
@@ -28,7 +29,16 @@ export interface NewMachineProps {
|
||||
|
||||
export default function NewMachine(data: NewMachineProps) {
|
||||
const [pushDialog, setPushDialog] = useState(false);
|
||||
const form = useForm({ schema: registerSchema });
|
||||
const form = useForm({
|
||||
schema: registerSchema,
|
||||
validate: (values) =>
|
||||
normalizeRegistrationKey(String(values.register_key ?? ""))
|
||||
? undefined
|
||||
: {
|
||||
register_key:
|
||||
"Paste the registration URL or full hskey-authreq-... key from tailscale up.",
|
||||
},
|
||||
});
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
@@ -43,7 +53,8 @@ export default function NewMachine(data: NewMachineProps) {
|
||||
{...form.field("register_key")}
|
||||
required
|
||||
label="Machine Key"
|
||||
placeholder="AbCd..."
|
||||
placeholder="hskey-authreq-XXXXXXXXXXXXXXXXXXXXXXXX"
|
||||
description="Paste the registration URL or full key shown by tailscale up."
|
||||
/>
|
||||
<Select
|
||||
required
|
||||
@@ -52,7 +63,9 @@ export default function NewMachine(data: NewMachineProps) {
|
||||
onValueChange={(v) => form.setValue("user", v)}
|
||||
placeholder="Select a user"
|
||||
items={data.users.map((user) => ({
|
||||
value: user.id,
|
||||
// Headscale's v1/node/register endpoint resolves the owner by
|
||||
// username via GetUserByName, so we must pass user.name (not id).
|
||||
value: user.name,
|
||||
label: getUserDisplayName(user),
|
||||
}))}
|
||||
/>
|
||||
|
||||
@@ -12,6 +12,17 @@ const renameSchema = type({
|
||||
name: "string > 0",
|
||||
});
|
||||
|
||||
const dnsLabelPattern = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||
|
||||
function validateMachineName(values: Record<string, unknown>) {
|
||||
const name = String(values.name ?? "").toLowerCase();
|
||||
if (!dnsLabelPattern.test(name)) {
|
||||
return {
|
||||
name: "Use a valid DNS label: lowercase letters, numbers, and hyphens only. It must start and end with a letter or number.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface RenameProps {
|
||||
machine: Machine;
|
||||
isOpen: boolean;
|
||||
@@ -23,12 +34,13 @@ export default function Rename({ machine, magic, isOpen, setIsOpen }: RenameProp
|
||||
const form = useForm({
|
||||
schema: renameSchema,
|
||||
defaultValues: { name: machine.givenName },
|
||||
validate: validateMachineName,
|
||||
});
|
||||
const name = form.values.name as string;
|
||||
|
||||
return (
|
||||
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogPanel>
|
||||
<DialogPanel isDisabled={!form.canSubmit}>
|
||||
<Title>Edit machine name for {machine.givenName}</Title>
|
||||
<Text className="mb-6">
|
||||
This name is shown in the admin panel, in Tailscale clients, and used when generating
|
||||
|
||||
@@ -24,6 +24,10 @@ export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsP
|
||||
const submittingRef = useRef(false);
|
||||
const [tags, setTags] = useState([...machine.tags]);
|
||||
const [tag, setTag] = useState("tag:");
|
||||
const tagOptions = useMemo(
|
||||
() => (existingTags ?? []).filter((existingTag) => !tags.includes(existingTag)),
|
||||
[existingTags, tags],
|
||||
);
|
||||
const tagIsInvalid = useMemo(
|
||||
() => tag.length === 0 || !tag.startsWith("tag:") || tags.includes(tag),
|
||||
[tag, tags],
|
||||
@@ -98,6 +102,7 @@ export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsP
|
||||
onClick={() => {
|
||||
setTags(tags.filter((tag) => tag !== item));
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<X className="p-1" />
|
||||
</Button>
|
||||
@@ -124,10 +129,26 @@ export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsP
|
||||
setTags([...tags, tag]);
|
||||
setTag("tag:");
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Plus className="p-1" size={30} />
|
||||
</Button>
|
||||
</div>
|
||||
{tagOptions.length > 0 ? (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{tagOptions.map((option) => (
|
||||
<Button
|
||||
className="px-2 py-1 font-mono text-xs"
|
||||
key={option}
|
||||
onClick={() => setTags([...tags, option])}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{option}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<p className="mt-2 text-sm opacity-50">
|
||||
Not seeing the tags you expect? Tags need to be defined in your access control policy
|
||||
before they can be assigned to machines.
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import { data, redirect } from "react-router";
|
||||
|
||||
import { authContext, headscaleLiveStoreContext, requestApiContext } from "~/server/context";
|
||||
import { isDataWithApiError } from "~/server/headscale/api/error-client";
|
||||
import { nodesResource } from "~/server/headscale/live-store";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
import { normalizeRegistrationKey } from "~/utils/register-key";
|
||||
|
||||
import type { Route } from "./+types/machine";
|
||||
|
||||
export async function machineAction({ request, context }: Route.ActionArgs) {
|
||||
const principal = await context.auth.require(request);
|
||||
const auth = context.get(authContext);
|
||||
const getRequestApi = context.get(requestApiContext);
|
||||
const headscaleLiveStore = context.get(headscaleLiveStoreContext);
|
||||
|
||||
const { principal, api } = await getRequestApi(request);
|
||||
|
||||
const formData = await request.formData();
|
||||
const api = context.hsApi.getRuntimeClient(context.auth.getHeadscaleApiKey(principal));
|
||||
|
||||
const action = formData.get("action_id")?.toString();
|
||||
if (!action) {
|
||||
@@ -21,19 +26,26 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
|
||||
|
||||
// Fast track register since it doesn't require an existing machine
|
||||
if (action === "register") {
|
||||
if (!context.auth.can(principal, Capabilities.write_machines)) {
|
||||
if (!auth.can(principal, Capabilities.write_machines)) {
|
||||
throw data("You do not have permission to manage machines", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
const registrationKey = formData.get("register_key")?.toString();
|
||||
if (!registrationKey) {
|
||||
const registrationKeyInput = formData.get("register_key")?.toString();
|
||||
if (!registrationKeyInput) {
|
||||
throw data("Missing `register_key` in the form data.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const registrationKey = normalizeRegistrationKey(registrationKeyInput);
|
||||
if (!registrationKey) {
|
||||
throw data("Invalid `register_key` in the form data.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const user = formData.get("user")?.toString();
|
||||
if (!user) {
|
||||
throw data("Missing `user` in the form data.", {
|
||||
@@ -41,8 +53,8 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
|
||||
});
|
||||
}
|
||||
|
||||
const node = await api.registerNode(user, registrationKey);
|
||||
await context.hsLive.refresh(nodesResource, api);
|
||||
const node = await api.nodes.register(user, registrationKey);
|
||||
await headscaleLiveStore.refresh(nodesResource, api);
|
||||
return redirect(`/machines/${node.id}`);
|
||||
}
|
||||
|
||||
@@ -54,14 +66,14 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
|
||||
});
|
||||
}
|
||||
|
||||
const node = await api.getNode(nodeId);
|
||||
const node = await api.nodes.get(nodeId);
|
||||
if (!node) {
|
||||
throw data(`Machine with ID ${nodeId} not found`, {
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
if (!context.auth.canManageNode(principal, node)) {
|
||||
if (!auth.canManageNode(principal, node)) {
|
||||
throw data("You do not have permission to act on this machine", {
|
||||
status: 403,
|
||||
});
|
||||
@@ -77,20 +89,27 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
|
||||
}
|
||||
|
||||
const name = String(formData.get("name"));
|
||||
await api.renameNode(nodeId, name);
|
||||
await context.hsLive.refresh(nodesResource, api);
|
||||
if (!/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(name.toLowerCase())) {
|
||||
throw data(
|
||||
"Machine names must be valid DNS labels: lowercase letters, numbers, and hyphens only, and must start and end with a letter or number.",
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
await api.nodes.rename(nodeId, name);
|
||||
await headscaleLiveStore.refresh(nodesResource, api);
|
||||
return { message: "Machine renamed" };
|
||||
}
|
||||
|
||||
case "delete": {
|
||||
await api.deleteNode(nodeId);
|
||||
await context.hsLive.refresh(nodesResource, api);
|
||||
await api.nodes.delete(nodeId);
|
||||
await headscaleLiveStore.refresh(nodesResource, api);
|
||||
return redirect("/machines");
|
||||
}
|
||||
|
||||
case "expire": {
|
||||
await api.expireNode(nodeId);
|
||||
await context.hsLive.refresh(nodesResource, api);
|
||||
await api.nodes.expire(nodeId);
|
||||
await headscaleLiveStore.refresh(nodesResource, api);
|
||||
return { message: "Machine expired" };
|
||||
}
|
||||
|
||||
@@ -103,12 +122,12 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
|
||||
}
|
||||
|
||||
try {
|
||||
await api.setNodeTags(
|
||||
await api.nodes.setTags(
|
||||
nodeId,
|
||||
tags.map((tag) => tag.trim()).filter((tag) => tag !== ""),
|
||||
);
|
||||
|
||||
await context.hsLive.refresh(nodesResource, api);
|
||||
await headscaleLiveStore.refresh(nodesResource, api);
|
||||
return { success: true as const, message: "Tags updated" };
|
||||
} catch (error) {
|
||||
if (isDataWithApiError(error) && error.data.statusCode === 400) {
|
||||
@@ -116,6 +135,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
|
||||
{
|
||||
success: false as const,
|
||||
error:
|
||||
extractApiErrorMessage(error.data) ??
|
||||
"One or more tags are not defined in your ACL policy. Please add them to your policy before assigning them to a machine.",
|
||||
},
|
||||
{ status: 400 },
|
||||
@@ -172,8 +192,8 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
|
||||
}
|
||||
}
|
||||
|
||||
await api.approveNodeRoutes(nodeId, newApproved);
|
||||
await context.hsLive.refresh(nodesResource, api);
|
||||
await api.nodes.approveRoutes(nodeId, newApproved);
|
||||
await headscaleLiveStore.refresh(nodesResource, api);
|
||||
return { message: "Routes updated" };
|
||||
}
|
||||
|
||||
@@ -185,8 +205,13 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
|
||||
});
|
||||
}
|
||||
|
||||
await api.setNodeUser(nodeId, user);
|
||||
await context.hsLive.refresh(nodesResource, api);
|
||||
if (!api.nodes.reassignUser) {
|
||||
throw data("Reassigning a node owner is no longer supported on this Headscale version.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
await api.nodes.reassignUser(nodeId, user);
|
||||
await headscaleLiveStore.refresh(nodesResource, api);
|
||||
return { message: "Machine reassigned" };
|
||||
}
|
||||
|
||||
@@ -196,3 +221,14 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function extractApiErrorMessage(error: { data?: unknown; rawData: string }) {
|
||||
if (error.data != null && typeof error.data === "object" && "message" in error.data) {
|
||||
const message = (error.data as { message?: unknown }).message;
|
||||
if (typeof message === "string" && message.length > 0) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
return error.rawData.length > 0 ? error.rawData : undefined;
|
||||
}
|
||||
|
||||
@@ -9,10 +9,17 @@ import Chip from "~/components/chip";
|
||||
import Link from "~/components/link";
|
||||
import StatusCircle from "~/components/status-circle";
|
||||
import Tooltip from "~/components/tooltip";
|
||||
import {
|
||||
agentsContext,
|
||||
headscaleConfigContext,
|
||||
headscaleContext,
|
||||
headscaleLiveStoreContext,
|
||||
requestApiContext,
|
||||
} from "~/server/context";
|
||||
import { nodesResource, usersResource } from "~/server/headscale/live-store";
|
||||
import cn from "~/utils/cn";
|
||||
import { getOSInfo, getTSVersion } from "~/utils/host-info";
|
||||
import { isNoExpiry, mapNodes, sortNodeTags } from "~/utils/node-info";
|
||||
import { isNoExpiry, mapNodes, sortAssignableTags } from "~/utils/node-info";
|
||||
import { getUserDisplayName } from "~/utils/user";
|
||||
|
||||
import type { Route } from "./+types/machine";
|
||||
@@ -22,7 +29,12 @@ import Routes from "./dialogs/routes";
|
||||
import { machineAction } from "./machine-actions";
|
||||
|
||||
export async function loader({ request, params, context }: Route.LoaderArgs) {
|
||||
const principal = await context.auth.require(request);
|
||||
const agentsFeature = context.get(agentsContext);
|
||||
const getRequestApi = context.get(requestApiContext);
|
||||
const headscale = context.get(headscaleContext);
|
||||
const headscaleConfig = context.get(headscaleConfigContext);
|
||||
const headscaleLiveStore = context.get(headscaleLiveStoreContext);
|
||||
|
||||
if (!params.id) {
|
||||
throw new Error("No machine ID provided");
|
||||
}
|
||||
@@ -31,17 +43,12 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
|
||||
throw data(null, { status: 204 });
|
||||
}
|
||||
|
||||
let magic: string | undefined;
|
||||
if (context.hs.readable()) {
|
||||
if (context.hs.c?.dns.magic_dns) {
|
||||
magic = context.hs.c.dns.base_domain;
|
||||
}
|
||||
}
|
||||
const magic = headscaleConfig.getMagicDNSBaseDomain();
|
||||
|
||||
const api = context.hsApi.getRuntimeClient(context.auth.getHeadscaleApiKey(principal));
|
||||
const { api } = await getRequestApi(request);
|
||||
const [nodesSnap, usersSnap] = await Promise.all([
|
||||
context.hsLive.get(nodesResource, api),
|
||||
context.hsLive.get(usersResource, api),
|
||||
headscaleLiveStore.get(nodesResource, api),
|
||||
headscaleLiveStore.get(usersResource, api),
|
||||
]);
|
||||
const nodes = nodesSnap.data;
|
||||
const users = usersSnap.data;
|
||||
@@ -50,24 +57,30 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
|
||||
throw data(null, { status: 404 });
|
||||
}
|
||||
|
||||
const lookup = await context.agents?.lookup([node.nodeKey]);
|
||||
const [enhancedNode] = mapNodes([node], lookup);
|
||||
const agents = agentsFeature.state === "enabled" ? agentsFeature.value : undefined;
|
||||
const [lookup, policyResult] = await Promise.allSettled([
|
||||
agents?.lookup([node.nodeKey]),
|
||||
api.policy.get(),
|
||||
]);
|
||||
const stats = lookup.status === "fulfilled" ? lookup.value : undefined;
|
||||
const [enhancedNode] = mapNodes([node], stats);
|
||||
const tags = [...node.tags].toSorted();
|
||||
const supportsNodeOwnerChange = !context.hsApi.clientHelpers.isAtleast("0.28.0");
|
||||
const agentSync = context.agents?.lastSync();
|
||||
const supportsNodeOwnerChange = !headscale.capabilities.nodeOwnerIsImmutable;
|
||||
const agentSync = agents?.lastSync();
|
||||
const policy = policyResult.status === "fulfilled" ? policyResult.value.policy : undefined;
|
||||
|
||||
return {
|
||||
agent: agentSync
|
||||
? {
|
||||
syncedAt: agentSync.syncedAt?.toISOString() ?? null,
|
||||
nodeCount: agentSync.nodeCount,
|
||||
nodeKey: context.agents?.agentNodeKey(),
|
||||
nodeKey: agents?.agentNodeKey(),
|
||||
}
|
||||
: undefined,
|
||||
existingTags: sortNodeTags(nodes),
|
||||
existingTags: sortAssignableTags(nodes, policy),
|
||||
magic,
|
||||
node: enhancedNode,
|
||||
stats: lookup?.[enhancedNode.nodeKey],
|
||||
stats: stats?.[enhancedNode.nodeKey],
|
||||
supportsNodeOwnerChange: supportsNodeOwnerChange,
|
||||
tags,
|
||||
users,
|
||||
|
||||
@@ -7,10 +7,20 @@ import Input from "~/components/input";
|
||||
import Link from "~/components/link";
|
||||
import PageError from "~/components/page-error";
|
||||
import Tooltip from "~/components/tooltip";
|
||||
import {
|
||||
agentsContext,
|
||||
appConfigContext,
|
||||
authContext,
|
||||
headscaleConfigContext,
|
||||
headscaleContext,
|
||||
headscaleLiveStoreContext,
|
||||
requestApiContext,
|
||||
} from "~/server/context";
|
||||
import { nodesResource, usersResource } from "~/server/headscale/live-store";
|
||||
import { isUserPrincipal } from "~/server/web/auth";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
import cn from "~/utils/cn";
|
||||
import { mapNodes, sortNodeTags, type PopulatedNode } from "~/utils/node-info";
|
||||
import { mapNodes, sortAssignableTags, type PopulatedNode } from "~/utils/node-info";
|
||||
|
||||
import type { Route } from "./+types/overview";
|
||||
import { MachineFilters } from "./components/machine-filters";
|
||||
@@ -20,51 +30,61 @@ import { useMachineFilterParams } from "./hooks/use-machine-filter-params";
|
||||
import { machineAction } from "./machine-actions";
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const principal = await context.auth.require(request);
|
||||
const agentsFeature = context.get(agentsContext);
|
||||
const auth = context.get(authContext);
|
||||
const config = context.get(appConfigContext);
|
||||
const getRequestApi = context.get(requestApiContext);
|
||||
const headscale = context.get(headscaleContext);
|
||||
const headscaleConfig = context.get(headscaleConfigContext);
|
||||
const headscaleLiveStore = context.get(headscaleLiveStoreContext);
|
||||
|
||||
if (!context.auth.can(principal, Capabilities.read_machines)) {
|
||||
const principal = await auth.require(request);
|
||||
|
||||
if (!auth.can(principal, Capabilities.read_machines)) {
|
||||
throw new Error(
|
||||
"You do not have permission to view this page. Please contact your administrator.",
|
||||
);
|
||||
}
|
||||
|
||||
const writablePermission = context.auth.can(principal, Capabilities.write_machines);
|
||||
const writablePermission = auth.can(principal, Capabilities.write_machines);
|
||||
|
||||
const api = context.hsApi.getRuntimeClient(context.auth.getHeadscaleApiKey(principal));
|
||||
const { api } = await getRequestApi(request);
|
||||
const [nodesSnap, usersSnap] = await Promise.all([
|
||||
context.hsLive.get(nodesResource, api),
|
||||
context.hsLive.get(usersResource, api),
|
||||
headscaleLiveStore.get(nodesResource, api),
|
||||
headscaleLiveStore.get(usersResource, api),
|
||||
]);
|
||||
const nodes = nodesSnap.data;
|
||||
const users = usersSnap.data;
|
||||
|
||||
let magic: string | undefined;
|
||||
if (context.hs.readable()) {
|
||||
if (context.hs.c?.dns.magic_dns) {
|
||||
magic = context.hs.c.dns.base_domain;
|
||||
}
|
||||
}
|
||||
const magic = headscaleConfig.getMagicDNSBaseDomain();
|
||||
|
||||
const stats = await context.agents?.lookup(nodes.map((node) => node.nodeKey));
|
||||
const agents = agentsFeature.state === "enabled" ? agentsFeature.value : undefined;
|
||||
const [statsResult, policyResult] = await Promise.allSettled([
|
||||
agents?.lookup(nodes.map((node) => node.nodeKey)),
|
||||
api.policy.get(),
|
||||
]);
|
||||
const stats = statsResult.status === "fulfilled" ? statsResult.value : undefined;
|
||||
const policy = policyResult.status === "fulfilled" ? policyResult.value.policy : undefined;
|
||||
const populatedNodes = mapNodes(nodes, stats);
|
||||
const supportsNodeOwnerChange = !context.hsApi.clientHelpers.isAtleast("0.28.0");
|
||||
const agentSync = context.agents?.lastSync();
|
||||
const supportsNodeOwnerChange = !headscale.capabilities.nodeOwnerIsImmutable;
|
||||
const agentSync = agents?.lastSync();
|
||||
|
||||
return {
|
||||
agent: agentSync
|
||||
? {
|
||||
syncedAt: agentSync.syncedAt?.toISOString() ?? null,
|
||||
nodeCount: agentSync.nodeCount,
|
||||
nodeKey: context.agents?.agentNodeKey(),
|
||||
nodeKey: agents?.agentNodeKey(),
|
||||
}
|
||||
: undefined,
|
||||
headscaleUserId: principal.kind === "oidc" ? principal.user.headscaleUserId : undefined,
|
||||
headscaleUserId: isUserPrincipal(principal) ? principal.user.headscaleUserId : undefined,
|
||||
existingTags: sortAssignableTags(nodes, policy),
|
||||
magic,
|
||||
nodes,
|
||||
populatedNodes,
|
||||
preAuth: context.auth.can(principal, Capabilities.generate_authkeys),
|
||||
publicServer: context.config.headscale.public_url,
|
||||
server: context.config.headscale.url,
|
||||
preAuth: auth.can(principal, Capabilities.generate_authkeys),
|
||||
publicServer: config.headscale.public_url,
|
||||
server: config.headscale.url,
|
||||
supportsNodeOwnerChange: supportsNodeOwnerChange,
|
||||
users,
|
||||
writable: writablePermission,
|
||||
@@ -422,7 +442,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
|
||||
) : (
|
||||
filteredAndSortedNodes.map((node) => (
|
||||
<MachineRow
|
||||
existingTags={sortNodeTags(loaderData.nodes)}
|
||||
existingTags={loaderData.existingTags}
|
||||
isAgent={
|
||||
loaderData.agent !== undefined
|
||||
? node.nodeKey === loaderData.agent.nodeKey
|
||||
|
||||
@@ -6,36 +6,48 @@ import Notice from "~/components/notice";
|
||||
import StatusCircle from "~/components/status-circle";
|
||||
import Text from "~/components/text";
|
||||
import Title from "~/components/title";
|
||||
import { agentsContext, authContext } from "~/server/context";
|
||||
import { formatTimeDelta } from "~/utils/time";
|
||||
|
||||
import type { Route } from "./+types/agent";
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const principal = await context.auth.require(request);
|
||||
const agents = context.get(agentsContext);
|
||||
const auth = context.get(authContext);
|
||||
|
||||
if (!context.agents) {
|
||||
return { enabled: false as const };
|
||||
await auth.require(request);
|
||||
|
||||
if (agents.state !== "enabled") {
|
||||
return { enabled: false as const, reason: agents.reason };
|
||||
}
|
||||
|
||||
const sync = context.agents.lastSync();
|
||||
const sync = agents.value.lastSync();
|
||||
return {
|
||||
enabled: true as const,
|
||||
syncedAt: sync.syncedAt?.toISOString() ?? null,
|
||||
nodeCount: sync.nodeCount,
|
||||
error: sync.error,
|
||||
authUrl: sync.authUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export async function action({ request, context }: Route.ActionArgs) {
|
||||
await context.auth.require(request);
|
||||
const agents = context.get(agentsContext);
|
||||
const auth = context.get(authContext);
|
||||
|
||||
if (!context.agents) {
|
||||
return { success: false, error: "Agent is not enabled" };
|
||||
await auth.require(request);
|
||||
|
||||
if (agents.state !== "enabled") {
|
||||
return { success: false, error: agents.reason };
|
||||
}
|
||||
|
||||
await context.agents.triggerSync();
|
||||
const sync = context.agents.lastSync();
|
||||
return { success: !sync.error, error: sync.error };
|
||||
await agents.value.triggerSync();
|
||||
const sync = agents.value.lastSync();
|
||||
return {
|
||||
success: !sync.error,
|
||||
error: sync.error,
|
||||
authUrl: sync.authUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export default function Page({ loaderData }: Route.ComponentProps) {
|
||||
@@ -47,8 +59,8 @@ export default function Page({ loaderData }: Route.ComponentProps) {
|
||||
<div className="flex max-w-(--breakpoint-lg) flex-col gap-8">
|
||||
<Title>Headplane Agent</Title>
|
||||
<Notice title="Agent Not Enabled">
|
||||
The Headplane Agent is not enabled. To learn how to set up the agent, visit the{" "}
|
||||
<Link external styled to="https://headplane.dev/docs/agent">
|
||||
{loaderData.reason}. To learn how to set up the agent, visit the{" "}
|
||||
<Link external styled to="https://headplane.net/features/agent">
|
||||
documentation
|
||||
</Link>
|
||||
</Notice>
|
||||
@@ -56,6 +68,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const isPending = !loaderData.syncedAt && loaderData.authUrl;
|
||||
const hasError = Boolean(loaderData.error);
|
||||
|
||||
return (
|
||||
@@ -69,8 +82,10 @@ export default function Page({ loaderData }: Route.ComponentProps) {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusCircle isOnline={!hasError} className="h-5 w-5" />
|
||||
<span className="text-lg font-medium">{hasError ? "Error" : "Healthy"}</span>
|
||||
<StatusCircle isOnline={!hasError && !isPending} className="h-5 w-5" />
|
||||
<span className="text-lg font-medium">
|
||||
{hasError ? "Error" : isPending ? "Waiting for approval" : "Healthy"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -88,6 +103,17 @@ export default function Page({ loaderData }: Route.ComponentProps) {
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{isPending ? (
|
||||
<Notice variant="warning" title="Agent Needs Approval">
|
||||
The agent is waiting for its Tailnet registration to be approved. Headplane will attempt
|
||||
to auto-approve it, but if that fails, you can complete approval by visiting{" "}
|
||||
<Link external styled to={loaderData.authUrl!}>
|
||||
this link
|
||||
</Link>
|
||||
.
|
||||
</Notice>
|
||||
) : undefined}
|
||||
|
||||
{loaderData.error ? (
|
||||
<Notice variant="error" title="Sync Error">
|
||||
{loaderData.error}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { data } from "react-router";
|
||||
|
||||
import { authContext, requestApiContext } from "~/server/context";
|
||||
import { isUserPrincipal } from "~/server/web/auth";
|
||||
import { getOidcSubject } from "~/server/web/headscale-identity";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
import type { PreAuthKey } from "~/types";
|
||||
@@ -7,12 +9,13 @@ import type { PreAuthKey } from "~/types";
|
||||
import type { Route } from "./+types/overview";
|
||||
|
||||
export async function authKeysAction({ request, context }: Route.ActionArgs) {
|
||||
const principal = await context.auth.require(request);
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
const auth = context.get(authContext);
|
||||
const getRequestApi = context.get(requestApiContext);
|
||||
|
||||
const canGenerateAny = context.auth.can(principal, Capabilities.generate_authkeys);
|
||||
const canGenerateOwn = context.auth.can(principal, Capabilities.generate_own_authkeys);
|
||||
const { principal, api } = await getRequestApi(request);
|
||||
|
||||
const canGenerateAny = auth.can(principal, Capabilities.generate_authkeys);
|
||||
const canGenerateOwn = auth.can(principal, Capabilities.generate_own_authkeys);
|
||||
|
||||
if (!canGenerateAny && !canGenerateOwn) {
|
||||
throw data("You do not have permission to manage pre-auth keys", {
|
||||
@@ -22,12 +25,15 @@ export async function authKeysAction({ request, context }: Route.ActionArgs) {
|
||||
|
||||
async function checkSelfServiceOwnership(userId: string) {
|
||||
if (canGenerateAny || !canGenerateOwn) return;
|
||||
const [targetUser] = await api.getUsers(userId);
|
||||
const [targetUser] = await api.users.list({ id: userId });
|
||||
if (!targetUser) {
|
||||
throw data("User not found.", { status: 404 });
|
||||
}
|
||||
const targetSubject = getOidcSubject(targetUser);
|
||||
if (principal.kind !== "oidc" || targetSubject !== principal.user.subject) {
|
||||
const ownsTarget =
|
||||
isUserPrincipal(principal) &&
|
||||
(principal.user.headscaleUserId === userId || targetSubject === principal.user.subject);
|
||||
if (!ownsTarget) {
|
||||
throw data("You do not have permission to manage this user's pre-auth keys", {
|
||||
status: 403,
|
||||
});
|
||||
@@ -86,13 +92,13 @@ export async function authKeysAction({ request, context }: Route.ActionArgs) {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + day);
|
||||
|
||||
const key = await api.createPreAuthKey(
|
||||
const key = await api.preAuthKeys.create({
|
||||
user,
|
||||
ephemeral === "on",
|
||||
reusable === "on",
|
||||
date,
|
||||
aclTags.length > 0 ? aclTags : null,
|
||||
);
|
||||
ephemeral: ephemeral === "on",
|
||||
reusable: reusable === "on",
|
||||
expiration: date,
|
||||
aclTags: aclTags.length > 0 ? aclTags : null,
|
||||
});
|
||||
|
||||
return data({ success: true as const, key: key.key });
|
||||
}
|
||||
@@ -114,7 +120,15 @@ export async function authKeysAction({ request, context }: Route.ActionArgs) {
|
||||
}
|
||||
|
||||
await checkSelfServiceOwnership(user);
|
||||
await api.expirePreAuthKey(user, { id: keyId, key } as unknown as PreAuthKey);
|
||||
// `user` here is the Headscale numeric user id (form field is wired
|
||||
// from User.id). Pre-0.28 expire posts a uint64 `user` field, which
|
||||
// the API layer reads from `key.user?.id`. Headscale 0.28+ only
|
||||
// looks at `key.id` (the stable preauthkey id).
|
||||
await api.preAuthKeys.expire({
|
||||
id: keyId,
|
||||
key,
|
||||
user: { id: user },
|
||||
} as unknown as PreAuthKey);
|
||||
return data("Pre-auth key expired");
|
||||
}
|
||||
|
||||
|
||||
@@ -18,10 +18,22 @@ interface AddAuthKeyProps {
|
||||
users: User[];
|
||||
url: string;
|
||||
selfServiceOnly: boolean;
|
||||
currentHeadscaleUserId?: string;
|
||||
currentSubject?: string;
|
||||
}
|
||||
|
||||
function findCurrentUser(users: User[], subject: string | undefined): User | undefined {
|
||||
function findCurrentUser(
|
||||
users: User[],
|
||||
headscaleUserId: string | undefined,
|
||||
subject: string | undefined,
|
||||
): User | undefined {
|
||||
if (headscaleUserId) {
|
||||
const linked = users.find((u) => u.id === headscaleUserId);
|
||||
if (linked) {
|
||||
return linked;
|
||||
}
|
||||
}
|
||||
|
||||
if (!subject) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -38,6 +50,7 @@ export default function AddAuthKey({
|
||||
users,
|
||||
url,
|
||||
selfServiceOnly,
|
||||
currentHeadscaleUserId,
|
||||
currentSubject,
|
||||
}: AddAuthKeyProps) {
|
||||
const fetcher = useFetcher();
|
||||
@@ -46,7 +59,9 @@ export default function AddAuthKey({
|
||||
const [reusable, setReusable] = useState(false);
|
||||
const [ephemeral, setEphemeral] = useState(false);
|
||||
const [tagOnly, setTagOnly] = useState(false);
|
||||
const currentUser = selfServiceOnly ? findCurrentUser(users, currentSubject) : null;
|
||||
const currentUser = selfServiceOnly
|
||||
? findCurrentUser(users, currentHeadscaleUserId, currentSubject)
|
||||
: null;
|
||||
const availableUsers = selfServiceOnly && currentUser ? [currentUser] : users;
|
||||
const [userId, setUserId] = useState<string | null>(availableUsers[0]?.id);
|
||||
const [tags, setTags] = useState("");
|
||||
|
||||
@@ -6,7 +6,14 @@ import Link from "~/components/link";
|
||||
import Notice from "~/components/notice";
|
||||
import Select from "~/components/select";
|
||||
import TableList from "~/components/table-list";
|
||||
import {
|
||||
appConfigContext,
|
||||
authContext,
|
||||
headscaleLiveStoreContext,
|
||||
requestApiContext,
|
||||
} from "~/server/context";
|
||||
import { usersResource } from "~/server/headscale/live-store";
|
||||
import { isUserPrincipal } from "~/server/web/auth";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
import type { PreAuthKey } from "~/types";
|
||||
import type { User } from "~/types/User";
|
||||
@@ -19,11 +26,14 @@ import AuthKeyRow from "./auth-key-row";
|
||||
import AddAuthKey from "./dialogs/add-auth-key";
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const principal = await context.auth.require(request);
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
const auth = context.get(authContext);
|
||||
const config = context.get(appConfigContext);
|
||||
const getRequestApi = context.get(requestApiContext);
|
||||
const headscaleLiveStore = context.get(headscaleLiveStoreContext);
|
||||
|
||||
const usersSnap = await context.hsLive.get(usersResource, api);
|
||||
const { principal, api } = await getRequestApi(request);
|
||||
|
||||
const usersSnap = await headscaleLiveStore.get(usersResource, api);
|
||||
const users = usersSnap.data;
|
||||
|
||||
let keys: { user: User | null; preAuthKeys: PreAuthKey[] }[];
|
||||
@@ -31,10 +41,12 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
|
||||
// Try fetching all keys at once (Headscale 0.28+), fall back to per-user
|
||||
let allKeys: PreAuthKey[] | null = null;
|
||||
try {
|
||||
allKeys = await api.getAllPreAuthKeys();
|
||||
} catch {
|
||||
// Older versions don't support this endpoint
|
||||
if (api.preAuthKeys.listAll) {
|
||||
try {
|
||||
allKeys = await api.preAuthKeys.listAll();
|
||||
} catch {
|
||||
// Treat any failure as "no global list available" and fall through.
|
||||
}
|
||||
}
|
||||
|
||||
if (allKeys !== null) {
|
||||
@@ -67,7 +79,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
.filter((u) => u.id?.length > 0)
|
||||
.map(async (user) => {
|
||||
try {
|
||||
const preAuthKeys = await api.getPreAuthKeys(user.id);
|
||||
const preAuthKeys = await api.preAuthKeys.listForUser(user.id);
|
||||
return { preAuthKeys, success: true as const, user };
|
||||
} catch (error) {
|
||||
log.error("api", "GET /v1/preauthkey for %s: %o", user.name, error);
|
||||
@@ -85,16 +97,17 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
.map(({ user, error }) => ({ error, user }));
|
||||
}
|
||||
|
||||
const canGenerateAny = context.auth.can(principal, Capabilities.generate_authkeys);
|
||||
const canGenerateOwn = context.auth.can(principal, Capabilities.generate_own_authkeys);
|
||||
const canGenerateAny = auth.can(principal, Capabilities.generate_authkeys);
|
||||
const canGenerateOwn = auth.can(principal, Capabilities.generate_own_authkeys);
|
||||
|
||||
return {
|
||||
access: canGenerateAny || canGenerateOwn,
|
||||
currentSubject: principal.kind === "oidc" ? principal.user.subject : undefined,
|
||||
currentHeadscaleUserId: isUserPrincipal(principal) ? principal.user.headscaleUserId : undefined,
|
||||
currentSubject: isUserPrincipal(principal) ? principal.user.subject : undefined,
|
||||
keys,
|
||||
missing,
|
||||
selfServiceOnly: !canGenerateAny && canGenerateOwn,
|
||||
url: context.config.headscale.public_url ?? context.config.headscale.url,
|
||||
url: config.headscale.public_url ?? config.headscale.url,
|
||||
users,
|
||||
};
|
||||
}
|
||||
@@ -103,7 +116,16 @@ export const action = authKeysAction;
|
||||
|
||||
type Status = "all" | "active" | "expired" | "reusable" | "ephemeral";
|
||||
export default function Page({
|
||||
loaderData: { keys, missing, users, url, access, selfServiceOnly, currentSubject },
|
||||
loaderData: {
|
||||
keys,
|
||||
missing,
|
||||
users,
|
||||
url,
|
||||
access,
|
||||
selfServiceOnly,
|
||||
currentHeadscaleUserId,
|
||||
currentSubject,
|
||||
},
|
||||
}: Route.ComponentProps) {
|
||||
const [selectedUser, setSelectedUser] = useState("__headplane_all");
|
||||
const [status, setStatus] = useState<Status>("active");
|
||||
@@ -199,6 +221,7 @@ export default function Page({
|
||||
</Link>
|
||||
</p>
|
||||
<AddAuthKey
|
||||
currentHeadscaleUserId={currentHeadscaleUserId}
|
||||
currentSubject={currentSubject}
|
||||
selfServiceOnly={selfServiceOnly}
|
||||
url={url}
|
||||
|
||||
@@ -2,13 +2,17 @@ import { ArrowRight } from "lucide-react";
|
||||
|
||||
import Link from "~/components/link";
|
||||
import PageError from "~/components/page-error";
|
||||
import { headscaleConfigContext, oidcContext } from "~/server/context";
|
||||
|
||||
import type { Route } from "./+types/overview";
|
||||
|
||||
export async function loader({ context }: Route.LoaderArgs) {
|
||||
const headscaleConfig = context.get(headscaleConfigContext);
|
||||
const oidc = context.get(oidcContext);
|
||||
|
||||
return {
|
||||
config: context.hs.writable(),
|
||||
isOidcEnabled: context.oidc?.service.status().state === "ready",
|
||||
config: headscaleConfig.writable(),
|
||||
isOidcEnabled: oidc.state === "enabled" && oidc.value.status().state === "ready",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
import { data } from "react-router";
|
||||
|
||||
import {
|
||||
authContext,
|
||||
headscaleConfigContext,
|
||||
headscaleContext,
|
||||
integrationContext,
|
||||
} from "~/server/context";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
|
||||
import type { Route } from "./+types/overview";
|
||||
|
||||
export async function restrictionAction({ request, context }: Route.ActionArgs) {
|
||||
const principal = await context.auth.require(request);
|
||||
const check = context.auth.can(principal, Capabilities.configure_iam);
|
||||
const auth = context.get(authContext);
|
||||
const headscale = context.get(headscaleContext);
|
||||
const headscaleConfig = context.get(headscaleConfigContext);
|
||||
const integration = context.get(integrationContext);
|
||||
|
||||
const principal = await auth.require(request);
|
||||
const check = auth.can(principal, Capabilities.configure_iam);
|
||||
|
||||
if (!check) {
|
||||
throw data("You do not have permission to modify IAM settings.", {
|
||||
@@ -14,7 +25,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
|
||||
});
|
||||
}
|
||||
|
||||
if (!context.hs.writable()) {
|
||||
if (!headscaleConfig.writable()) {
|
||||
throw data("The Headscale configuration file is not editable.", {
|
||||
status: 403,
|
||||
});
|
||||
@@ -28,8 +39,6 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
|
||||
});
|
||||
}
|
||||
|
||||
// We only need healthchecks which don't rely on an API key
|
||||
const api = context.hsApi.getRuntimeClient("fake-api-key");
|
||||
switch (action) {
|
||||
case "add_domain": {
|
||||
const domain = formData.get("domain")?.toString()?.trim();
|
||||
@@ -39,16 +48,18 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
|
||||
});
|
||||
}
|
||||
|
||||
const domains = [...new Set([...(context.hs.c?.oidc?.allowed_domains ?? []), domain])];
|
||||
const domains = [
|
||||
...new Set([...(headscaleConfig.getOIDCConfig()?.allowedDomains ?? []), domain]),
|
||||
];
|
||||
|
||||
await context.hs.patch([
|
||||
await headscaleConfig.patch([
|
||||
{
|
||||
path: "oidc.allowed_domains",
|
||||
value: domains,
|
||||
},
|
||||
]);
|
||||
|
||||
context.integration?.onConfigChange(api);
|
||||
integration?.onConfigChange(headscale);
|
||||
return data("Domain added successfully.");
|
||||
}
|
||||
|
||||
@@ -60,7 +71,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
|
||||
});
|
||||
}
|
||||
|
||||
const storedDomains = context.hs.c?.oidc?.allowed_domains ?? [];
|
||||
const storedDomains = headscaleConfig.getOIDCConfig()?.allowedDomains ?? [];
|
||||
if (!storedDomains.includes(domain)) {
|
||||
// Domain not found in the list
|
||||
throw data(`Domain "${domain}" not found in allowed domains.`, {
|
||||
@@ -70,13 +81,13 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
|
||||
|
||||
// Filter out the domain to remove it from the list
|
||||
const domains = storedDomains.filter((d: string) => d !== domain);
|
||||
await context.hs.patch([
|
||||
await headscaleConfig.patch([
|
||||
{
|
||||
path: "oidc.allowed_domains",
|
||||
value: domains,
|
||||
},
|
||||
]);
|
||||
context.integration?.onConfigChange(api);
|
||||
integration?.onConfigChange(headscale);
|
||||
return data("Domain removed successfully.");
|
||||
}
|
||||
|
||||
@@ -88,16 +99,18 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
|
||||
});
|
||||
}
|
||||
|
||||
const groups = [...new Set([...(context.hs.c?.oidc?.allowed_groups ?? []), group])];
|
||||
const groups = [
|
||||
...new Set([...(headscaleConfig.getOIDCConfig()?.allowedGroups ?? []), group]),
|
||||
];
|
||||
|
||||
await context.hs.patch([
|
||||
await headscaleConfig.patch([
|
||||
{
|
||||
path: "oidc.allowed_groups",
|
||||
value: groups,
|
||||
},
|
||||
]);
|
||||
|
||||
context.integration?.onConfigChange(api);
|
||||
integration?.onConfigChange(headscale);
|
||||
return data("Group added successfully.");
|
||||
}
|
||||
|
||||
@@ -109,7 +122,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
|
||||
});
|
||||
}
|
||||
|
||||
const storedGroups = context.hs.c?.oidc?.allowed_groups ?? [];
|
||||
const storedGroups = headscaleConfig.getOIDCConfig()?.allowedGroups ?? [];
|
||||
if (!storedGroups.includes(group)) {
|
||||
// Group not found in the list
|
||||
throw data(`Group "${group}" not found in allowed groups.`, {
|
||||
@@ -119,14 +132,14 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
|
||||
|
||||
// Filter out the group to remove it from the list
|
||||
const groups = storedGroups.filter((d: string) => d !== group);
|
||||
await context.hs.patch([
|
||||
await headscaleConfig.patch([
|
||||
{
|
||||
path: "oidc.allowed_groups",
|
||||
value: groups,
|
||||
},
|
||||
]);
|
||||
|
||||
context.integration?.onConfigChange(api);
|
||||
integration?.onConfigChange(headscale);
|
||||
return data("Group removed successfully.");
|
||||
}
|
||||
|
||||
@@ -138,16 +151,16 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
|
||||
});
|
||||
}
|
||||
|
||||
const users = [...new Set([...(context.hs.c?.oidc?.allowed_users ?? []), user])];
|
||||
const users = [...new Set([...(headscaleConfig.getOIDCConfig()?.allowedUsers ?? []), user])];
|
||||
|
||||
await context.hs.patch([
|
||||
await headscaleConfig.patch([
|
||||
{
|
||||
path: "oidc.allowed_users",
|
||||
value: users,
|
||||
},
|
||||
]);
|
||||
|
||||
context.integration?.onConfigChange(api);
|
||||
integration?.onConfigChange(headscale);
|
||||
return data("User added successfully.");
|
||||
}
|
||||
|
||||
@@ -159,7 +172,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
|
||||
});
|
||||
}
|
||||
|
||||
const storedUsers = context.hs.c?.oidc?.allowed_users ?? [];
|
||||
const storedUsers = headscaleConfig.getOIDCConfig()?.allowedUsers ?? [];
|
||||
if (!storedUsers.includes(user)) {
|
||||
// User not found in the list
|
||||
throw data(`User "${user}" not found in allowed users.`, {
|
||||
@@ -169,14 +182,14 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
|
||||
|
||||
// Filter out the user to remove it from the list
|
||||
const users = storedUsers.filter((d: string) => d !== user);
|
||||
await context.hs.patch([
|
||||
await headscaleConfig.patch([
|
||||
{
|
||||
path: "oidc.allowed_users",
|
||||
value: users,
|
||||
},
|
||||
]);
|
||||
|
||||
context.integration?.onConfigChange(api);
|
||||
integration?.onConfigChange(headscale);
|
||||
return data("User removed successfully.");
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { data } from "react-router";
|
||||
|
||||
import Link from "~/components/link";
|
||||
import Notice from "~/components/notice";
|
||||
import { authContext, headscaleConfigContext } from "~/server/context";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
|
||||
import type { Route } from "./+types/overview";
|
||||
@@ -12,28 +13,32 @@ import AddUser from "./dialogs/add-user";
|
||||
import RestrictionTable from "./table";
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const principal = await context.auth.require(request);
|
||||
const check = context.auth.can(principal, Capabilities.read_users);
|
||||
const auth = context.get(authContext);
|
||||
const headscaleConfig = context.get(headscaleConfigContext);
|
||||
|
||||
const principal = await auth.require(request);
|
||||
const check = auth.can(principal, Capabilities.read_users);
|
||||
if (!check) {
|
||||
throw data("You do not have permission to view IAM settings.", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
if (!context.hs.c?.oidc) {
|
||||
const oidc = headscaleConfig.getOIDCConfig();
|
||||
if (!oidc) {
|
||||
throw data("OIDC is not configured on this Headscale instance.", {
|
||||
status: 501,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
access: context.auth.can(principal, Capabilities.configure_iam),
|
||||
access: auth.can(principal, Capabilities.configure_iam),
|
||||
settings: {
|
||||
domains: [...new Set(context.hs.c.oidc.allowed_domains)],
|
||||
groups: [...new Set(context.hs.c.oidc.allowed_groups)],
|
||||
users: [...new Set(context.hs.c.oidc.allowed_users)],
|
||||
domains: [...new Set(oidc.allowedDomains)],
|
||||
groups: [...new Set(oidc.allowedGroups)],
|
||||
users: [...new Set(oidc.allowedUsers)],
|
||||
},
|
||||
writable: context.hs.writable(),
|
||||
writable: headscaleConfig.writable(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+112
-39
@@ -5,6 +5,13 @@ import { data, isRouteErrorResponse, type ShouldRevalidateFunction } from "react
|
||||
import Button from "~/components/button";
|
||||
import Card from "~/components/card";
|
||||
import Code from "~/components/code";
|
||||
import StatusBanner from "~/components/status-banner";
|
||||
import {
|
||||
agentsContext,
|
||||
appConfigContext,
|
||||
headscaleContext,
|
||||
requestApiContext,
|
||||
} from "~/server/context";
|
||||
import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity";
|
||||
|
||||
import type { Route } from "./+types/page";
|
||||
@@ -16,13 +23,20 @@ import { loadHeadplaneWASM } from "./wasm.client";
|
||||
|
||||
const WASM_MODULE_URL = `${__PREFIX__}/hp_ssh.wasm`;
|
||||
const WASM_HELPER_URL = `${__PREFIX__}/wasm_exec.js`;
|
||||
const SSH_PREAUTH_KEY_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
export const shouldRevalidate: ShouldRevalidateFunction = () => {
|
||||
return false;
|
||||
};
|
||||
|
||||
export async function loader({ request, params, context }: Route.LoaderArgs) {
|
||||
const origin = new URL(request.url).origin;
|
||||
export async function loader({ request, params, context, url }: Route.LoaderArgs) {
|
||||
const agents = context.get(agentsContext);
|
||||
const config = context.get(appConfigContext);
|
||||
const headscale = context.get(headscaleContext);
|
||||
const getRequestApi = context.get(requestApiContext);
|
||||
const compatibilityWarning = getBrowserSSHCompatibilityWarning(headscale.version);
|
||||
|
||||
const origin = url.origin;
|
||||
const assets = [WASM_HELPER_URL, WASM_MODULE_URL];
|
||||
const missing: string[] = [];
|
||||
|
||||
@@ -37,52 +51,57 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
|
||||
throw data(sshErrors.wasm_missing, 405);
|
||||
}
|
||||
|
||||
if (context.agents == null) {
|
||||
if (agents.state !== "enabled") {
|
||||
throw data(sshErrors.agent_required, 400);
|
||||
}
|
||||
|
||||
const principal = await context.auth.require(request);
|
||||
const { principal, api } = await getRequestApi(request);
|
||||
if (principal.kind === "api_key") {
|
||||
throw data(sshErrors.oidc_required, 403);
|
||||
}
|
||||
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
|
||||
const hostname = params.id;
|
||||
const username = new URL(request.url).searchParams.get("user") || undefined;
|
||||
const username = url.searchParams.get("user") || undefined;
|
||||
|
||||
const nodes = await api.getNodes();
|
||||
const nodes = await api.nodes.list();
|
||||
const node = nodes.find((n) => n.givenName === hostname);
|
||||
if (!node) {
|
||||
throw data(sshErrors.node_not_found(hostname), 404);
|
||||
}
|
||||
|
||||
if (!node.online) {
|
||||
return { hostname, username, offline: true, node: undefined };
|
||||
return { hostname, username, offline: true, node: undefined, compatibilityWarning };
|
||||
}
|
||||
|
||||
if (!username) {
|
||||
return { hostname, username: undefined, offline: false, node: undefined };
|
||||
return {
|
||||
hostname,
|
||||
username: undefined,
|
||||
offline: false,
|
||||
node: undefined,
|
||||
compatibilityWarning,
|
||||
};
|
||||
}
|
||||
|
||||
// The user must exist within Headscale to generate a pre-auth key
|
||||
const users = await api.getUsers();
|
||||
const hsUser = findHeadscaleUserBySubject(users, principal.user.subject, principal.profile.email);
|
||||
const users = await api.users.list();
|
||||
const hsUser = principal.user.headscaleUserId
|
||||
? users.find((u) => u.id === principal.user.headscaleUserId)
|
||||
: findHeadscaleUserBySubject(users, principal.user.subject, principal.profile.email);
|
||||
|
||||
if (!hsUser) {
|
||||
throw data(sshErrors.user_not_linked, 404);
|
||||
}
|
||||
|
||||
const preAuthKey = await api.createPreAuthKey(
|
||||
hsUser.id,
|
||||
true,
|
||||
false,
|
||||
new Date(Date.now() + 60 * 1000), // 1 minute expiry
|
||||
null,
|
||||
);
|
||||
const preAuthKey = await api.preAuthKeys.create({
|
||||
user: hsUser.id,
|
||||
ephemeral: true,
|
||||
reusable: false,
|
||||
expiration: new Date(Date.now() + SSH_PREAUTH_KEY_TTL_MS),
|
||||
aclTags: null,
|
||||
});
|
||||
|
||||
const controlURL = context.config.headscale.public_url ?? context.config.headscale.url;
|
||||
const controlURL = config.headscale.public_url ?? config.headscale.url;
|
||||
return {
|
||||
hostname,
|
||||
username,
|
||||
@@ -93,9 +112,24 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
|
||||
preAuthKey: preAuthKey.key,
|
||||
ephemeralHostname: generateHostname(username),
|
||||
},
|
||||
compatibilityWarning,
|
||||
};
|
||||
}
|
||||
|
||||
function getBrowserSSHCompatibilityWarning(version: {
|
||||
unknown: boolean;
|
||||
major: number;
|
||||
minor: number;
|
||||
patch: number;
|
||||
raw: string;
|
||||
}) {
|
||||
if (version.unknown) return null;
|
||||
if (version.major === 0 && version.minor === 29 && version.patch < 2) {
|
||||
return { version: version.raw };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function generateHostname(username: string) {
|
||||
const hex = crypto.randomUUID().replace(/-/g, "").slice(0, 8);
|
||||
return `ssh-${hex}-${username}`;
|
||||
@@ -112,32 +146,66 @@ export const links: Route.LinksFunction = () => [
|
||||
];
|
||||
|
||||
export default function Page({ loaderData }: Route.ComponentProps) {
|
||||
const { hostname, username, offline, node } = loaderData;
|
||||
const { hostname, username, offline, node, compatibilityWarning } = loaderData;
|
||||
|
||||
if (offline) {
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-black">
|
||||
<Card className="w-screen" variant="flat">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Card.Title>Node Offline</Card.Title>
|
||||
<WifiOff className="mb-2 h-6 w-6 text-red-500" />
|
||||
</div>
|
||||
<Card.Text>
|
||||
<Code>{hostname}</Code> is not currently connected to the Tailnet.
|
||||
</Card.Text>
|
||||
<Button className="mt-8 w-full" onClick={() => window.location.reload()}>
|
||||
Retry Connection
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
<>
|
||||
<BrowserSSHCompatibilityBanner warning={compatibilityWarning} />
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-black">
|
||||
<Card className="w-screen" variant="flat">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Card.Title>Node Offline</Card.Title>
|
||||
<WifiOff className="mb-2 h-6 w-6 text-red-500" />
|
||||
</div>
|
||||
<Card.Text>
|
||||
<Code>{hostname}</Code> is not currently connected to the Tailnet.
|
||||
</Card.Text>
|
||||
<Button className="mt-8 w-full" onClick={() => window.location.reload()}>
|
||||
Retry Connection
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!username || !node) {
|
||||
return <UserPrompt hostname={hostname} />;
|
||||
return (
|
||||
<>
|
||||
<BrowserSSHCompatibilityBanner warning={compatibilityWarning} />
|
||||
<UserPrompt hostname={hostname} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <SSHConsole hostname={hostname} username={username} node={node} />;
|
||||
return (
|
||||
<>
|
||||
<BrowserSSHCompatibilityBanner warning={compatibilityWarning} />
|
||||
<SSHConsole hostname={hostname} username={username} node={node} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BrowserSSHCompatibilityBanner({
|
||||
warning,
|
||||
}: {
|
||||
warning: { version: string } | null | undefined;
|
||||
}) {
|
||||
if (!warning) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-x-4 top-4 z-[60] mx-auto max-w-2xl">
|
||||
<StatusBanner
|
||||
variant="warning"
|
||||
title={`Browser SSH is broken on Headscale ${warning.version}`}
|
||||
>
|
||||
Headscale 0.29 beta releases through 0.29.1 reject Tailscale's browser/WASM{" "}
|
||||
<Code>/ts2021</Code> WebSocket request with <Code>405 Method Not Allowed</Code>. Upgrade
|
||||
Headscale to 0.29.2 or newer, or use Headscale 0.28.x.
|
||||
</StatusBanner>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SSHConsole({
|
||||
@@ -176,7 +244,12 @@ function SSHConsole({
|
||||
setSsh(instance);
|
||||
}
|
||||
},
|
||||
onError: (msg) => console.error("[ssh] IPN error:", msg),
|
||||
onError: (msg) => {
|
||||
console.error("[ssh] IPN error:", msg);
|
||||
if (!cancelled) {
|
||||
setStatus(`Failed to join Tailnet: ${msg}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
console.log("[ssh] IPN instance created", instance);
|
||||
|
||||
@@ -27,6 +27,11 @@ export default function HeadplaneUserRow({
|
||||
);
|
||||
|
||||
const displayName = user.linkedHeadscaleUser?.displayName || user.name || user.email || user.sub;
|
||||
const displayUsername =
|
||||
user.linkedHeadscaleUser?.displayName &&
|
||||
user.linkedHeadscaleUser.displayName !== user.linkedHeadscaleUser.name
|
||||
? user.linkedHeadscaleUser.name
|
||||
: undefined;
|
||||
const displayEmail = user.linkedHeadscaleUser?.email ?? user.email;
|
||||
|
||||
return (
|
||||
@@ -40,6 +45,7 @@ export default function HeadplaneUserRow({
|
||||
)}
|
||||
<div className="ml-4">
|
||||
<p className="leading-snug font-semibold">{displayName}</p>
|
||||
{displayUsername && <p className="text-sm opacity-50">{displayUsername}</p>}
|
||||
{displayEmail && <p className="text-sm opacity-50">{displayEmail}</p>}
|
||||
{!user.headscaleUserId && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">Not linked</p>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Ellipsis } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "~/components/menu";
|
||||
|
||||
import Delete from "../dialogs/delete-user";
|
||||
import Rename from "../dialogs/rename-user";
|
||||
import type { UnlinkedHeadscaleUser } from "../overview";
|
||||
|
||||
interface HeadscaleUserMenuProps {
|
||||
user: UnlinkedHeadscaleUser;
|
||||
}
|
||||
|
||||
type Modal = "rename" | "delete" | null;
|
||||
|
||||
export default function HeadscaleUserMenu({ user }: HeadscaleUserMenuProps) {
|
||||
const [modal, setModal] = useState<Modal>(null);
|
||||
|
||||
// Headscale-managed OIDC users cannot be renamed via the API.
|
||||
const canRename = user.provider !== "oidc";
|
||||
|
||||
return (
|
||||
<>
|
||||
{modal === "rename" && canRename && (
|
||||
<Rename
|
||||
isOpen={modal === "rename"}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
user={user}
|
||||
/>
|
||||
)}
|
||||
{modal === "delete" && (
|
||||
<Delete
|
||||
isOpen={modal === "delete"}
|
||||
machines={user.machines}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
user={user}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Menu>
|
||||
<MenuTrigger className="w-10 rounded-full bg-transparent p-1 py-0.5 hover:bg-mist-100 dark:hover:bg-mist-800">
|
||||
<Ellipsis className="h-5" />
|
||||
</MenuTrigger>
|
||||
<MenuContent>
|
||||
{canRename && <MenuItem onClick={() => setModal("rename")}>Rename</MenuItem>}
|
||||
{canRename && <MenuSeparator />}
|
||||
<MenuItem variant="danger" onClick={() => setModal("delete")}>
|
||||
Delete
|
||||
</MenuItem>
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -4,33 +4,35 @@ import StatusCircle from "~/components/status-circle";
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
import type { UnlinkedHeadscaleUser } from "../overview";
|
||||
import HeadscaleUserMenu from "./headscale-user-menu";
|
||||
|
||||
interface HeadscaleUserRowProps {
|
||||
user: UnlinkedHeadscaleUser;
|
||||
writable?: boolean;
|
||||
}
|
||||
|
||||
export default function HeadscaleUserRow({ user }: HeadscaleUserRowProps) {
|
||||
export default function HeadscaleUserRow({ user, writable }: HeadscaleUserRowProps) {
|
||||
const isOnline = user.machines.some((machine) => machine.online);
|
||||
const lastSeen = user.machines.reduce(
|
||||
(acc, machine) => Math.max(acc, new Date(machine.lastSeen).getTime()),
|
||||
0,
|
||||
);
|
||||
const displayName = user.displayName || user.name;
|
||||
const displayUsername =
|
||||
user.displayName && user.displayName !== user.name ? user.name : undefined;
|
||||
|
||||
return (
|
||||
<tr className="group hover:bg-mist-50 dark:hover:bg-mist-950" key={user.id}>
|
||||
<td className="py-2 pl-0.5">
|
||||
<div className="flex items-center">
|
||||
{user.profilePicUrl ? (
|
||||
<img
|
||||
alt={user.name || user.displayName}
|
||||
className="h-10 w-10 rounded-full"
|
||||
src={user.profilePicUrl}
|
||||
/>
|
||||
<img alt={displayName} className="h-10 w-10 rounded-full" src={user.profilePicUrl} />
|
||||
) : (
|
||||
<CircleUser className="h-10 w-10" />
|
||||
)}
|
||||
<div className="ml-4">
|
||||
<p className="leading-snug font-semibold">{user.name || user.displayName}</p>
|
||||
<p className="leading-snug font-semibold">{displayName}</p>
|
||||
{displayUsername && <p className="text-sm opacity-50">{displayUsername}</p>}
|
||||
{user.email && <p className="text-sm opacity-50">{user.email}</p>}
|
||||
</div>
|
||||
</div>
|
||||
@@ -54,9 +56,7 @@ export default function HeadscaleUserRow({ user }: HeadscaleUserRowProps) {
|
||||
<p className="text-sm text-mist-600 dark:text-mist-300">No machines</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-0.5">
|
||||
{/* Unlinked users only get basic Headscale operations (rename, delete) */}
|
||||
</td>
|
||||
<td className="py-2 pr-0.5">{writable ? <HeadscaleUserMenu user={user} /> : null}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,24 +54,24 @@ export default function UserMenu({
|
||||
{modal === "reassign" && (
|
||||
<Reassign
|
||||
displayName={displayName}
|
||||
headplaneUserId={user.id}
|
||||
isOpen={modal === "reassign"}
|
||||
role={user.role}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
userId={user.linkedHeadscaleUser?.id ?? user.id}
|
||||
/>
|
||||
)}
|
||||
{modal === "link" && (
|
||||
<LinkUser
|
||||
currentLink={currentLink}
|
||||
displayName={displayName}
|
||||
headplaneUserId={user.id}
|
||||
headscaleUsers={linkableUsers}
|
||||
isOpen={modal === "link"}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
userId={user.linkedHeadscaleUser?.id ?? user.id}
|
||||
/>
|
||||
)}
|
||||
{modal === "transfer" && (
|
||||
@@ -81,7 +81,7 @@ export default function UserMenu({
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
targetDisplayName={displayName}
|
||||
targetUserId={user.linkedHeadscaleUser?.id ?? user.id}
|
||||
targetHeadplaneUserId={user.id}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function DeleteUser({ user, machines, isOpen, setIsOpen }: Delete
|
||||
</Text>
|
||||
)}
|
||||
<input name="action_id" type="hidden" value="delete_user" />
|
||||
<input name="user_id" type="hidden" value={user.id} />
|
||||
<input name="headscale_user_id" type="hidden" value={user.id} />
|
||||
</DialogPanel>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ import Title from "~/components/title";
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
interface LinkUserProps {
|
||||
userId: string;
|
||||
headplaneUserId: string;
|
||||
displayName: string;
|
||||
headscaleUsers: { id: string; name: string }[];
|
||||
currentLink?: string;
|
||||
@@ -14,7 +14,7 @@ interface LinkUserProps {
|
||||
}
|
||||
|
||||
export default function LinkUser({
|
||||
userId,
|
||||
headplaneUserId,
|
||||
displayName,
|
||||
headscaleUsers,
|
||||
currentLink,
|
||||
@@ -34,7 +34,7 @@ export default function LinkUser({
|
||||
) : (
|
||||
<>
|
||||
<input name="action_id" type="hidden" value="link_user" />
|
||||
<input name="user_id" type="hidden" value={userId} />
|
||||
<input name="headplane_user_id" type="hidden" value={headplaneUserId} />
|
||||
<select
|
||||
className={cn(
|
||||
"w-full rounded-lg border p-2",
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Roles } from "~/server/web/roles";
|
||||
import type { Role } from "~/server/web/roles";
|
||||
|
||||
interface ReassignProps {
|
||||
userId: string;
|
||||
headplaneUserId: string;
|
||||
displayName: string;
|
||||
role: Role;
|
||||
isOpen: boolean;
|
||||
@@ -16,7 +16,7 @@ interface ReassignProps {
|
||||
}
|
||||
|
||||
export default function ReassignUser({
|
||||
userId,
|
||||
headplaneUserId,
|
||||
displayName,
|
||||
role,
|
||||
isOpen,
|
||||
@@ -38,7 +38,7 @@ export default function ReassignUser({
|
||||
) : (
|
||||
<>
|
||||
<input name="action_id" type="hidden" value="reassign_user" />
|
||||
<input name="user_id" type="hidden" value={userId} />
|
||||
<input name="headplane_user_id" type="hidden" value={headplaneUserId} />
|
||||
<RadioGroup className="gap-4" defaultValue={role} label="Role" name="new_role">
|
||||
{Object.keys(Roles)
|
||||
.filter((r) => r !== "owner")
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function RenameUser({ user, isOpen, setIsOpen }: RenameProps) {
|
||||
update any ACL policies that may refer to this user by their old username.
|
||||
</Text>
|
||||
<input name="action_id" type="hidden" value="rename_user" />
|
||||
<input name="user_id" type="hidden" value={user.id} />
|
||||
<input name="headscale_user_id" type="hidden" value={user.id} />
|
||||
<Input
|
||||
defaultValue={user.name}
|
||||
required
|
||||
|
||||
@@ -4,14 +4,14 @@ import Text from "~/components/text";
|
||||
import Title from "~/components/title";
|
||||
|
||||
interface TransferOwnershipProps {
|
||||
targetUserId: string;
|
||||
targetHeadplaneUserId: string;
|
||||
targetDisplayName: string;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
export default function TransferOwnership({
|
||||
targetUserId,
|
||||
targetHeadplaneUserId,
|
||||
targetDisplayName,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
@@ -29,7 +29,7 @@ export default function TransferOwnership({
|
||||
ownership.
|
||||
</Notice>
|
||||
<input name="action_id" type="hidden" value="transfer_ownership" />
|
||||
<input name="user_id" type="hidden" value={targetUserId} />
|
||||
<input name="headplane_user_id" type="hidden" value={targetHeadplaneUserId} />
|
||||
</DialogPanel>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import PageError from "~/components/page-error";
|
||||
import {
|
||||
appConfigContext,
|
||||
authContext,
|
||||
headscaleConfigContext,
|
||||
headscaleLiveStoreContext,
|
||||
requestApiContext,
|
||||
} from "~/server/context";
|
||||
import { nodesResource, usersResource } from "~/server/headscale/live-store";
|
||||
import { isUserPrincipal } from "~/server/web/auth";
|
||||
import { Capabilities, Roles } from "~/server/web/roles";
|
||||
import type { Role } from "~/server/web/roles";
|
||||
import type { Machine, User } from "~/types";
|
||||
@@ -35,18 +43,24 @@ export interface UnlinkedHeadscaleUser extends User {
|
||||
}
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const principal = await context.auth.require(request);
|
||||
const check = await context.auth.can(principal, Capabilities.read_users);
|
||||
const auth = context.get(authContext);
|
||||
const config = context.get(appConfigContext);
|
||||
const getRequestApi = context.get(requestApiContext);
|
||||
const headscaleConfig = context.get(headscaleConfigContext);
|
||||
const headscaleLiveStore = context.get(headscaleLiveStoreContext);
|
||||
|
||||
const principal = await auth.require(request);
|
||||
const check = await auth.can(principal, Capabilities.read_users);
|
||||
if (!check) {
|
||||
throw new Error(
|
||||
"You do not have permission to view this page. Please contact your administrator.",
|
||||
);
|
||||
}
|
||||
|
||||
const writablePermission = await context.auth.can(principal, Capabilities.write_users);
|
||||
const writablePermission = await auth.can(principal, Capabilities.write_users);
|
||||
|
||||
// Primary data: Headplane users from the database (always available)
|
||||
const hpUsers = await context.auth.listUsers();
|
||||
const hpUsers = await auth.listUsers();
|
||||
|
||||
// Secondary data: Headscale API (may fail)
|
||||
let apiUsers: User[] = [];
|
||||
@@ -54,11 +68,10 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
let apiError: string | undefined;
|
||||
|
||||
try {
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
const { api } = await getRequestApi(request);
|
||||
const [nodesSnap, usersSnap] = await Promise.all([
|
||||
context.hsLive.get(nodesResource, api),
|
||||
context.hsLive.get(usersResource, api),
|
||||
headscaleLiveStore.get(nodesResource, api),
|
||||
headscaleLiveStore.get(usersResource, api),
|
||||
]);
|
||||
nodes = nodesSnap.data;
|
||||
apiUsers = usersSnap.data;
|
||||
@@ -68,7 +81,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
"Could not connect to the Headscale API. Headscale user data and machine information are unavailable.";
|
||||
}
|
||||
|
||||
const useGravatar = context.config.oidc?.profile_picture_source === "gravatar";
|
||||
const useGravatar = config.oidc?.profile_picture_source === "gravatar";
|
||||
|
||||
function resolveProfilePic(email?: string, profilePicUrl?: string): string | undefined {
|
||||
if (!useGravatar) return profilePicUrl;
|
||||
@@ -125,20 +138,15 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
claimed: claimedIds.has(u.id),
|
||||
}));
|
||||
|
||||
let magic: string | undefined;
|
||||
if (context.hs.readable()) {
|
||||
if (context.hs.c?.dns.magic_dns) {
|
||||
magic = context.hs.c.dns.base_domain;
|
||||
}
|
||||
}
|
||||
const magic = headscaleConfig.getMagicDNSBaseDomain();
|
||||
|
||||
const isOwner = principal.kind === "oidc" && principal.user.role === "owner";
|
||||
const isOwner = isUserPrincipal(principal) && principal.user.role === "owner";
|
||||
|
||||
return {
|
||||
writable: writablePermission,
|
||||
currentUserId: principal.kind === "oidc" ? principal.user.id : undefined,
|
||||
currentUserId: isUserPrincipal(principal) ? principal.user.id : undefined,
|
||||
isOwner,
|
||||
oidc: context.config.oidc ? { issuer: context.config.oidc.issuer } : undefined,
|
||||
oidc: config.oidc ? { issuer: config.oidc.issuer } : undefined,
|
||||
magic,
|
||||
apiError,
|
||||
headplaneUsers,
|
||||
@@ -235,7 +243,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
|
||||
)}
|
||||
>
|
||||
{loaderData.unlinkedHeadscaleUsers.map((user) => (
|
||||
<HeadscaleUserRow key={user.id} user={user} />
|
||||
<HeadscaleUserRow key={user.id} user={user} writable={loaderData.writable} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { data } from "react-router";
|
||||
|
||||
import { authContext, headscaleLiveStoreContext, requestApiContext } from "~/server/context";
|
||||
import { usersResource } from "~/server/headscale/live-store";
|
||||
import { getOidcSubject } from "~/server/web/headscale-identity";
|
||||
import { isUserPrincipal } from "~/server/web/auth";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
import type { Role } from "~/server/web/roles";
|
||||
|
||||
import type { Route } from "./+types/overview";
|
||||
|
||||
export async function userAction({ request, context }: Route.ActionArgs) {
|
||||
const principal = await context.auth.require(request);
|
||||
const check = await context.auth.can(principal, Capabilities.write_users);
|
||||
const auth = context.get(authContext);
|
||||
const getRequestApi = context.get(requestApiContext);
|
||||
const headscaleLiveStore = context.get(headscaleLiveStoreContext);
|
||||
|
||||
const principal = await auth.require(request);
|
||||
const check = await auth.can(principal, Capabilities.write_users);
|
||||
if (!check) {
|
||||
throw data("You do not have permission to update users", {
|
||||
status: 403,
|
||||
@@ -24,8 +29,7 @@ export async function userAction({ request, context }: Route.ActionArgs) {
|
||||
});
|
||||
}
|
||||
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
const { api } = await getRequestApi(request);
|
||||
switch (action) {
|
||||
case "create_user": {
|
||||
const name = formData.get("username")?.toString();
|
||||
@@ -38,33 +42,33 @@ export async function userAction({ request, context }: Route.ActionArgs) {
|
||||
});
|
||||
}
|
||||
|
||||
await api.createUser(name, email, displayName);
|
||||
await context.hsLive.refresh(usersResource, api);
|
||||
await api.users.create({ name, email, displayName });
|
||||
await headscaleLiveStore.refresh(usersResource, api);
|
||||
return { message: "User created successfully" };
|
||||
}
|
||||
case "delete_user": {
|
||||
const userId = formData.get("user_id")?.toString();
|
||||
if (!userId) {
|
||||
throw data("Missing `user_id` in the form data.", {
|
||||
const headscaleUserId = formData.get("headscale_user_id")?.toString();
|
||||
if (!headscaleUserId) {
|
||||
throw data("Missing `headscale_user_id` in the form data.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
await api.deleteUser(userId);
|
||||
await context.hsLive.refresh(usersResource, api);
|
||||
await api.users.delete(headscaleUserId);
|
||||
await headscaleLiveStore.refresh(usersResource, api);
|
||||
return { message: "User deleted successfully" };
|
||||
}
|
||||
case "rename_user": {
|
||||
const userId = formData.get("user_id")?.toString();
|
||||
const headscaleUserId = formData.get("headscale_user_id")?.toString();
|
||||
const newName = formData.get("new_name")?.toString();
|
||||
if (!userId || !newName) {
|
||||
if (!headscaleUserId || !newName) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
const users = await api.getUsers(userId);
|
||||
const user = users.find((user) => user.id === userId);
|
||||
const users = await api.users.list({ id: headscaleUserId });
|
||||
const user = users.find((user) => user.id === headscaleUserId);
|
||||
if (!user) {
|
||||
throw data(`No user found with id: ${userId}`, { status: 400 });
|
||||
throw data(`No user found with id: ${headscaleUserId}`, { status: 400 });
|
||||
}
|
||||
|
||||
if (user.provider === "oidc") {
|
||||
@@ -74,34 +78,20 @@ export async function userAction({ request, context }: Route.ActionArgs) {
|
||||
});
|
||||
}
|
||||
|
||||
await api.renameUser(userId, newName);
|
||||
await context.hsLive.refresh(usersResource, api);
|
||||
await api.users.rename(headscaleUserId, newName);
|
||||
await headscaleLiveStore.refresh(usersResource, api);
|
||||
return { message: "User renamed successfully" };
|
||||
}
|
||||
case "reassign_user": {
|
||||
const userId = formData.get("user_id")?.toString();
|
||||
const headplaneUserId = formData.get("headplane_user_id")?.toString();
|
||||
const newRole = formData.get("new_role")?.toString();
|
||||
if (!userId || !newRole) {
|
||||
throw data("Missing `user_id` or `new_role` in the form data.", {
|
||||
if (!headplaneUserId || !newRole) {
|
||||
throw data("Missing `headplane_user_id` or `new_role` in the form data.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const users = await api.getUsers(userId);
|
||||
const user = users.find((user) => user.id === userId);
|
||||
if (!user) {
|
||||
throw data("Specified user not found", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const subject = getOidcSubject(user);
|
||||
if (!subject) {
|
||||
throw data("Specified user is not an OIDC user or has no subject.", { status: 400 });
|
||||
}
|
||||
|
||||
const result = await context.auth.reassignSubject(subject, newRole as Role);
|
||||
|
||||
const result = await auth.reassignUser(headplaneUserId, newRole as Role);
|
||||
if (!result) {
|
||||
throw data("Failed to reassign user role.", { status: 500 });
|
||||
}
|
||||
@@ -109,27 +99,16 @@ export async function userAction({ request, context }: Route.ActionArgs) {
|
||||
return { message: "User reassigned successfully" };
|
||||
}
|
||||
case "transfer_ownership": {
|
||||
if (principal.kind !== "oidc" || principal.user.role !== "owner") {
|
||||
if (!isUserPrincipal(principal) || principal.user.role !== "owner") {
|
||||
throw data("Only the owner can transfer ownership.", { status: 403 });
|
||||
}
|
||||
|
||||
const userId = formData.get("user_id")?.toString();
|
||||
if (!userId) {
|
||||
throw data("Missing `user_id` in the form data.", { status: 400 });
|
||||
const headplaneUserId = formData.get("headplane_user_id")?.toString();
|
||||
if (!headplaneUserId) {
|
||||
throw data("Missing `headplane_user_id` in the form data.", { status: 400 });
|
||||
}
|
||||
|
||||
const users = await api.getUsers(userId);
|
||||
const user = users.find((user) => user.id === userId);
|
||||
if (!user) {
|
||||
throw data("Specified user not found", { status: 400 });
|
||||
}
|
||||
|
||||
const targetSubject = getOidcSubject(user);
|
||||
if (!targetSubject) {
|
||||
throw data("Target user is not an OIDC user or has no subject.", { status: 400 });
|
||||
}
|
||||
|
||||
const result = await context.auth.transferOwnership(principal.user.subject, targetSubject);
|
||||
const result = await auth.transferOwnership(principal.user.id, headplaneUserId);
|
||||
if (!result) {
|
||||
throw data("Failed to transfer ownership.", { status: 500 });
|
||||
}
|
||||
@@ -137,26 +116,15 @@ export async function userAction({ request, context }: Route.ActionArgs) {
|
||||
return { message: "Ownership transferred successfully" };
|
||||
}
|
||||
case "link_user": {
|
||||
const userId = formData.get("user_id")?.toString();
|
||||
const headplaneUserId = formData.get("headplane_user_id")?.toString();
|
||||
const headscaleUserId = formData.get("headscale_user_id")?.toString();
|
||||
if (!userId || !headscaleUserId) {
|
||||
throw data("Missing `user_id` or `headscale_user_id` in the form data.", {
|
||||
if (!headplaneUserId || !headscaleUserId) {
|
||||
throw data("Missing `headplane_user_id` or `headscale_user_id` in the form data.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const users = await api.getUsers(userId);
|
||||
const user = users.find((user) => user.id === userId);
|
||||
if (!user) {
|
||||
throw data("Specified user not found", { status: 400 });
|
||||
}
|
||||
|
||||
const subject = getOidcSubject(user);
|
||||
if (!subject) {
|
||||
throw data("Specified user is not an OIDC user or has no subject.", { status: 400 });
|
||||
}
|
||||
|
||||
const linked = await context.auth.linkHeadscaleUserBySubject(subject, headscaleUserId);
|
||||
const linked = await auth.linkHeadscaleUser(headplaneUserId, headscaleUserId);
|
||||
if (!linked) {
|
||||
throw data("That Headscale user is already linked to another account.", { status: 409 });
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { headscaleContext } from "~/server/context";
|
||||
|
||||
import type { Route } from "./+types/healthz";
|
||||
|
||||
export async function loader({ context }: Route.LoaderArgs) {
|
||||
// Use a fake API key for healthcheck
|
||||
const api = context.hsApi.getRuntimeClient("fake-api-key");
|
||||
const healthy = await api.isHealthy();
|
||||
const headscale = context.get(headscaleContext);
|
||||
|
||||
const healthy = await headscale.health();
|
||||
|
||||
return new Response(JSON.stringify({ status: healthy ? "OK" : "ERROR" }), {
|
||||
status: healthy ? 200 : 500,
|
||||
|
||||
@@ -2,10 +2,15 @@ import { versions } from "node:process";
|
||||
|
||||
import { data } from "react-router";
|
||||
|
||||
import { appConfigContext, headscaleContext } from "~/server/context";
|
||||
|
||||
import type { Route } from "./+types/info";
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
if (context.config.server.info_secret == null) {
|
||||
const config = context.get(appConfigContext);
|
||||
const headscale = context.get(headscaleContext);
|
||||
|
||||
if (config.server.info_secret == null) {
|
||||
throw data(
|
||||
{
|
||||
status: "Forbidden",
|
||||
@@ -25,7 +30,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
}
|
||||
|
||||
const token = bearer.slice("Bearer ".length).trim();
|
||||
if (token !== context.config.server.info_secret) {
|
||||
if (token !== config.server.info_secret) {
|
||||
throw data(
|
||||
{
|
||||
status: "Forbidden",
|
||||
@@ -34,14 +39,12 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
);
|
||||
}
|
||||
|
||||
// Use a fake API key for healthcheck
|
||||
const api = context.hsApi.getRuntimeClient("fake-api-key");
|
||||
const healthy = await api.isHealthy();
|
||||
const healthy = await headscale.health();
|
||||
|
||||
const body = {
|
||||
status: healthy ? "healthy" : "unhealthy",
|
||||
headplane_version: __VERSION__,
|
||||
headscale_canonical_version: healthy ? context.hsApi.apiVersion : "unknown",
|
||||
headscale_canonical_version: healthy ? headscale.version.raw : "unknown",
|
||||
internal_versions: {
|
||||
node: versions.node,
|
||||
v8: versions.v8,
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { headscaleLiveStoreContext, requestApiContext } from "~/server/context";
|
||||
import { nodesResource, usersResource } from "~/server/headscale/live-store";
|
||||
import log from "~/utils/log";
|
||||
|
||||
import type { Route } from "./+types/live";
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const principal = await context.auth.require(request);
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
const getRequestApi = context.get(requestApiContext);
|
||||
const headscaleLiveStore = context.get(headscaleLiveStoreContext);
|
||||
|
||||
const { api } = await getRequestApi(request);
|
||||
|
||||
// Ensure resources are loaded before streaming
|
||||
await Promise.all([
|
||||
context.hsLive.get(nodesResource, api),
|
||||
context.hsLive.get(usersResource, api),
|
||||
headscaleLiveStore.get(nodesResource, api),
|
||||
headscaleLiveStore.get(usersResource, api),
|
||||
]);
|
||||
|
||||
const stream = new ReadableStream({
|
||||
@@ -27,11 +29,11 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
}
|
||||
};
|
||||
|
||||
const versions = context.hsLive.getVersions();
|
||||
const versions = headscaleLiveStore.getVersions();
|
||||
log.debug("sse", "Client connected, sending hello with versions: %o", versions);
|
||||
send("hello", versions);
|
||||
|
||||
const unsubscribe = context.hsLive.subscribe((resource, version) => {
|
||||
const unsubscribe = headscaleLiveStore.subscribe((resource, version) => {
|
||||
log.debug("sse", "Sending change event: %s v%s", resource, version);
|
||||
send("changed", { resource, version });
|
||||
});
|
||||
|
||||
+17
-14
@@ -9,7 +9,7 @@ runs only on the Node process — never in the browser.
|
||||
app/server/
|
||||
├── app.ts ← The Headplane application (load context, RR listener)
|
||||
├── main.ts ← Production bootstrap (binds an http(s) server)
|
||||
├── context.ts ← createAppContext() — assembles the AppLoadContext
|
||||
├── context.ts ← createAppContext() — assembles the RouterContextProvider data
|
||||
├── result.ts ← Result<T, E> helper used across the server modules
|
||||
│
|
||||
├── config/ ← YAML config loading, schema, env-overrides, integrations
|
||||
@@ -26,9 +26,10 @@ There are two SSR entries; both are picked up by Vite via `vite.config.ts`.
|
||||
|
||||
### `app.ts` — the application module
|
||||
|
||||
Loads config → builds the `AppLoadContext` (via [`context.ts`](./context.ts))
|
||||
→ exports the React Router `RequestListener` as `default`, plus the
|
||||
resolved `config` as a named export.
|
||||
Loads config → builds the application context (via [`context.ts`](./context.ts))
|
||||
→ seeds React Router's `RouterContextProvider` with the named service contexts
|
||||
→ exports the React Router `RequestListener` as `default`, plus the resolved
|
||||
`config` as a named export.
|
||||
|
||||
This module has no opinions about how the server is hosted. It does not
|
||||
listen on a socket, doesn't compose static-asset serving, and doesn't
|
||||
@@ -63,7 +64,7 @@ constructs everything that needs to live for the lifetime of the
|
||||
process:
|
||||
|
||||
- the SQLite client (`db`)
|
||||
- the Headscale REST interface (`hsApi`)
|
||||
- the Headscale REST interface (`headscale`)
|
||||
- the optional Headplane agent manager (`agents`)
|
||||
- the auth service (`auth`)
|
||||
- the optional OIDC service (`oidc`)
|
||||
@@ -71,17 +72,19 @@ process:
|
||||
- the (best-effort) parsed Headscale config (`hs`)
|
||||
- the integration adapter (`integration`)
|
||||
|
||||
The returned object is the `AppLoadContext` exposed to every React
|
||||
Router loader/action. The module also `declare module "react-router" { interface AppLoadContext extends AppContext {} }`
|
||||
so route handlers get full type inference on `context`.
|
||||
The returned object owns process-lifetime services, but route handlers consume
|
||||
those services through named React Router contexts such as `authContext`,
|
||||
`headscaleContext`, and `headscaleConfigContext`:
|
||||
|
||||
When a route needs the type, import it from `~/server/context`:
|
||||
When a route needs a service, import the matching context from
|
||||
`~/server/context`:
|
||||
|
||||
```ts
|
||||
import type { AppContext } from "~/server/context";
|
||||
import { authContext } from "~/server/context";
|
||||
|
||||
export async function loader({ context }: LoaderFunctionArgs<AppContext>) {
|
||||
// …
|
||||
export async function loader({ context, request }: Route.LoaderArgs) {
|
||||
const auth = context.get(authContext);
|
||||
const principal = await auth.require(request);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -105,7 +108,7 @@ file is loaded, not by runtime conditionals.
|
||||
that names a coherent concern, e.g. `metrics/`, `ratelimit/`).
|
||||
2. If it owns process-lifetime state (a connection pool, a service
|
||||
client, …), construct it in [`context.ts`](./context.ts) and add it
|
||||
to the returned object — this gives every route automatic access via
|
||||
`context.<name>`.
|
||||
to the returned object. Expose it through a named React Router context
|
||||
and seed that context in [`app.ts`](./app.ts)'s `getLoadContext`.
|
||||
3. If it's purely a helper (pure functions, type definitions), import
|
||||
it directly from the module that needs it.
|
||||
|
||||
+56
-3
@@ -12,6 +12,7 @@
|
||||
import { exit, versions } from "node:process";
|
||||
|
||||
import { createRequestListener } from "@react-router/node";
|
||||
import { RouterContextProvider } from "react-router";
|
||||
import * as build from "virtual:react-router/server-build";
|
||||
|
||||
import log from "~/utils/log";
|
||||
@@ -19,7 +20,20 @@ import log from "~/utils/log";
|
||||
import type { HeadplaneConfig } from "./config/config-schema";
|
||||
import { ConfigError } from "./config/error";
|
||||
import { loadConfig } from "./config/load";
|
||||
import { createAppContext } from "./context";
|
||||
import {
|
||||
agentsContext,
|
||||
appConfigContext,
|
||||
authContext,
|
||||
createAppContext,
|
||||
dbContext,
|
||||
headscaleApiKeyContext,
|
||||
headscaleConfigContext,
|
||||
headscaleContext,
|
||||
headscaleLiveStoreContext,
|
||||
integrationContext,
|
||||
oidcContext,
|
||||
requestApiContext,
|
||||
} from "./context";
|
||||
|
||||
log.info("server", "Running Node.js %s", versions.node);
|
||||
|
||||
@@ -35,16 +49,55 @@ try {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if ((config.server.tls_cert_path || config.server.tls_key_path) && !config.server.cookie_secure) {
|
||||
log.warn(
|
||||
"server",
|
||||
"TLS is enabled but `server.cookie_secure` is false; forcing it to true (browsers reject Secure-less cookies over HTTPS)",
|
||||
);
|
||||
config.server.cookie_secure = true;
|
||||
}
|
||||
|
||||
const ctx = await createAppContext(config);
|
||||
ctx.auth.start();
|
||||
ctx.startServices();
|
||||
|
||||
export { config };
|
||||
|
||||
/**
|
||||
* Disposes the per-process context. Invoked by the production
|
||||
* supervisor on SIGTERM/SIGINT and by the dev Vite plugin on HMR
|
||||
* reload.
|
||||
*/
|
||||
export async function dispose(): Promise<void> {
|
||||
await ctx.dispose();
|
||||
}
|
||||
|
||||
// TODO: `getLoadContext` is the right place to handle reverse proxy
|
||||
// translation — better than doing it in the OIDC client because it
|
||||
// applies to all requests, not just OIDC ones.
|
||||
function getLoadContext(request: Request, client: ClientAddress) {
|
||||
ctx.auth.registerRequestClientAddress(request, client.address);
|
||||
|
||||
const routerContext = new RouterContextProvider();
|
||||
routerContext.set(agentsContext, ctx.agents);
|
||||
routerContext.set(appConfigContext, ctx.config);
|
||||
routerContext.set(authContext, ctx.auth);
|
||||
routerContext.set(dbContext, ctx.db);
|
||||
routerContext.set(headscaleContext, ctx.headscale);
|
||||
routerContext.set(headscaleApiKeyContext, ctx.headscaleApiKey);
|
||||
routerContext.set(headscaleConfigContext, ctx.hs);
|
||||
routerContext.set(headscaleLiveStoreContext, ctx.hsLive);
|
||||
routerContext.set(integrationContext, ctx.integration);
|
||||
routerContext.set(oidcContext, ctx.oidc);
|
||||
routerContext.set(requestApiContext, ctx.apiForRequest);
|
||||
return routerContext;
|
||||
}
|
||||
|
||||
interface ClientAddress {
|
||||
address?: string;
|
||||
}
|
||||
|
||||
export default createRequestListener({
|
||||
build,
|
||||
mode: import.meta.env.MODE,
|
||||
getLoadContext: () => ctx,
|
||||
getLoadContext,
|
||||
});
|
||||
|
||||
@@ -42,6 +42,23 @@ const serverConfig = type({
|
||||
cookie_secure: "boolean = true",
|
||||
cookie_domain: "string.lower?",
|
||||
cookie_max_age: "number.integer = 86400",
|
||||
|
||||
// TLS termination. When both `tls_cert_path` and `tls_key_path`
|
||||
// are provided, Headplane serves HTTPS on `server.port`. When
|
||||
// either is set, `cookie_secure` is forced to `true`.
|
||||
tls_cert_path: "string?",
|
||||
tls_key_path: "string?",
|
||||
|
||||
"proxy_auth?": {
|
||||
enabled: "boolean",
|
||||
allowed_cidrs: "string[]?",
|
||||
trusted_proxy_cidrs: "string[]?",
|
||||
ip_header: "string?",
|
||||
user_header: "string?",
|
||||
email_header: "string?",
|
||||
name_header: "string?",
|
||||
picture_header: "string?",
|
||||
},
|
||||
});
|
||||
|
||||
const partialServerConfig = type({
|
||||
@@ -55,6 +72,20 @@ const partialServerConfig = type({
|
||||
cookie_secure: "boolean?",
|
||||
cookie_domain: "string.lower?",
|
||||
cookie_max_age: "number.integer?",
|
||||
|
||||
tls_cert_path: "string?",
|
||||
tls_key_path: "string?",
|
||||
|
||||
"proxy_auth?": {
|
||||
enabled: "boolean?",
|
||||
allowed_cidrs: "string[]?",
|
||||
trusted_proxy_cidrs: "string[]?",
|
||||
ip_header: "string?",
|
||||
user_header: "string?",
|
||||
email_header: "string?",
|
||||
name_header: "string?",
|
||||
picture_header: "string?",
|
||||
},
|
||||
});
|
||||
|
||||
const headscaleConfig = type({
|
||||
@@ -83,6 +114,8 @@ const partialHeadscaleConfig = type({
|
||||
tls_cert_path: "string.lower?",
|
||||
});
|
||||
|
||||
const assignableRole = '"admin" | "network_admin" | "it_admin" | "auditor" | "viewer" | "member"';
|
||||
|
||||
const oidcConfig = type({
|
||||
enabled: "boolean = true",
|
||||
issuer: "string.url",
|
||||
@@ -116,6 +149,8 @@ const oidcConfig = type({
|
||||
disable_api_key_login: "boolean = false",
|
||||
scope: 'string = "openid email profile"',
|
||||
subject_claims: type("string[]").pipe(normalizeStringArray).optional(),
|
||||
default_role: `${assignableRole} = "member"`,
|
||||
role_claim: "string?",
|
||||
allow_weak_rsa_keys: "boolean = false",
|
||||
profile_picture_source: '"oidc" | "gravatar" = "oidc"',
|
||||
extra_params: "Record<string, string>?",
|
||||
@@ -143,6 +178,8 @@ const partialOidcConfig = type({
|
||||
disable_api_key_login: "boolean?",
|
||||
scope: "string?",
|
||||
subject_claims: type("string[]").pipe(normalizeStringArray).optional(),
|
||||
default_role: `${assignableRole}?`,
|
||||
role_claim: "string?",
|
||||
allow_weak_rsa_keys: "boolean?",
|
||||
extra_params: "Record<string, string>?",
|
||||
profile_picture_source: '"oidc" | "gravatar"?',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { platform } from "node:os";
|
||||
|
||||
import { type } from "arktype";
|
||||
|
||||
import type { RuntimeApiClient } from "~/server/headscale/api/endpoints";
|
||||
import type { Headscale } from "~/server/headscale/api";
|
||||
import log from "~/utils/log";
|
||||
|
||||
import { Integration } from "./abstract";
|
||||
@@ -51,12 +51,12 @@ export default class ProcIntegration extends Integration<typeof configSchema.ful
|
||||
}
|
||||
}
|
||||
|
||||
async onConfigChange(client: RuntimeApiClient) {
|
||||
async onConfigChange(headscale: Headscale) {
|
||||
if (!this.pid) {
|
||||
return;
|
||||
}
|
||||
|
||||
await signalAndWaitHealthy(client, {
|
||||
await signalAndWaitHealthy(headscale, {
|
||||
pid: this.pid,
|
||||
signal: "SIGHUP",
|
||||
});
|
||||
|
||||
+157
-60
@@ -1,50 +1,66 @@
|
||||
import { join } from "node:path";
|
||||
|
||||
import { createContext } from "react-router";
|
||||
|
||||
import log from "~/utils/log";
|
||||
|
||||
import type { HeadplaneConfig } from "./config/config-schema";
|
||||
import { loadIntegration } from "./config/integration";
|
||||
import { createDbClient } from "./db/client.server";
|
||||
import { createHeadscaleInterface } from "./headscale/api";
|
||||
import { disabled, enabled, type Feature } from "./feature";
|
||||
import { createHeadscale, type HeadscaleClient } from "./headscale/api";
|
||||
import { loadHeadscaleConfig } from "./headscale/config-loader";
|
||||
import { createLiveStore, nodesResource, usersResource } from "./headscale/live-store";
|
||||
import { createAgentManager } from "./hp-agent";
|
||||
import { createOidcService } from "./oidc/provider";
|
||||
import { createAuthService } from "./web/auth";
|
||||
import { type AgentManager, createAgentManager } from "./hp-agent";
|
||||
import { createOidcService, type OidcService } from "./oidc/provider";
|
||||
import { createAuthService, type Principal } from "./web/auth";
|
||||
|
||||
export type AppContext = Awaited<ReturnType<typeof createAppContext>>;
|
||||
|
||||
declare module "react-router" {
|
||||
interface AppLoadContext extends AppContext {}
|
||||
}
|
||||
export const agentsContext = createContext<AppContext["agents"]>();
|
||||
export const appConfigContext = createContext<AppContext["config"]>();
|
||||
export const authContext = createContext<AppContext["auth"]>();
|
||||
export const dbContext = createContext<AppContext["db"]>();
|
||||
export const headscaleContext = createContext<AppContext["headscale"]>();
|
||||
export const headscaleApiKeyContext = createContext<AppContext["headscaleApiKey"]>();
|
||||
export const headscaleConfigContext = createContext<AppContext["hs"]>();
|
||||
export const headscaleLiveStoreContext = createContext<AppContext["hsLive"]>();
|
||||
export const integrationContext = createContext<AppContext["integration"]>();
|
||||
export const oidcContext = createContext<AppContext["oidc"]>();
|
||||
export const requestApiContext = createContext<AppContext["apiForRequest"]>();
|
||||
|
||||
export async function createAppContext(config: HeadplaneConfig) {
|
||||
const db = await createDbClient(join(config.server.data_path, "hp_persist.db"));
|
||||
const hsApi = await createHeadscaleInterface(
|
||||
config.headscale.url,
|
||||
config.headscale.tls_cert_path,
|
||||
);
|
||||
const headscale = await createHeadscale({
|
||||
url: config.headscale.url,
|
||||
certPath: config.headscale.tls_cert_path,
|
||||
});
|
||||
|
||||
// Resolve the Headscale API key: headscale.api_key takes precedence,
|
||||
// falling back to the deprecated oidc.headscale_api_key for compatibility.
|
||||
const headscaleApiKey = config.headscale.api_key ?? config.oidc?.headscale_api_key;
|
||||
|
||||
let agents;
|
||||
if (headscaleApiKey) {
|
||||
agents = await createAgentManager(
|
||||
config.integration?.agent,
|
||||
config.headscale.url,
|
||||
hsApi.getRuntimeClient(headscaleApiKey),
|
||||
hsApi.clientHelpers.isAtleast("0.28.0"),
|
||||
db,
|
||||
);
|
||||
} else if (config.integration?.agent?.enabled) {
|
||||
log.warn("agent", "Agent is enabled but no headscale.api_key is configured");
|
||||
}
|
||||
const agents = await buildAgents(
|
||||
config,
|
||||
headscale.capabilities.preAuthKeysHaveStableIds,
|
||||
headscaleApiKey ? headscale.client(headscaleApiKey) : undefined,
|
||||
db,
|
||||
);
|
||||
|
||||
const auth = createAuthService({
|
||||
secret: config.server.cookie_secret,
|
||||
headscaleApiKey,
|
||||
proxyAuth: config.server.proxy_auth
|
||||
? {
|
||||
enabled: config.server.proxy_auth.enabled,
|
||||
allowedCidrs: config.server.proxy_auth.allowed_cidrs,
|
||||
trustedProxyCidrs: config.server.proxy_auth.trusted_proxy_cidrs,
|
||||
ipHeader: config.server.proxy_auth.ip_header,
|
||||
userHeader: config.server.proxy_auth.user_header,
|
||||
emailHeader: config.server.proxy_auth.email_header,
|
||||
nameHeader: config.server.proxy_auth.name_header,
|
||||
pictureHeader: config.server.proxy_auth.picture_header,
|
||||
}
|
||||
: undefined,
|
||||
db,
|
||||
cookie: {
|
||||
name: "_hp_auth",
|
||||
@@ -54,49 +70,130 @@ export async function createAppContext(config: HeadplaneConfig) {
|
||||
},
|
||||
});
|
||||
|
||||
const oidc =
|
||||
config.oidc && config.oidc.enabled !== false && headscaleApiKey
|
||||
? {
|
||||
service: createOidcService({
|
||||
issuer: config.oidc.issuer,
|
||||
clientId: config.oidc.client_id,
|
||||
clientSecret: config.oidc.client_secret,
|
||||
baseUrl: config.server.base_url ?? "",
|
||||
authorizationEndpoint: config.oidc.authorization_endpoint,
|
||||
tokenEndpoint: config.oidc.token_endpoint,
|
||||
userinfoEndpoint: config.oidc.userinfo_endpoint,
|
||||
endSessionEndpoint: config.oidc.end_session_endpoint,
|
||||
tokenEndpointAuthMethod:
|
||||
config.oidc.token_endpoint_auth_method === "client_secret_jwt"
|
||||
? undefined
|
||||
: config.oidc.token_endpoint_auth_method,
|
||||
usePkce: config.oidc.use_pkce,
|
||||
scope: config.oidc.scope,
|
||||
subjectClaims: config.oidc.subject_claims,
|
||||
allowWeakRsaKeys: config.oidc.allow_weak_rsa_keys,
|
||||
extraParams: config.oidc.extra_params,
|
||||
profilePictureSource: config.oidc.profile_picture_source,
|
||||
postLogoutRedirectUri: config.oidc.post_logout_redirect_uri,
|
||||
}),
|
||||
disableApiKeyLogin: config.oidc.disable_api_key_login,
|
||||
useEndSession: config.oidc.use_end_session,
|
||||
}
|
||||
: undefined;
|
||||
const oidc = buildOidc(config, headscaleApiKey);
|
||||
|
||||
const hsLive = createLiveStore([nodesResource, usersResource]);
|
||||
const hs = await loadHeadscaleConfig(
|
||||
config.headscale.config_path,
|
||||
config.headscale.dns_records_path,
|
||||
);
|
||||
const integration = await loadIntegration(config.integration);
|
||||
|
||||
// Disposers run in reverse-registration order on shutdown.
|
||||
const disposers: Array<() => Promise<void> | void> = [
|
||||
() => auth.stop(),
|
||||
() => hsLive.dispose(),
|
||||
() => headscale.dispose(),
|
||||
];
|
||||
if (agents.state === "enabled") {
|
||||
disposers.push(() => agents.value.dispose());
|
||||
}
|
||||
|
||||
async function apiForRequest(
|
||||
request: Request,
|
||||
): Promise<{ principal: Principal; api: HeadscaleClient }> {
|
||||
const principal = await auth.require(request);
|
||||
const apiKey = auth.getHeadscaleApiKey(principal);
|
||||
return { principal, api: headscale.client(apiKey) };
|
||||
}
|
||||
|
||||
function startServices() {
|
||||
auth.start();
|
||||
}
|
||||
|
||||
async function dispose() {
|
||||
for (const d of [...disposers].reverse()) {
|
||||
try {
|
||||
await d();
|
||||
} catch (error) {
|
||||
log.warn("server", "Error during shutdown: %s", String(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
config,
|
||||
db,
|
||||
hsApi,
|
||||
headscale,
|
||||
headscaleApiKey,
|
||||
agents,
|
||||
auth,
|
||||
oidc,
|
||||
hsLive: createLiveStore([nodesResource, usersResource]),
|
||||
hs: await loadHeadscaleConfig(
|
||||
config.headscale.config_path,
|
||||
config.headscale.config_strict,
|
||||
config.headscale.dns_records_path,
|
||||
),
|
||||
integration: await loadIntegration(config.integration),
|
||||
hsLive,
|
||||
hs,
|
||||
integration,
|
||||
apiForRequest,
|
||||
startServices,
|
||||
dispose,
|
||||
};
|
||||
}
|
||||
|
||||
function buildOidc(
|
||||
config: HeadplaneConfig,
|
||||
headscaleApiKey: string | undefined,
|
||||
): Feature<OidcService> {
|
||||
if (!config.oidc) {
|
||||
return disabled("OIDC is not configured");
|
||||
}
|
||||
if (config.oidc.enabled === false) {
|
||||
return disabled("OIDC is disabled in the configuration");
|
||||
}
|
||||
if (!headscaleApiKey) {
|
||||
return disabled("OIDC requires headscale.api_key to be configured");
|
||||
}
|
||||
|
||||
return enabled(
|
||||
createOidcService({
|
||||
issuer: config.oidc.issuer,
|
||||
clientId: config.oidc.client_id,
|
||||
clientSecret: config.oidc.client_secret,
|
||||
baseUrl: config.server.base_url ?? "",
|
||||
authorizationEndpoint: config.oidc.authorization_endpoint,
|
||||
tokenEndpoint: config.oidc.token_endpoint,
|
||||
userinfoEndpoint: config.oidc.userinfo_endpoint,
|
||||
endSessionEndpoint: config.oidc.end_session_endpoint,
|
||||
tokenEndpointAuthMethod:
|
||||
config.oidc.token_endpoint_auth_method === "client_secret_jwt"
|
||||
? undefined
|
||||
: config.oidc.token_endpoint_auth_method,
|
||||
usePkce: config.oidc.use_pkce,
|
||||
scope: config.oidc.scope,
|
||||
subjectClaims: config.oidc.subject_claims,
|
||||
roleClaim: config.oidc.role_claim,
|
||||
allowWeakRsaKeys: config.oidc.allow_weak_rsa_keys,
|
||||
extraParams: config.oidc.extra_params,
|
||||
profilePictureSource: config.oidc.profile_picture_source,
|
||||
postLogoutRedirectUri: config.oidc.post_logout_redirect_uri,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function buildAgents(
|
||||
config: HeadplaneConfig,
|
||||
supportsTagOnlyKeys: boolean,
|
||||
apiClient: HeadscaleClient | undefined,
|
||||
db: Awaited<ReturnType<typeof createDbClient>>,
|
||||
): Promise<Feature<AgentManager>> {
|
||||
const agentConfig = config.integration?.agent;
|
||||
if (!agentConfig?.enabled) {
|
||||
return disabled("Agent is not enabled in the configuration");
|
||||
}
|
||||
if (!apiClient) {
|
||||
return disabled("Agent requires headscale.api_key to be configured");
|
||||
}
|
||||
if (!supportsTagOnlyKeys) {
|
||||
return disabled("Agent requires Headscale 0.28 or newer");
|
||||
}
|
||||
|
||||
const manager = await createAgentManager(
|
||||
agentConfig,
|
||||
config.headscale.url,
|
||||
apiClient,
|
||||
supportsTagOnlyKeys,
|
||||
db,
|
||||
);
|
||||
if (!manager) {
|
||||
return disabled("Agent failed to initialize (see logs)");
|
||||
}
|
||||
return enabled(manager);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ export type HeadplaneUserInsert = typeof users.$inferInsert;
|
||||
|
||||
export const authSessions = sqliteTable("auth_sessions", {
|
||||
id: text("id").primaryKey(),
|
||||
kind: text("kind").notNull(), // 'oidc' | 'api_key'
|
||||
kind: text("kind").notNull(), // 'oidc' | 'api_key' (proxy auth is request-scoped)
|
||||
user_id: text("user_id"),
|
||||
api_key_hash: text("api_key_hash"),
|
||||
api_key_display: text("api_key_display"),
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// MARK: Feature<T>
|
||||
//
|
||||
// A two-state tagged union used in place of `T | undefined` for
|
||||
// optional features on the AppContext. Carries a human-readable
|
||||
// reason when the feature is disabled so loaders can surface it (or
|
||||
// choose to ignore it).
|
||||
//
|
||||
// This is deliberately *not* a Result/Either. It does not represent
|
||||
// async readiness or retryable failure — it represents "is this
|
||||
// feature wired up at all." Services that have their own runtime
|
||||
// status (e.g. OIDC discovery) keep that on the service itself.
|
||||
|
||||
export type Feature<T> = { state: "enabled"; value: T } | { state: "disabled"; reason: string };
|
||||
|
||||
export const enabled = <T>(value: T): Feature<T> => ({ state: "enabled", value });
|
||||
|
||||
export const disabled = (reason: string): Feature<never> => ({
|
||||
state: "disabled",
|
||||
reason,
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
// MARK: Headscale Capabilities
|
||||
//
|
||||
// Behavioural facts about the connected Headscale server, derived
|
||||
// once from `ServerVersion` at boot. Capabilities are named for *what
|
||||
// changed* — not for the version number it changed in — so a reader
|
||||
// who has never seen Headscale can tell what each flag controls
|
||||
// without consulting a release note.
|
||||
//
|
||||
// Add a capability here when you find yourself reaching for a raw
|
||||
// version comparison in endpoint or route code. Adding a capability
|
||||
// is also the right answer when a new Headscale release changes wire
|
||||
// format or removes an endpoint.
|
||||
|
||||
import { gte, type ServerVersion } from "./server-version";
|
||||
|
||||
export interface Capabilities {
|
||||
/**
|
||||
* Pre-auth keys have stable IDs. `GET /api/v1/preauthkey` (no
|
||||
* user filter) returns every key in the system, and
|
||||
* `POST /api/v1/preauthkey/expire` takes `{ id }` instead of
|
||||
* `{ user, key }`. Tag-only pre-auth keys (no owning user) are
|
||||
* supported. Introduced in 0.28.0.
|
||||
*/
|
||||
readonly preAuthKeysHaveStableIds: boolean;
|
||||
|
||||
/**
|
||||
* Node tags are a flat `tags: string[]` field on the wire.
|
||||
* Pre-0.28 returned `forcedTags` / `validTags` / `invalidTags`
|
||||
* that the client had to union itself. Introduced in 0.28.0.
|
||||
*/
|
||||
readonly nodeTagsAreFlat: boolean;
|
||||
|
||||
/**
|
||||
* A node's owning user is immutable after creation;
|
||||
* `POST /api/v1/node/{id}/user` no longer reassigns. Effective in
|
||||
* 0.28.0+.
|
||||
*/
|
||||
readonly nodeOwnerIsImmutable: boolean;
|
||||
|
||||
/**
|
||||
* The `POST /api/v1/node/register` endpoint expects the full
|
||||
* `hskey-authreq-<id>` AuthID as the `key` parameter. Pre-0.29
|
||||
* Headscale expected the raw 24-character registration ID without
|
||||
* the `hskey-authreq-` prefix. Introduced in 0.29.0.
|
||||
*/
|
||||
readonly registerKeyIncludesAuthReqPrefix: boolean;
|
||||
}
|
||||
|
||||
export function capabilitiesFor(version: ServerVersion): Capabilities {
|
||||
return {
|
||||
preAuthKeysHaveStableIds: gte(version, "0.28.0"),
|
||||
nodeTagsAreFlat: gte(version, "0.28.0"),
|
||||
nodeOwnerIsImmutable: gte(version, "0.28.0"),
|
||||
registerKeyIncludesAuthReqPrefix: gte(version, "0.29.0"),
|
||||
};
|
||||
}
|
||||
@@ -1,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);
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
}));
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { dereference } from "@readme/openapi-parser";
|
||||
import { OpenAPIV2 } from "openapi-types";
|
||||
|
||||
/**
|
||||
* A map of operation IDs to their hashes.
|
||||
*/
|
||||
export interface DocumentHash {
|
||||
[operationId: string]: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an OpenAPI v2 document, generate a map of operations with hashes.
|
||||
* This gives us deterministic identifers to determine the version of Headscale
|
||||
* that is being used at runtime.
|
||||
*
|
||||
* @param doc The OpenAPI v2 document to hash.
|
||||
* @returns A map of operation IDs to their hashes.
|
||||
*/
|
||||
export async function hashOpenApiDocument(doc: OpenAPIV2.Document): Promise<DocumentHash> {
|
||||
const spec = await dereference(doc);
|
||||
const hashes: DocumentHash = {};
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const [path, item] of Object.entries(spec.paths)) {
|
||||
for (const [method, operation] of Object.entries(item)) {
|
||||
if (typeof operation !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { parameters, responses } = operation as OpenAPIV2.OperationObject;
|
||||
const raw = JSON.stringify(
|
||||
{
|
||||
path,
|
||||
method: method.toUpperCase(),
|
||||
parameters,
|
||||
responses,
|
||||
},
|
||||
Object.keys({ path, method, parameters, responses }).sort(),
|
||||
);
|
||||
|
||||
const hash = createHash("md5").update(raw).digest("hex").slice(0, 16);
|
||||
const final = seen.has(hash) ? `${hash}_${seen.size}` : hash;
|
||||
seen.add(final);
|
||||
hashes[`${method.toUpperCase()} ${path}`] = final;
|
||||
}
|
||||
}
|
||||
|
||||
return hashes;
|
||||
}
|
||||
+144
-306
@@ -1,328 +1,166 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import { dereference } from "@readme/openapi-parser";
|
||||
import type { OpenAPIV2 } from "openapi-types";
|
||||
import { data } from "react-router";
|
||||
import { Agent, type Dispatcher, request } from "undici";
|
||||
// MARK: Headscale API
|
||||
//
|
||||
// The public entry point for talking to a Headscale server. At boot
|
||||
// we try `GET /version` (unauthenticated, present since Headscale
|
||||
// 0.27.0 — the minimum version Headplane supports) to derive a
|
||||
// typed `Capabilities` object. Boot outcomes:
|
||||
//
|
||||
// - success: parse the response, derive capabilities, done.
|
||||
// - 404: Headscale is reachable but predates 0.27.0 and is no
|
||||
// longer supported. Log an error and keep retrying so an
|
||||
// upgrade is picked up without a Headplane restart.
|
||||
// - any other failure (network, 5xx, parse): Headplane still
|
||||
// boots with `version = unknown` (capabilities-permissive) and
|
||||
// a background retry. This handles docker-compose start-order
|
||||
// races without making the whole process unhappy.
|
||||
//
|
||||
// Capabilities are always derived from `version`; once detection
|
||||
// finishes there's no further state to track.
|
||||
|
||||
import log from "~/utils/log";
|
||||
|
||||
import endpointSets, { RuntimeApiClient } from "./endpoints";
|
||||
import { undiciToFriendlyError } from "./error";
|
||||
import { HeadscaleAPIError, isApiError } from "./error-client";
|
||||
import { detectApiVersion, isAtLeast, type Version } from "./version";
|
||||
import { type Capabilities, capabilitiesFor } from "./capabilities";
|
||||
import { isDataWithApiError } from "./error-client";
|
||||
import { type ApiKeyApi, makeApiKeyApi } from "./resources/api-keys";
|
||||
import { type AuthApi, makeAuthApi } from "./resources/auth";
|
||||
import { makeNodeApi, type NodeApi } from "./resources/nodes";
|
||||
import { makePolicyApi, type PolicyApi } from "./resources/policy";
|
||||
import { makePreAuthKeyApi, type PreAuthKeyApi } from "./resources/pre-auth-keys";
|
||||
import { makeUserApi, type UserApi } from "./resources/users";
|
||||
import { formatServerVersion, parseServerVersion, type ServerVersion } from "./server-version";
|
||||
import { createTransport } from "./transport";
|
||||
|
||||
/**
|
||||
* A low-level composed interface for interacting with the Headscale API.
|
||||
* This interface provides direct access to the underlying Undici agent
|
||||
* and methods for making API requests.
|
||||
*
|
||||
* It is also responsible for handling OpenAPI spec polling and hashing to
|
||||
* determine the implementations of API methods when requested for use.
|
||||
*/
|
||||
export interface HeadscaleApiInterface {
|
||||
/**
|
||||
* The underlying Undici agent used for making requests.
|
||||
*/
|
||||
undiciAgent: Agent;
|
||||
const MIN_SUPPORTED_VERSION = "0.27.0";
|
||||
|
||||
/**
|
||||
* The base URL of the Headscale API.
|
||||
*/
|
||||
baseUrl: string;
|
||||
|
||||
/**
|
||||
* The OpenAPI hashes retrieved from the Headscale instance at runtime.
|
||||
* This is used to determine which implementations of API methods to use.
|
||||
*/
|
||||
openapiHashes: Record<string, string> | null;
|
||||
|
||||
/**
|
||||
* The detected API version of the connected Headscale instance.
|
||||
*/
|
||||
apiVersion: Version;
|
||||
|
||||
/**
|
||||
* Retrieves a runtime API client for the given API key.
|
||||
*
|
||||
* @param apiKey The API key to use for authentication.
|
||||
* @returns A `RuntimeApiClient` instance for interacting with the API.
|
||||
*/
|
||||
getRuntimeClient(apiKey: string): RuntimeApiClient;
|
||||
|
||||
/**
|
||||
* A set of helper methods made available to API method implementations.
|
||||
* The idea is to make interacting with the API easier by providing
|
||||
* common functionality that can be reused across multiple methods.
|
||||
*/
|
||||
clientHelpers: {
|
||||
/**
|
||||
* Checks if the connected Headscale instance's API version
|
||||
* is at least the specified version.
|
||||
*
|
||||
* @param version The version to check against.
|
||||
* @returns `true` if the API version is at least the specified version, `false` otherwise.
|
||||
*/
|
||||
isAtleast(version: Version): boolean;
|
||||
|
||||
/**
|
||||
* Makes a raw fetch request to the Headscale API via the Undici agent.
|
||||
* This method is used internally by API method implementations
|
||||
* to make requests to the Headscale API.
|
||||
*
|
||||
* @param path The API path to request.
|
||||
* @param options Optional request options.
|
||||
* @returns A promise that resolves to the response data.
|
||||
*/
|
||||
rawFetch(
|
||||
path: string,
|
||||
options?: Partial<Dispatcher.RequestOptions>,
|
||||
): Promise<Dispatcher.ResponseData>;
|
||||
|
||||
/**
|
||||
* Makes a typed API fetch request to the Headscale API.
|
||||
* This method is used internally by API method implementations
|
||||
* to make requests to the Headscale API and parse the response.
|
||||
*
|
||||
* @param method The HTTP method to use.
|
||||
* @param apiPath The API path to request.
|
||||
* @param apiKey The API key to use for authentication.
|
||||
* @param bodyOrQuery Optional body or query parameters.
|
||||
* @returns A promise that resolves to the typed response data.
|
||||
*/
|
||||
apiFetch<T>(
|
||||
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH",
|
||||
apiPath: `v1/${string}`,
|
||||
apiKey: string,
|
||||
bodyOrQuery?: Record<string, unknown>,
|
||||
): Promise<T>;
|
||||
};
|
||||
export interface Headscale {
|
||||
readonly version: ServerVersion;
|
||||
readonly capabilities: Capabilities;
|
||||
/** True if the Headscale server's `/health` endpoint returns 200. */
|
||||
health(): Promise<boolean>;
|
||||
/** Build an API client bound to a specific Headscale API key. */
|
||||
client(apiKey: string): HeadscaleClient;
|
||||
/** Stop background work and close the underlying HTTP agent. */
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Headscale API client interface.
|
||||
*
|
||||
* @param baseUrl The base URL of the Headscale API.
|
||||
* @param certPath Optional path to a custom TLS certificate for secure connections.
|
||||
* @returns A promise that resolves to a `HeadscaleApiClient` instance.
|
||||
*/
|
||||
export async function createHeadscaleInterface(
|
||||
baseUrl: string,
|
||||
certPath?: string,
|
||||
): Promise<HeadscaleApiInterface> {
|
||||
const undiciAgent = await createUndiciAgent(certPath);
|
||||
let openapiHashes: Record<string, string> | null = null;
|
||||
let apiVersion: Version;
|
||||
|
||||
const rawFetch = async (
|
||||
url: string,
|
||||
options?: Partial<Dispatcher.RequestOptions>,
|
||||
): Promise<Dispatcher.ResponseData> => {
|
||||
const method = options?.method ?? "GET";
|
||||
log.debug("api", "%s %s", method, url);
|
||||
|
||||
try {
|
||||
const res = await request(new URL(url, baseUrl), {
|
||||
dispatcher: undiciAgent,
|
||||
headers: {
|
||||
...options?.headers,
|
||||
Accept: "application/json",
|
||||
"User-Agent": `Headplane/${__VERSION__}`,
|
||||
},
|
||||
|
||||
body: options?.body,
|
||||
method,
|
||||
});
|
||||
|
||||
return res;
|
||||
} catch (error) {
|
||||
const errorBody = undiciToFriendlyError(error, `${method} ${url}`);
|
||||
throw data(errorBody, {
|
||||
status: 502,
|
||||
statusText: "Bad Gateway",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const apiFetch = async <T>(
|
||||
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH",
|
||||
apiPath: `v1/${string}`,
|
||||
apiKey: string,
|
||||
bodyOrQuery?: Record<string, unknown>,
|
||||
): Promise<T> => {
|
||||
let url = `/api/${apiPath}`;
|
||||
const options: Partial<Dispatcher.RequestOptions> = {
|
||||
method: method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
};
|
||||
|
||||
if (bodyOrQuery) {
|
||||
if (method === "GET" || method === "DELETE") {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(bodyOrQuery)) {
|
||||
if (value !== undefined) {
|
||||
params.append(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
if ([...params.keys()].length > 0) {
|
||||
url += `?${params.toString()}`;
|
||||
}
|
||||
} else {
|
||||
options.body = JSON.stringify(bodyOrQuery);
|
||||
options.headers = {
|
||||
...options.headers,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const res = await rawFetch(url, options);
|
||||
if (res.statusCode >= 400) {
|
||||
log.debug("api", "%s %s failed with status %d", method, apiPath, res.statusCode);
|
||||
const rawData = await res.body.text();
|
||||
const jsonData = (() => {
|
||||
try {
|
||||
return JSON.parse(rawData) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
throw data(
|
||||
{
|
||||
requestUrl: `${method} ${apiPath}`,
|
||||
statusCode: res.statusCode,
|
||||
rawData,
|
||||
data: jsonData,
|
||||
} satisfies HeadscaleAPIError,
|
||||
{ status: 502, statusText: "Bad Gateway" },
|
||||
);
|
||||
}
|
||||
|
||||
return res.body.json() as Promise<T>;
|
||||
};
|
||||
export interface HeadscaleClient {
|
||||
nodes: NodeApi;
|
||||
users: UserApi;
|
||||
policy: PolicyApi;
|
||||
preAuthKeys: PreAuthKeyApi;
|
||||
apiKeys: ApiKeyApi;
|
||||
auth: AuthApi;
|
||||
}
|
||||
|
||||
export interface CreateHeadscaleOptions {
|
||||
url: string;
|
||||
certPath?: string;
|
||||
/**
|
||||
* Polls the OpenAPI spec endpoint and generates operation hashes.
|
||||
* This is used to determine which implementations of API methods to use.
|
||||
*
|
||||
* @returns A promise that resolves to the OpenAPI operation hashes.
|
||||
* How often to retry `/version` while Headscale is unreachable.
|
||||
* Defaults to 30 seconds. Exposed for tests.
|
||||
*/
|
||||
async function fetchAndHashOpenapi(): Promise<Record<string, string> | null> {
|
||||
try {
|
||||
const res = await rawFetch("/swagger/v1/openapiv2.json");
|
||||
if (res.statusCode !== 200) {
|
||||
log.error("api", "Failed to fetch OpenAPI spec: %d", res.statusCode);
|
||||
return null;
|
||||
}
|
||||
retryIntervalMs?: number;
|
||||
}
|
||||
|
||||
const body = await res.body.json();
|
||||
const spec = await dereference(body as OpenAPIV2.Document);
|
||||
const hashes = generateSpecHashes(spec);
|
||||
log.debug("api", "OpenAPI hashes updated (%d endpoints)", Object.keys(hashes).length);
|
||||
return hashes;
|
||||
} catch (error) {
|
||||
if (isApiError(error)) {
|
||||
log.debug("api", "Failed to fetch OpenAPI spec: %d", error.statusCode);
|
||||
}
|
||||
const DEFAULT_RETRY_INTERVAL_MS = 30_000;
|
||||
|
||||
return null;
|
||||
export async function createHeadscale(opts: CreateHeadscaleOptions): Promise<Headscale> {
|
||||
const transport = await createTransport({ url: opts.url, certPath: opts.certPath });
|
||||
const retryIntervalMs = opts.retryIntervalMs ?? DEFAULT_RETRY_INTERVAL_MS;
|
||||
|
||||
let version: ServerVersion = parseServerVersion("unreachable");
|
||||
let capabilities: Capabilities = capabilitiesFor(version);
|
||||
let detected = false;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let disposed = false;
|
||||
|
||||
function settle(parsed: ServerVersion) {
|
||||
version = parsed;
|
||||
capabilities = capabilitiesFor(parsed);
|
||||
detected = true;
|
||||
if (parsed.unknown) {
|
||||
log.warn(
|
||||
"api",
|
||||
"Could not parse Headscale version %s, assuming newest known capabilities",
|
||||
parsed.raw,
|
||||
);
|
||||
} else {
|
||||
log.info("api", "Connected to Headscale %s", formatServerVersion(parsed));
|
||||
}
|
||||
}
|
||||
|
||||
const isAtleast = (version: Version): boolean => {
|
||||
return isAtLeast(apiVersion, version);
|
||||
};
|
||||
|
||||
openapiHashes = await fetchAndHashOpenapi();
|
||||
apiVersion = detectApiVersion(openapiHashes);
|
||||
|
||||
setInterval(async () => {
|
||||
const hashes = await fetchAndHashOpenapi();
|
||||
if (hashes) {
|
||||
openapiHashes = hashes;
|
||||
apiVersion = detectApiVersion(openapiHashes);
|
||||
async function detectOnce(): Promise<boolean> {
|
||||
try {
|
||||
const { version: raw } = await transport.getPublic<{ version: string }>("/version");
|
||||
settle(parseServerVersion(raw));
|
||||
return true;
|
||||
} catch (error) {
|
||||
// 404 means Headscale is reachable but predates 0.27.0 (where
|
||||
// /version was introduced). That server is below the supported
|
||||
// floor, so we don't settle — leave capabilities permissive and
|
||||
// keep retrying in case the operator upgrades in place.
|
||||
if (isDataWithApiError(error) && error.data.statusCode === 404) {
|
||||
log.error(
|
||||
"api",
|
||||
"Headscale /version returned 404; Headplane requires Headscale %s or newer",
|
||||
MIN_SUPPORTED_VERSION,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
log.debug("api", "Headscale /version probe failed: %s", String(error));
|
||||
return false;
|
||||
}
|
||||
}, 60_000); // every 60 seconds
|
||||
}
|
||||
|
||||
function scheduleRetry() {
|
||||
if (disposed || detected) return;
|
||||
retryTimer = setTimeout(async () => {
|
||||
retryTimer = undefined;
|
||||
if (disposed) return;
|
||||
if (await detectOnce()) return;
|
||||
scheduleRetry();
|
||||
}, retryIntervalMs);
|
||||
// Don't keep the event loop alive on this timer alone — Headplane
|
||||
// should still shut down cleanly while we're waiting to retry.
|
||||
retryTimer.unref?.();
|
||||
}
|
||||
|
||||
if (!(await detectOnce())) {
|
||||
log.warn(
|
||||
"api",
|
||||
"Headscale unreachable at boot; defaulting to newest-known capabilities and retrying every %dms",
|
||||
retryIntervalMs,
|
||||
);
|
||||
scheduleRetry();
|
||||
}
|
||||
|
||||
return {
|
||||
undiciAgent,
|
||||
baseUrl,
|
||||
openapiHashes,
|
||||
apiVersion,
|
||||
getRuntimeClient: (apiKey: string) => {
|
||||
return endpointSets(
|
||||
{
|
||||
rawFetch,
|
||||
apiFetch,
|
||||
isAtleast,
|
||||
},
|
||||
apiKey,
|
||||
);
|
||||
// Getters so callers always observe the latest detected values
|
||||
// without having to know about the retry loop.
|
||||
get version() {
|
||||
return version;
|
||||
},
|
||||
clientHelpers: {
|
||||
rawFetch,
|
||||
apiFetch,
|
||||
isAtleast,
|
||||
get capabilities() {
|
||||
return capabilities;
|
||||
},
|
||||
health: () => transport.health(),
|
||||
client(apiKey) {
|
||||
return {
|
||||
nodes: makeNodeApi(transport, capabilities, apiKey),
|
||||
users: makeUserApi(transport, capabilities, apiKey),
|
||||
policy: makePolicyApi(transport, capabilities, apiKey),
|
||||
preAuthKeys: makePreAuthKeyApi(transport, capabilities, apiKey),
|
||||
apiKeys: makeApiKeyApi(transport, capabilities, apiKey),
|
||||
auth: makeAuthApi(transport, capabilities, apiKey),
|
||||
};
|
||||
},
|
||||
async dispose() {
|
||||
disposed = true;
|
||||
if (retryTimer) {
|
||||
clearTimeout(retryTimer);
|
||||
retryTimer = undefined;
|
||||
}
|
||||
await transport.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Undici agent for making HTTP requests.
|
||||
*
|
||||
* @param certPath Optional path to a custom TLS certificate for secure connections.
|
||||
* @returns A promise that resolves to an `Agent` instance.
|
||||
*/
|
||||
async function createUndiciAgent(certPath?: string): Promise<Agent> {
|
||||
if (!certPath) {
|
||||
return new Agent();
|
||||
}
|
||||
|
||||
try {
|
||||
log.debug("config", "Loading certificate from %s", certPath);
|
||||
const data = await readFile(certPath, "utf8");
|
||||
|
||||
log.info("config", "Using certificate from %s", certPath);
|
||||
return new Agent({ connect: { ca: data.trim() } });
|
||||
} catch (error) {
|
||||
log.error("config", "Failed to load Headscale TLS cert: %s", error);
|
||||
log.debug("config", "Error Details: %o", error);
|
||||
return new Agent();
|
||||
}
|
||||
}
|
||||
|
||||
function generateSpecHashes(spec: OpenAPIV2.Document) {
|
||||
const hashes: Record<string, string> = {};
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const [path, item] of Object.entries(spec.paths)) {
|
||||
for (const [method, operation] of Object.entries(item)) {
|
||||
if (typeof operation !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { parameters, responses } = operation as OpenAPIV2.OperationObject;
|
||||
const raw = JSON.stringify(
|
||||
{
|
||||
path,
|
||||
method: method.toUpperCase(),
|
||||
parameters,
|
||||
responses,
|
||||
},
|
||||
Object.keys({ path, method, parameters, responses }).sort(),
|
||||
);
|
||||
|
||||
const hash = createHash("md5").update(raw).digest("hex").slice(0, 16);
|
||||
const final = seen.has(hash) ? `${hash}_${seen.size}` : hash;
|
||||
seen.add(final);
|
||||
hashes[`${method.toUpperCase()} ${path}`] = final;
|
||||
}
|
||||
}
|
||||
|
||||
return hashes;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Key } from "~/types";
|
||||
|
||||
import type { Capabilities } from "../capabilities";
|
||||
import type { Transport } from "../transport";
|
||||
|
||||
export interface ApiKeyApi {
|
||||
list(): Promise<Key[]>;
|
||||
}
|
||||
|
||||
export function makeApiKeyApi(
|
||||
transport: Transport,
|
||||
_capabilities: Capabilities,
|
||||
apiKey: string,
|
||||
): ApiKeyApi {
|
||||
return {
|
||||
list: async () => {
|
||||
const { apiKeys } = await transport.request<{ apiKeys: Key[] }>({
|
||||
method: "GET",
|
||||
path: "v1/apikey",
|
||||
apiKey,
|
||||
});
|
||||
return apiKeys;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Capabilities } from "../capabilities";
|
||||
import type { Transport } from "../transport";
|
||||
|
||||
export interface AuthApi {
|
||||
/**
|
||||
* Approve a pending Headscale authentication request.
|
||||
* Used by the Headplane agent to auto-approve its own registration.
|
||||
*/
|
||||
approve(authId: string): Promise<void>;
|
||||
}
|
||||
|
||||
export function makeAuthApi(
|
||||
transport: Transport,
|
||||
_capabilities: Capabilities,
|
||||
apiKey: string,
|
||||
): AuthApi {
|
||||
return {
|
||||
approve: async (authId) => {
|
||||
await transport.request({
|
||||
method: "POST",
|
||||
path: "v1/auth/approve",
|
||||
apiKey,
|
||||
body: { authId },
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { Machine } from "~/types";
|
||||
|
||||
import type { Capabilities } from "../capabilities";
|
||||
import type { Transport } from "../transport";
|
||||
|
||||
interface RawMachine extends Omit<Machine, "tags"> {
|
||||
tags?: string[];
|
||||
forcedTags?: string[];
|
||||
validTags?: string[];
|
||||
invalidTags?: string[];
|
||||
}
|
||||
|
||||
export interface NodeApi {
|
||||
list(): Promise<Machine[]>;
|
||||
get(id: string): Promise<Machine>;
|
||||
delete(id: string): Promise<void>;
|
||||
register(user: string, key: string): Promise<Machine>;
|
||||
approveRoutes(id: string, routes: string[]): Promise<void>;
|
||||
expire(id: string): Promise<void>;
|
||||
rename(id: string, newName: string): Promise<void>;
|
||||
setTags(id: string, tags: string[]): Promise<void>;
|
||||
/**
|
||||
* Reassign a node to a different user. Only present when
|
||||
* `capabilities.nodeOwnerIsImmutable` is false (Headscale < 0.28).
|
||||
*/
|
||||
reassignUser?: (id: string, user: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function makeNodeApi(
|
||||
transport: Transport,
|
||||
capabilities: Capabilities,
|
||||
apiKey: string,
|
||||
): NodeApi {
|
||||
function normalize(raw: RawMachine): Machine {
|
||||
if (capabilities.nodeTagsAreFlat) {
|
||||
return { ...raw, tags: raw.tags ?? [] } as Machine;
|
||||
}
|
||||
const tags = Array.from(new Set([...(raw.forcedTags ?? []), ...(raw.validTags ?? [])]));
|
||||
return { ...raw, tags } as Machine;
|
||||
}
|
||||
|
||||
const api: NodeApi = {
|
||||
list: async () => {
|
||||
const { nodes } = await transport.request<{ nodes: RawMachine[] }>({
|
||||
method: "GET",
|
||||
path: "v1/node",
|
||||
apiKey,
|
||||
});
|
||||
return nodes.map(normalize);
|
||||
},
|
||||
get: async (id) => {
|
||||
const { node } = await transport.request<{ node: RawMachine }>({
|
||||
method: "GET",
|
||||
path: `v1/node/${id}`,
|
||||
apiKey,
|
||||
});
|
||||
return normalize(node);
|
||||
},
|
||||
delete: async (id) => {
|
||||
await transport.request({ method: "DELETE", path: `v1/node/${id}`, apiKey });
|
||||
},
|
||||
register: async (user, key) => {
|
||||
// Headscale's node-register endpoint expects the registration
|
||||
// params as both query string and body — preserved as-is.
|
||||
// Pre-0.29 expects the raw 24-char registration ID; 0.29+ expects
|
||||
// the full `hskey-authreq-<id>` AuthID.
|
||||
const registerKey = capabilities.registerKeyIncludesAuthReqPrefix
|
||||
? key
|
||||
: key.replace(/^hskey-authreq-/, "");
|
||||
const qp = new URLSearchParams();
|
||||
qp.append("user", user);
|
||||
qp.append("key", registerKey);
|
||||
const { node } = await transport.request<{ node: RawMachine }>({
|
||||
method: "POST",
|
||||
path: `v1/node/register?${qp.toString()}`,
|
||||
apiKey,
|
||||
body: { user, key: registerKey },
|
||||
});
|
||||
return normalize(node);
|
||||
},
|
||||
approveRoutes: async (id, routes) => {
|
||||
await transport.request({
|
||||
method: "POST",
|
||||
path: `v1/node/${id}/approve_routes`,
|
||||
apiKey,
|
||||
body: { routes },
|
||||
});
|
||||
},
|
||||
expire: async (id) => {
|
||||
await transport.request({ method: "POST", path: `v1/node/${id}/expire`, apiKey });
|
||||
},
|
||||
rename: async (id, newName) => {
|
||||
await transport.request({
|
||||
method: "POST",
|
||||
path: `v1/node/${id}/rename/${encodeURIComponent(newName)}`,
|
||||
apiKey,
|
||||
});
|
||||
},
|
||||
setTags: async (id, tags) => {
|
||||
await transport.request({
|
||||
method: "POST",
|
||||
path: `v1/node/${id}/tags`,
|
||||
apiKey,
|
||||
body: { tags },
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
if (!capabilities.nodeOwnerIsImmutable) {
|
||||
api.reassignUser = async (id, user) => {
|
||||
await transport.request({
|
||||
method: "POST",
|
||||
path: `v1/node/${id}/user`,
|
||||
apiKey,
|
||||
body: { user },
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
return api;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Capabilities } from "../capabilities";
|
||||
import type { Transport } from "../transport";
|
||||
|
||||
export interface PolicyApi {
|
||||
get(): Promise<{ policy: string; updatedAt: Date | null }>;
|
||||
set(policy: string): Promise<{ policy: string; updatedAt: Date }>;
|
||||
}
|
||||
|
||||
export function makePolicyApi(
|
||||
transport: Transport,
|
||||
_capabilities: Capabilities,
|
||||
apiKey: string,
|
||||
): PolicyApi {
|
||||
return {
|
||||
get: async () => {
|
||||
const { policy, updatedAt } = await transport.request<{
|
||||
policy: string;
|
||||
updatedAt: string;
|
||||
}>({ method: "GET", path: "v1/policy", apiKey });
|
||||
return {
|
||||
policy,
|
||||
updatedAt: updatedAt !== null ? new Date(updatedAt) : null,
|
||||
};
|
||||
},
|
||||
set: async (policy) => {
|
||||
const { policy: newPolicy, updatedAt } = await transport.request<{
|
||||
policy: string;
|
||||
updatedAt: string;
|
||||
}>({ method: "PUT", path: "v1/policy", apiKey, body: { policy } });
|
||||
return { policy: newPolicy, updatedAt: new Date(updatedAt) };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { PreAuthKey } from "~/types";
|
||||
|
||||
import type { Capabilities } from "../capabilities";
|
||||
import type { Transport } from "../transport";
|
||||
|
||||
export interface CreatePreAuthKeyOptions {
|
||||
/** Owning user ID, or `null` for tag-only keys (0.28+). */
|
||||
user: string | null;
|
||||
ephemeral: boolean;
|
||||
reusable: boolean;
|
||||
expiration: Date | null;
|
||||
aclTags: string[] | null;
|
||||
}
|
||||
|
||||
export interface PreAuthKeyApi {
|
||||
/**
|
||||
* List every pre-auth key on the server. Only present when
|
||||
* `capabilities.preAuthKeysHaveStableIds` is true (Headscale 0.28+).
|
||||
* Pre-0.28 callers must use {@link listForUser}.
|
||||
*/
|
||||
listAll?: () => Promise<PreAuthKey[]>;
|
||||
|
||||
listForUser(userId: string): Promise<PreAuthKey[]>;
|
||||
|
||||
create(opts: CreatePreAuthKeyOptions): Promise<PreAuthKey>;
|
||||
|
||||
expire(key: PreAuthKey): Promise<void>;
|
||||
}
|
||||
|
||||
export function makePreAuthKeyApi(
|
||||
transport: Transport,
|
||||
capabilities: Capabilities,
|
||||
apiKey: string,
|
||||
): PreAuthKeyApi {
|
||||
const api: PreAuthKeyApi = {
|
||||
listForUser: async (userId) => {
|
||||
const { preAuthKeys } = await transport.request<{
|
||||
preAuthKeys: PreAuthKey[];
|
||||
}>({ method: "GET", path: "v1/preauthkey", apiKey, query: { user: userId } });
|
||||
return preAuthKeys;
|
||||
},
|
||||
|
||||
create: async ({ user, ephemeral, reusable, expiration, aclTags }) => {
|
||||
const body: Record<string, unknown> = {
|
||||
ephemeral,
|
||||
reusable,
|
||||
expiration: expiration ? expiration.toISOString() : null,
|
||||
};
|
||||
if (user) body.user = user;
|
||||
if (aclTags && aclTags.length > 0) body.aclTags = aclTags;
|
||||
|
||||
const { preAuthKey } = await transport.request<{
|
||||
preAuthKey: PreAuthKey;
|
||||
}>({ method: "POST", path: "v1/preauthkey", apiKey, body });
|
||||
return preAuthKey;
|
||||
},
|
||||
|
||||
expire: async (key) => {
|
||||
if (capabilities.preAuthKeysHaveStableIds) {
|
||||
await transport.request({
|
||||
method: "POST",
|
||||
path: "v1/preauthkey/expire",
|
||||
apiKey,
|
||||
body: { id: key.id },
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Pre-0.28: expire takes the owning user's ID (a uint64 — Headscale
|
||||
// rejects names with `proto: invalid value for uint64 field user`)
|
||||
// plus the key string.
|
||||
await transport.request({
|
||||
method: "POST",
|
||||
path: "v1/preauthkey/expire",
|
||||
apiKey,
|
||||
body: { user: key.user?.id ?? "", key: key.key },
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
if (capabilities.preAuthKeysHaveStableIds) {
|
||||
api.listAll = async () => {
|
||||
const { preAuthKeys } = await transport.request<{
|
||||
preAuthKeys: PreAuthKey[];
|
||||
}>({ method: "GET", path: "v1/preauthkey", apiKey });
|
||||
return preAuthKeys;
|
||||
};
|
||||
}
|
||||
|
||||
return api;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { User } from "~/types";
|
||||
|
||||
import type { Capabilities } from "../capabilities";
|
||||
import type { Transport } from "../transport";
|
||||
|
||||
export interface CreateUserOptions {
|
||||
name: string;
|
||||
email?: string;
|
||||
displayName?: string;
|
||||
pictureUrl?: string;
|
||||
}
|
||||
|
||||
export interface ListUsersFilter {
|
||||
id?: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface UserApi {
|
||||
list(filter?: ListUsersFilter): Promise<User[]>;
|
||||
create(opts: CreateUserOptions): Promise<User>;
|
||||
delete(id: string): Promise<void>;
|
||||
rename(id: string, newName: string): Promise<void>;
|
||||
}
|
||||
|
||||
export function makeUserApi(
|
||||
transport: Transport,
|
||||
_capabilities: Capabilities,
|
||||
apiKey: string,
|
||||
): UserApi {
|
||||
return {
|
||||
list: async (filter) => {
|
||||
const { id, name, email } = filter ?? {};
|
||||
const moreThanOneFilter = [id, name, email].filter((v) => v !== undefined).length > 1;
|
||||
if (moreThanOneFilter) {
|
||||
throw new Error("Only one of id, name, or email filters can be provided");
|
||||
}
|
||||
const { users } = await transport.request<{ users: User[] }>({
|
||||
method: "GET",
|
||||
path: "v1/user",
|
||||
apiKey,
|
||||
query: { id, name, email },
|
||||
});
|
||||
return users;
|
||||
},
|
||||
create: async ({ name, email, displayName, pictureUrl }) => {
|
||||
const { user } = await transport.request<{ user: User }>({
|
||||
method: "POST",
|
||||
path: "v1/user",
|
||||
apiKey,
|
||||
body: { name, email, displayName, pictureUrl },
|
||||
});
|
||||
return user;
|
||||
},
|
||||
delete: async (id) => {
|
||||
await transport.request({ method: "DELETE", path: `v1/user/${id}`, apiKey });
|
||||
},
|
||||
rename: async (id, newName) => {
|
||||
await transport.request({
|
||||
method: "POST",
|
||||
path: `v1/user/${id}/rename/${encodeURIComponent(newName)}`,
|
||||
apiKey,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// MARK: ServerVersion
|
||||
//
|
||||
// Parses the response from Headscale's `GET /version` endpoint into a
|
||||
// structured value that capability checks can reason about. The
|
||||
// endpoint exists in every Headscale release we support (0.27.0+) and
|
||||
// returns a plain semver-like string such as `v0.28.0`, `v0.28.0-beta.1`,
|
||||
// or `dev` for untagged builds.
|
||||
//
|
||||
// Comparisons (`gte`) are lenient about prerelease tags by design:
|
||||
// `0.28.0-beta.1` is treated as `0.28.0` for capability gating. The
|
||||
// behavioural changes that capabilities gate on always land in the
|
||||
// first prerelease of a minor version, so a strict semver
|
||||
// interpretation would lock prerelease users out of features that
|
||||
// their server actually has.
|
||||
|
||||
const SEMVER_RE = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$/;
|
||||
|
||||
// Go pseudo-version prerelease segment: `<14-digit timestamp>-<12-hex sha>`,
|
||||
// e.g. the prerelease part of `v0.0.0-20260703052708-048308511c72`. Untagged
|
||||
// Headscale builds (per-commit `main-*` / `development` Docker images) report
|
||||
// this instead of `dev`, and it parses as semver 0.0.0 — which would strip
|
||||
// every capability from a server that actually runs the newest code.
|
||||
const GO_PSEUDO_VERSION_PRERELEASE_RE = /^\d{14}-[0-9a-f]{12}$/;
|
||||
|
||||
export interface ServerVersion {
|
||||
readonly major: number;
|
||||
readonly minor: number;
|
||||
readonly patch: number;
|
||||
readonly prerelease: string | undefined;
|
||||
readonly build: string | undefined;
|
||||
/** The raw string as reported by Headscale (e.g. `v0.28.0-beta.1`, `dev`). */
|
||||
readonly raw: string;
|
||||
/**
|
||||
* True when the server reported a version we couldn't parse —
|
||||
* typically `dev` for an untagged Headscale build. Capability checks
|
||||
* treat unknown versions as having every known capability so we
|
||||
* exercise the modern code paths against unfamiliar servers rather
|
||||
* than silently falling back to compatibility shims.
|
||||
*/
|
||||
readonly unknown: boolean;
|
||||
}
|
||||
|
||||
export function parseServerVersion(raw: string): ServerVersion {
|
||||
const match = SEMVER_RE.exec(raw.trim());
|
||||
if (!match) {
|
||||
return {
|
||||
major: 0,
|
||||
minor: 0,
|
||||
patch: 0,
|
||||
prerelease: undefined,
|
||||
build: undefined,
|
||||
raw,
|
||||
unknown: true,
|
||||
};
|
||||
}
|
||||
const [, maj, min, pat, pre, build] = match;
|
||||
// A Go pseudo-version (v0.0.0-<timestamp>-<sha>) is an untagged dev build,
|
||||
// not an ancient release: treat it like `dev` so capability checks assume
|
||||
// the modern code paths instead of gating everything off.
|
||||
if (
|
||||
Number(maj) === 0 &&
|
||||
Number(min) === 0 &&
|
||||
Number(pat) === 0 &&
|
||||
pre !== undefined &&
|
||||
GO_PSEUDO_VERSION_PRERELEASE_RE.test(pre)
|
||||
) {
|
||||
return {
|
||||
major: 0,
|
||||
minor: 0,
|
||||
patch: 0,
|
||||
prerelease: pre,
|
||||
build,
|
||||
raw,
|
||||
unknown: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
major: Number(maj),
|
||||
minor: Number(min),
|
||||
patch: Number(pat),
|
||||
prerelease: pre,
|
||||
build,
|
||||
raw,
|
||||
unknown: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** Pretty-print a parsed version for logs (no `v` prefix, includes prerelease). */
|
||||
export function formatServerVersion(version: ServerVersion): string {
|
||||
if (version.unknown) return version.raw;
|
||||
const core = `${version.major}.${version.minor}.${version.patch}`;
|
||||
return version.prerelease ? `${core}-${version.prerelease}` : core;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when `version` is at least `target` (ignoring prerelease
|
||||
* tags — see module-level note). `target` must be a plain semver
|
||||
* string like `0.28.0`. Throws on a malformed target since those come
|
||||
* from static capability tables, not user input.
|
||||
*/
|
||||
export function gte(version: ServerVersion, target: string): boolean {
|
||||
if (version.unknown) return true;
|
||||
const t = parseTarget(target);
|
||||
if (version.major !== t.major) return version.major > t.major;
|
||||
if (version.minor !== t.minor) return version.minor > t.minor;
|
||||
return version.patch >= t.patch;
|
||||
}
|
||||
|
||||
interface TargetVersion {
|
||||
major: number;
|
||||
minor: number;
|
||||
patch: number;
|
||||
}
|
||||
|
||||
function parseTarget(target: string): TargetVersion {
|
||||
const match = SEMVER_RE.exec(target);
|
||||
if (!match) {
|
||||
throw new Error(`Invalid capability target version: ${target}`);
|
||||
}
|
||||
return {
|
||||
major: Number(match[1]),
|
||||
minor: Number(match[2]),
|
||||
patch: Number(match[3]),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
// MARK: Headscale Transport
|
||||
//
|
||||
// Internal HTTP transport for talking to a Headscale server. Owns
|
||||
// the Undici agent (and any custom CA), the base URL, error
|
||||
// translation, and the distinction between authenticated `/api/v1`
|
||||
// calls and unauthenticated public endpoints (`/version`, `/health`).
|
||||
//
|
||||
// This module is intentionally not exported from the package; all
|
||||
// consumers should go through `Headscale` and `HeadscaleClient` in
|
||||
// `./index.ts`, never the transport directly.
|
||||
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import { data } from "react-router";
|
||||
import { Agent, type Dispatcher, request } from "undici";
|
||||
|
||||
import log from "~/utils/log";
|
||||
|
||||
import { undiciToFriendlyError } from "./error";
|
||||
import { type HeadscaleAPIError, isApiError } from "./error-client";
|
||||
|
||||
export interface TransportRequest {
|
||||
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
|
||||
/** API path without the `/api/` prefix (e.g. `v1/node`). */
|
||||
path: `v1/${string}`;
|
||||
apiKey: string;
|
||||
/** JSON request body for non-GET/DELETE requests. */
|
||||
body?: Record<string, unknown>;
|
||||
/** Query parameters for GET/DELETE requests. */
|
||||
query?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface Transport {
|
||||
/**
|
||||
* Send an authenticated JSON request against `/api/{path}`.
|
||||
* Throws a React Router `data()` 502 response on transport errors
|
||||
* and a typed `HeadscaleAPIError` (wrapped in `data()`) on API
|
||||
* errors with statusCode >= 400.
|
||||
*/
|
||||
request<T>(opts: TransportRequest): Promise<T>;
|
||||
|
||||
/**
|
||||
* Send an unauthenticated GET against the server root
|
||||
* (e.g. `/version`, `/health`). Returns parsed JSON.
|
||||
*/
|
||||
getPublic<T>(path: `/${string}`): Promise<T>;
|
||||
|
||||
/** True if `GET /health` returns 200. Never throws. */
|
||||
health(): Promise<boolean>;
|
||||
|
||||
/** Shut down the underlying Undici agent. */
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface TransportOptions {
|
||||
url: string;
|
||||
certPath?: string;
|
||||
}
|
||||
|
||||
export async function createTransport(opts: TransportOptions): Promise<Transport> {
|
||||
const agent = await createUndiciAgent(opts.certPath);
|
||||
const baseUrl = opts.url;
|
||||
|
||||
async function rawRequest(
|
||||
url: string,
|
||||
options: Partial<Dispatcher.RequestOptions> & { method: string },
|
||||
): Promise<Dispatcher.ResponseData> {
|
||||
log.debug("api", "%s %s", options.method, url);
|
||||
try {
|
||||
return await request(new URL(url, baseUrl), {
|
||||
dispatcher: agent,
|
||||
headers: {
|
||||
...options.headers,
|
||||
Accept: "application/json",
|
||||
"User-Agent": `Headplane/${__VERSION__}`,
|
||||
},
|
||||
body: options.body,
|
||||
method: options.method,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorBody = undiciToFriendlyError(error, `${options.method} ${url}`);
|
||||
throw data(errorBody, { status: 502, statusText: "Bad Gateway" });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async request<T>({ method, path, apiKey, body, query }: TransportRequest): Promise<T> {
|
||||
let url = `/api/${path}`;
|
||||
const options: Partial<Dispatcher.RequestOptions> & { method: string } = {
|
||||
method,
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
};
|
||||
|
||||
if (query && (method === "GET" || method === "DELETE")) {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value !== undefined) {
|
||||
params.append(key, String(value));
|
||||
}
|
||||
}
|
||||
if ([...params.keys()].length > 0) {
|
||||
url += `?${params.toString()}`;
|
||||
}
|
||||
} else if (body && method !== "GET" && method !== "DELETE") {
|
||||
options.body = JSON.stringify(body);
|
||||
options.headers = { ...options.headers, "Content-Type": "application/json" };
|
||||
}
|
||||
|
||||
const res = await rawRequest(url, options);
|
||||
if (res.statusCode >= 400) {
|
||||
log.debug("api", "%s %s failed with status %d", method, path, res.statusCode);
|
||||
const rawData = await res.body.text();
|
||||
const jsonData = (() => {
|
||||
try {
|
||||
return JSON.parse(rawData) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
throw data(
|
||||
{
|
||||
requestUrl: `${method} ${path}`,
|
||||
statusCode: res.statusCode,
|
||||
rawData,
|
||||
data: jsonData,
|
||||
} satisfies HeadscaleAPIError,
|
||||
{ status: 502, statusText: "Bad Gateway" },
|
||||
);
|
||||
}
|
||||
|
||||
return res.body.json() as Promise<T>;
|
||||
},
|
||||
|
||||
async getPublic<T>(path: `/${string}`): Promise<T> {
|
||||
const res = await rawRequest(path, { method: "GET" });
|
||||
if (res.statusCode >= 400) {
|
||||
const rawData = await res.body.text();
|
||||
const jsonData = (() => {
|
||||
try {
|
||||
return JSON.parse(rawData) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
throw data(
|
||||
{
|
||||
requestUrl: `GET ${path}`,
|
||||
statusCode: res.statusCode,
|
||||
rawData,
|
||||
data: jsonData,
|
||||
} satisfies HeadscaleAPIError,
|
||||
{ status: 502, statusText: "Bad Gateway" },
|
||||
);
|
||||
}
|
||||
return res.body.json() as Promise<T>;
|
||||
},
|
||||
|
||||
async health() {
|
||||
try {
|
||||
const res = await rawRequest("/health", { method: "GET" });
|
||||
// Drain the body so the connection can be reused.
|
||||
await res.body.dump();
|
||||
return res.statusCode === 200;
|
||||
} catch (error) {
|
||||
if (isApiError(error)) {
|
||||
log.debug("api", "Health check failed: %d", error.statusCode);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
async dispose() {
|
||||
await agent.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function createUndiciAgent(certPath?: string): Promise<Agent> {
|
||||
if (!certPath) return new Agent();
|
||||
try {
|
||||
log.debug("config", "Loading certificate from %s", certPath);
|
||||
const cert = await readFile(certPath, "utf8");
|
||||
log.info("config", "Using certificate from %s", certPath);
|
||||
return new Agent({ connect: { ca: cert.trim() } });
|
||||
} catch (error) {
|
||||
log.error("config", "Failed to load Headscale TLS cert: %s", error);
|
||||
log.debug("config", "Error Details: %o", error);
|
||||
return new Agent();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -99,7 +99,7 @@ async function validateConfigPath(path: string) {
|
||||
try {
|
||||
await access(path, constants.F_OK | constants.W_OK);
|
||||
return { w: true, r: true };
|
||||
} catch (error) {
|
||||
} catch {
|
||||
log.warn("config", "Headscale DNS file at %s is not writable", path);
|
||||
return { w: false, r: true };
|
||||
}
|
||||
|
||||
@@ -1,258 +1,381 @@
|
||||
import { constants, access, readFile, writeFile } from "node:fs/promises";
|
||||
import { exit } from "node:process";
|
||||
import { setTimeout } from "node:timers/promises";
|
||||
|
||||
import { type } from "arktype";
|
||||
import * as v from "valibot";
|
||||
import { Document, parseDocument } from "yaml";
|
||||
|
||||
import log from "~/utils/log";
|
||||
|
||||
import { DNSRecord, HeadscaleDNSConfig, loadHeadscaleDNS } from "./config-dns";
|
||||
import { headscaleConfig } from "./config-schema";
|
||||
|
||||
interface PatchConfig {
|
||||
path: string;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
// We need a class for the config because we need to be able to
|
||||
// support retrieving it via a getter but also be able to
|
||||
// patch it and to query it for its mode
|
||||
class HeadscaleConfig {
|
||||
private config?: typeof headscaleConfig.infer;
|
||||
private document?: Document;
|
||||
private access: "rw" | "ro" | "no";
|
||||
private path?: string;
|
||||
private writeLock = false;
|
||||
private dns?: HeadscaleDNSConfig;
|
||||
interface DNSConfigView {
|
||||
magicDns: boolean;
|
||||
baseDomain: string;
|
||||
nameservers: string[];
|
||||
splitDns: Record<string, string[]>;
|
||||
searchDomains: string[];
|
||||
overrideDns: boolean;
|
||||
extraRecords: DNSRecord[];
|
||||
}
|
||||
|
||||
constructor(
|
||||
access: "rw" | "ro" | "no",
|
||||
dns?: HeadscaleDNSConfig,
|
||||
config?: typeof headscaleConfig.infer,
|
||||
document?: Document,
|
||||
path?: string,
|
||||
) {
|
||||
this.access = access;
|
||||
this.config = config;
|
||||
this.document = document;
|
||||
this.path = path;
|
||||
this.dns = dns;
|
||||
interface OIDCConfigView {
|
||||
issuer: string;
|
||||
allowedDomains: string[];
|
||||
allowedGroups: string[];
|
||||
allowedUsers: string[];
|
||||
}
|
||||
|
||||
interface ParsedDNSConfig {
|
||||
magic_dns: boolean;
|
||||
base_domain: string;
|
||||
nameservers: {
|
||||
global: string[];
|
||||
split: Record<string, string[]>;
|
||||
};
|
||||
search_domains: string[];
|
||||
override_local_dns: boolean;
|
||||
extra_records: DNSRecord[];
|
||||
extra_records_path?: string;
|
||||
}
|
||||
|
||||
const DNS_CONFIG_DEFAULTS: ParsedDNSConfig = {
|
||||
magic_dns: true,
|
||||
base_domain: "",
|
||||
nameservers: {
|
||||
global: [],
|
||||
split: {},
|
||||
},
|
||||
search_domains: [],
|
||||
override_local_dns: true,
|
||||
extra_records: [],
|
||||
};
|
||||
|
||||
const stringSchema = v.string();
|
||||
const stringArraySchema = v.array(v.string());
|
||||
const stringArrayRecordSchema = v.record(v.string(), stringArraySchema);
|
||||
const goBooleanSchema = v.pipe(
|
||||
v.union([v.boolean(), v.picklist(["true", "false"])]),
|
||||
v.transform((value) => value === true || value === "true"),
|
||||
);
|
||||
const dnsRecordsSchema = v.array(
|
||||
v.object({
|
||||
name: v.string(),
|
||||
type: v.string(),
|
||||
value: v.string(),
|
||||
}),
|
||||
);
|
||||
const nameserversSchema = v.object({
|
||||
global: v.optional(v.fallback(stringArraySchema, []), []),
|
||||
split: v.optional(v.fallback(stringArrayRecordSchema, {}), {}),
|
||||
});
|
||||
const dnsConfigSchema = v.object({
|
||||
magic_dns: v.optional(
|
||||
v.fallback(goBooleanSchema, DNS_CONFIG_DEFAULTS.magic_dns),
|
||||
DNS_CONFIG_DEFAULTS.magic_dns,
|
||||
),
|
||||
base_domain: v.optional(
|
||||
v.fallback(stringSchema, DNS_CONFIG_DEFAULTS.base_domain),
|
||||
DNS_CONFIG_DEFAULTS.base_domain,
|
||||
),
|
||||
nameservers: v.optional(
|
||||
v.fallback(nameserversSchema, DNS_CONFIG_DEFAULTS.nameservers),
|
||||
DNS_CONFIG_DEFAULTS.nameservers,
|
||||
),
|
||||
search_domains: v.optional(
|
||||
v.fallback(stringArraySchema, DNS_CONFIG_DEFAULTS.search_domains),
|
||||
DNS_CONFIG_DEFAULTS.search_domains,
|
||||
),
|
||||
override_local_dns: v.optional(
|
||||
v.fallback(goBooleanSchema, DNS_CONFIG_DEFAULTS.override_local_dns),
|
||||
DNS_CONFIG_DEFAULTS.override_local_dns,
|
||||
),
|
||||
extra_records: v.optional(
|
||||
v.fallback(dnsRecordsSchema, DNS_CONFIG_DEFAULTS.extra_records),
|
||||
DNS_CONFIG_DEFAULTS.extra_records,
|
||||
),
|
||||
extra_records_path: v.optional(v.string()),
|
||||
});
|
||||
const headscaleConfigSchema = v.fallback(
|
||||
v.object({
|
||||
dns: v.optional(v.fallback(dnsConfigSchema, DNS_CONFIG_DEFAULTS), DNS_CONFIG_DEFAULTS),
|
||||
}),
|
||||
{ dns: DNS_CONFIG_DEFAULTS },
|
||||
);
|
||||
const oidcConfigSchema = v.object({
|
||||
issuer: v.string(),
|
||||
allowed_domains: v.optional(v.fallback(stringArraySchema, []), []),
|
||||
allowed_groups: v.optional(v.fallback(stringArraySchema, []), []),
|
||||
allowed_users: v.optional(v.fallback(stringArraySchema, []), []),
|
||||
});
|
||||
const rawOIDCConfigSchema = v.fallback(
|
||||
v.object({
|
||||
oidc: v.optional(v.unknown()),
|
||||
}),
|
||||
{},
|
||||
);
|
||||
const extraRecordsConflictSchema = v.object({
|
||||
dns: v.optional(
|
||||
v.object({
|
||||
extra_records: v.optional(v.array(v.unknown())),
|
||||
extra_records_path: v.optional(v.string()),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
interface HeadscaleConfigState {
|
||||
document?: Document;
|
||||
config: unknown;
|
||||
access: "rw" | "ro" | "no";
|
||||
path?: string;
|
||||
writeQueue: Promise<void>;
|
||||
dns?: HeadscaleDNSConfig;
|
||||
}
|
||||
|
||||
interface HeadscaleConfig {
|
||||
readable: () => boolean;
|
||||
writable: () => boolean;
|
||||
getDNSConfig: () => DNSConfigView;
|
||||
getMagicDNSBaseDomain: () => string | undefined;
|
||||
getOIDCConfig: () => OIDCConfigView | undefined;
|
||||
hasOIDCConfig: () => boolean;
|
||||
dnsRecords: () => DNSRecord[];
|
||||
patch: (patches: PatchConfig[]) => Promise<void>;
|
||||
addDNS: (record: DNSRecord) => Promise<boolean | void>;
|
||||
removeDNS: (record: DNSRecord) => Promise<boolean | void>;
|
||||
}
|
||||
|
||||
function createHeadscaleConfig(
|
||||
access: "rw" | "ro" | "no",
|
||||
dns?: HeadscaleDNSConfig,
|
||||
document?: Document,
|
||||
path?: string,
|
||||
): HeadscaleConfig {
|
||||
const state: HeadscaleConfigState = {
|
||||
access,
|
||||
config: document?.toJSON() ?? {},
|
||||
document,
|
||||
path,
|
||||
writeQueue: Promise.resolve(),
|
||||
dns,
|
||||
};
|
||||
|
||||
return {
|
||||
readable: () => readable(state),
|
||||
writable: () => writable(state),
|
||||
getDNSConfig: () => getDNSConfig(state),
|
||||
getMagicDNSBaseDomain: () => getMagicDNSBaseDomain(state),
|
||||
getOIDCConfig: () => getOIDCConfig(state),
|
||||
hasOIDCConfig: () => hasOIDCConfig(state),
|
||||
dnsRecords: () => dnsRecords(state),
|
||||
patch: (patches) => patchHeadscaleConfig(state, patches),
|
||||
addDNS: (record) => addDNS(state, record),
|
||||
removeDNS: (record) => removeDNS(state, record),
|
||||
};
|
||||
}
|
||||
|
||||
function readable(config: HeadscaleConfigState) {
|
||||
return config.access !== "no";
|
||||
}
|
||||
|
||||
function writable(config: HeadscaleConfigState) {
|
||||
return config.access === "rw";
|
||||
}
|
||||
|
||||
function getDNSConfig(config: HeadscaleConfigState): DNSConfigView {
|
||||
const dns = v.parse(headscaleConfigSchema, config.config).dns;
|
||||
|
||||
return {
|
||||
magicDns: dns.magic_dns,
|
||||
baseDomain: dns.base_domain,
|
||||
nameservers: dns.nameservers.global,
|
||||
splitDns: dns.nameservers.split ?? {},
|
||||
searchDomains: dns.search_domains,
|
||||
overrideDns: dns.override_local_dns,
|
||||
extraRecords: dnsRecords(config),
|
||||
};
|
||||
}
|
||||
|
||||
function getMagicDNSBaseDomain(config: HeadscaleConfigState) {
|
||||
if (!readable(config)) return;
|
||||
const dns = getDNSConfig(config);
|
||||
return dns.magicDns && dns.baseDomain ? dns.baseDomain : undefined;
|
||||
}
|
||||
|
||||
function getOIDCConfig(config: HeadscaleConfigState): OIDCConfigView | undefined {
|
||||
const oidc = v.safeParse(oidcConfigSchema, v.parse(rawOIDCConfigSchema, config.config).oidc);
|
||||
if (!oidc.success) return;
|
||||
|
||||
return {
|
||||
issuer: oidc.output.issuer,
|
||||
allowedDomains: oidc.output.allowed_domains,
|
||||
allowedGroups: oidc.output.allowed_groups,
|
||||
allowedUsers: oidc.output.allowed_users,
|
||||
};
|
||||
}
|
||||
|
||||
function hasOIDCConfig(config: HeadscaleConfigState) {
|
||||
return getOIDCConfig(config) !== undefined;
|
||||
}
|
||||
|
||||
function dnsRecords(config: HeadscaleConfigState) {
|
||||
if (config.dns) {
|
||||
return config.dns.r;
|
||||
}
|
||||
|
||||
readable() {
|
||||
return this.access !== "no";
|
||||
}
|
||||
return v.parse(headscaleConfigSchema, config.config).dns.extra_records;
|
||||
}
|
||||
|
||||
writable() {
|
||||
return this.access === "rw";
|
||||
}
|
||||
|
||||
get c() {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
get d() {
|
||||
if (this.dns) {
|
||||
return this.dns.r;
|
||||
}
|
||||
|
||||
return this.config?.dns.extra_records ?? [];
|
||||
}
|
||||
|
||||
async patch(patches: PatchConfig[]) {
|
||||
if (!this.path || !this.document || !this.readable() || !this.writable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug("config", "Patching Headscale configuration");
|
||||
for (const patch of patches) {
|
||||
const { path, value } = patch;
|
||||
log.debug("config", "Patching %s with %o", path, value);
|
||||
|
||||
// If the key is something like `test.bar."foo.bar"`, then we treat
|
||||
// the foo.bar as a single key, and not as two keys, so that needs
|
||||
// to be split correctly.
|
||||
|
||||
// Iterate through each character, and if we find a dot, we check if
|
||||
// the next character is a quote, and if it is, we skip until the next
|
||||
// quote, and then we skip the next character, which should be a dot.
|
||||
// If it's not a quote, we split it.
|
||||
const key = [];
|
||||
let current = "";
|
||||
let quote = false;
|
||||
|
||||
for (const char of path) {
|
||||
if (char === '"') {
|
||||
quote = !quote;
|
||||
}
|
||||
|
||||
if (char === "." && !quote) {
|
||||
key.push(current);
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
current += char;
|
||||
}
|
||||
|
||||
key.push(current.replaceAll('"', ""));
|
||||
if (value === null) {
|
||||
this.document.deleteIn(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.document.setIn(key, value);
|
||||
}
|
||||
|
||||
// Revalidate our configuration and update the config
|
||||
// object with the new configuration
|
||||
log.info("config", "Revalidating Headscale configuration");
|
||||
const config = validateConfig(this.document.toJSON());
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug("config", "Writing updated Headscale configuration to %s", this.path);
|
||||
|
||||
// We need to lock the writeLock so that we don't try to write
|
||||
// to the file while we're already writing to it
|
||||
while (this.writeLock) {
|
||||
await setTimeout(100);
|
||||
}
|
||||
|
||||
this.writeLock = true;
|
||||
await writeFile(this.path, this.document.toString(), "utf8");
|
||||
this.config = config;
|
||||
this.writeLock = false;
|
||||
async function patchHeadscaleConfig(config: HeadscaleConfigState, patches: PatchConfig[]) {
|
||||
if (!config.path || !config.document || !readable(config) || !writable(config)) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a DNS record to the Headscale configuration.
|
||||
* Differentiates between the file mode and config mode automatically.
|
||||
* @param record The DNS record to add.
|
||||
* @returns True if we need to restart the integration.
|
||||
*/
|
||||
async addDNS(record: DNSRecord) {
|
||||
if (this.dns) {
|
||||
if (!this.dns.readable() || !this.dns.writable()) {
|
||||
log.debug("config", "DNS config is not writable");
|
||||
return;
|
||||
}
|
||||
const write = config.writeQueue.then(() => writePatches(config, patches));
|
||||
config.writeQueue = write.catch(() => undefined);
|
||||
await write;
|
||||
}
|
||||
|
||||
const records = this.dns.r;
|
||||
if (records.some((i) => i.name === record.name && i.type === record.type)) {
|
||||
log.debug("config", "DNS record already exists");
|
||||
return;
|
||||
}
|
||||
async function writePatches(config: HeadscaleConfigState, patches: PatchConfig[]) {
|
||||
if (!config.path || !config.document) return;
|
||||
|
||||
return this.dns.patch([...records, record]);
|
||||
log.debug("config", "Patching Headscale configuration");
|
||||
for (const patch of patches) {
|
||||
const { path, value } = patch;
|
||||
log.debug("config", "Patching %s with %o", path, value);
|
||||
|
||||
const key = splitPatchPath(path);
|
||||
if (value === null) {
|
||||
config.document.deleteIn(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
// If we get here, we need to add to the main config instead of
|
||||
// a separate file (which requires an integration restart)
|
||||
const existing = this.config?.dns.extra_records ?? [];
|
||||
if (existing.some((i) => i.name === record.name && i.type === record.type)) {
|
||||
config.document.setIn(key, value);
|
||||
}
|
||||
|
||||
log.debug("config", "Writing updated Headscale configuration to %s", config.path);
|
||||
await writeFile(config.path, config.document.toString(), "utf8");
|
||||
config.config = config.document.toJSON();
|
||||
}
|
||||
|
||||
function splitPatchPath(path: string) {
|
||||
const key = [];
|
||||
let current = "";
|
||||
let quote = false;
|
||||
|
||||
for (const char of path) {
|
||||
if (char === '"') {
|
||||
quote = !quote;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "." && !quote) {
|
||||
key.push(current);
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
current += char;
|
||||
}
|
||||
|
||||
key.push(current);
|
||||
return key;
|
||||
}
|
||||
|
||||
async function addDNS(config: HeadscaleConfigState, record: DNSRecord) {
|
||||
if (config.dns) {
|
||||
if (!config.dns.readable() || !config.dns.writable()) {
|
||||
log.debug("config", "DNS config is not writable");
|
||||
return;
|
||||
}
|
||||
|
||||
const records = config.dns.r;
|
||||
if (records.some((i) => i.name === record.name && i.type === record.type)) {
|
||||
log.debug("config", "DNS record already exists");
|
||||
return;
|
||||
}
|
||||
|
||||
await this.patch([
|
||||
{
|
||||
path: "dns.extra_records",
|
||||
value: Array.from(new Set([...existing, record])),
|
||||
},
|
||||
]);
|
||||
|
||||
return true;
|
||||
return config.dns.patch([...records, record]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a DNS record from the Headscale configuration.
|
||||
* Differentiates between the file mode and config mode automatically.
|
||||
* @param records The DNS record to remove.
|
||||
* @returns True if we need to restart the integration.
|
||||
*/
|
||||
async removeDNS(record: DNSRecord) {
|
||||
// In this case we need to check both the main config and the DNS config
|
||||
// to see if the record exists, and if it does, we need to remove it
|
||||
// from both places.
|
||||
const existing = dnsRecords(config);
|
||||
if (existing.some((i) => i.name === record.name && i.type === record.type)) {
|
||||
log.debug("config", "DNS record already exists");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.dns) {
|
||||
if (!this.dns.readable() || !this.dns.writable()) {
|
||||
log.debug("config", "DNS config is not writable");
|
||||
return;
|
||||
}
|
||||
await patchHeadscaleConfig(config, [
|
||||
{
|
||||
path: "dns.extra_records",
|
||||
value: [...existing, record],
|
||||
},
|
||||
]);
|
||||
|
||||
const records = this.dns.r.filter((i) => i.name !== record.name || i.type !== record.type);
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.dns.patch(records);
|
||||
}
|
||||
|
||||
// If we get here, we need to remove from the main config instead of
|
||||
// a separate file (which requires an integration restart)
|
||||
const existing = this.config?.dns.extra_records ?? [];
|
||||
const filtered = existing.filter((i) => i.name !== record.name || i.type !== record.type);
|
||||
|
||||
// If the length of the existing records is the same as the filtered
|
||||
// records, then we don't need to do anything
|
||||
if (existing.length === filtered.length) {
|
||||
async function removeDNS(config: HeadscaleConfigState, record: DNSRecord) {
|
||||
if (config.dns) {
|
||||
if (!config.dns.readable() || !config.dns.writable()) {
|
||||
log.debug("config", "DNS config is not writable");
|
||||
return;
|
||||
}
|
||||
|
||||
await this.patch([
|
||||
{
|
||||
path: "dns.extra_records",
|
||||
value: existing.filter((i) => i.name !== record.name || i.type !== record.type),
|
||||
},
|
||||
]);
|
||||
|
||||
return true;
|
||||
const records = config.dns.r.filter((i) => i.name !== record.name || i.type !== record.type);
|
||||
return config.dns.patch(records);
|
||||
}
|
||||
|
||||
const existing = dnsRecords(config);
|
||||
const filtered = existing.filter((i) => i.name !== record.name || i.type !== record.type);
|
||||
if (existing.length === filtered.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
await patchHeadscaleConfig(config, [
|
||||
{
|
||||
path: "dns.extra_records",
|
||||
value: filtered,
|
||||
},
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function loadHeadscaleConfig(path?: string, strict = true, dnsPath?: string) {
|
||||
export async function loadHeadscaleConfig(path?: string, dnsPath?: string) {
|
||||
if (!path) {
|
||||
log.debug("config", "No Headscale configuration file was provided");
|
||||
return new HeadscaleConfig("no");
|
||||
return createHeadscaleConfig("no");
|
||||
}
|
||||
|
||||
log.debug("config", "Loading Headscale configuration file: %s", path);
|
||||
const { r, w } = await validateConfigPath(path);
|
||||
if (!r) {
|
||||
return new HeadscaleConfig("no");
|
||||
return createHeadscaleConfig("no");
|
||||
}
|
||||
|
||||
const document = await loadConfigFile(path);
|
||||
if (!document) {
|
||||
return new HeadscaleConfig("no");
|
||||
return createHeadscaleConfig("no");
|
||||
}
|
||||
|
||||
if (!strict) {
|
||||
return new HeadscaleConfig(
|
||||
w ? "rw" : "ro",
|
||||
new HeadscaleDNSConfig("no"),
|
||||
augmentUnstrictConfig(document.toJSON()),
|
||||
document,
|
||||
path,
|
||||
const rawConfig = document.toJSON();
|
||||
const parsedConfig = v.parse(headscaleConfigSchema, rawConfig);
|
||||
const conflict = v.safeParse(extraRecordsConflictSchema, rawConfig);
|
||||
const extraRecordsPath = parsedConfig.dns.extra_records_path;
|
||||
|
||||
if (conflict.success && conflict.output.dns?.extra_records && extraRecordsPath) {
|
||||
log.warn(
|
||||
"config",
|
||||
"Both dns.extra_records and dns.extra_records_path are set; Headplane will use the JSON records file",
|
||||
);
|
||||
}
|
||||
|
||||
const config = validateConfig(document.toJSON());
|
||||
if (!config) {
|
||||
return new HeadscaleConfig("no");
|
||||
}
|
||||
|
||||
if (config.dns.extra_records && config.dns.extra_records_path) {
|
||||
log.warn("config", "Both extra_records and extra_records_path are set, Headscale will crash");
|
||||
|
||||
log.warn("config", "Please remove one of them from the configuration file");
|
||||
return new HeadscaleConfig("no");
|
||||
}
|
||||
|
||||
const dns = await loadHeadscaleDNS(dnsPath);
|
||||
if (dns && !config.dns.extra_records_path) {
|
||||
const dns = await loadHeadscaleDNS(dnsPath ?? extraRecordsPath);
|
||||
if (dns && !extraRecordsPath) {
|
||||
log.error(
|
||||
"config",
|
||||
"Using separate DNS config file but dns.extra_records_path is not set in Headscale config",
|
||||
@@ -263,7 +386,7 @@ export async function loadHeadscaleConfig(path?: string, strict = true, dnsPath?
|
||||
exit(1);
|
||||
}
|
||||
|
||||
return new HeadscaleConfig(w ? "rw" : "ro", dns, config, document, path);
|
||||
return createHeadscaleConfig(w ? "rw" : "ro", dns, document, path);
|
||||
}
|
||||
|
||||
async function validateConfigPath(path: string) {
|
||||
@@ -279,7 +402,7 @@ async function validateConfigPath(path: string) {
|
||||
try {
|
||||
await access(path, constants.F_OK | constants.W_OK);
|
||||
return { w: true, r: true };
|
||||
} catch (error) {
|
||||
} catch {
|
||||
log.warn("config", "Headscale configuration file at %s is not writable", path);
|
||||
return { w: false, r: true };
|
||||
}
|
||||
@@ -306,68 +429,3 @@ async function loadConfigFile(path: string) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function validateConfig(config: unknown) {
|
||||
log.debug("config", "Validating Headscale configuration");
|
||||
const result = headscaleConfig(config);
|
||||
if (result instanceof type.errors) {
|
||||
log.error("config", "Error validating Headscale configuration:");
|
||||
for (const [number, error] of result.entries()) {
|
||||
log.error("config", ` - (${number}): ${error.toString()}`);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// If config_strict is false, we set the defaults and disable
|
||||
// the schema checking for the values that are not present
|
||||
function augmentUnstrictConfig(loaded: Partial<typeof headscaleConfig.infer>) {
|
||||
log.debug("config", "Augmenting Headscale configuration in non-strict mode");
|
||||
const config = {
|
||||
...loaded,
|
||||
tls_letsencrypt_cache_dir: loaded.tls_letsencrypt_cache_dir ?? "/var/www/cache",
|
||||
tls_letsencrypt_challenge_type: loaded.tls_letsencrypt_challenge_type ?? "HTTP-01",
|
||||
grpc_listen_addr: loaded.grpc_listen_addr ?? ":50443",
|
||||
grpc_allow_insecure: loaded.grpc_allow_insecure ?? false,
|
||||
randomize_client_port: loaded.randomize_client_port ?? false,
|
||||
unix_socket: loaded.unix_socket ?? "/var/run/headscale/headscale.sock",
|
||||
unix_socket_permission: loaded.unix_socket_permission ?? "0770",
|
||||
|
||||
log: loaded.log ?? {
|
||||
level: "info",
|
||||
format: "text",
|
||||
},
|
||||
|
||||
logtail: loaded.logtail ?? {
|
||||
enabled: false,
|
||||
},
|
||||
|
||||
prefixes: loaded.prefixes ?? {
|
||||
allocation: "sequential",
|
||||
v4: "",
|
||||
v6: "",
|
||||
},
|
||||
|
||||
dns: loaded.dns ?? {
|
||||
nameservers: {
|
||||
global: [],
|
||||
split: {},
|
||||
},
|
||||
search_domains: [],
|
||||
extra_records: [],
|
||||
magic_dns: false,
|
||||
base_domain: "headscale.net",
|
||||
},
|
||||
};
|
||||
|
||||
log.warn("config", "Headscale configuration was loaded in non-strict mode");
|
||||
log.warn("config", "This is very dangerous and comes with a few caveats:");
|
||||
log.warn("config", " - Headplane could very easily crash");
|
||||
log.warn("config", " - Headplane could break your Headscale installation");
|
||||
log.warn("config", " - The UI could throw random errors/show incorrect data");
|
||||
|
||||
return config as typeof headscaleConfig.infer;
|
||||
}
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
import { type } from "arktype";
|
||||
|
||||
const goBool = type('boolean | "true" | "false"').pipe((v) => {
|
||||
if (v === "true") return true;
|
||||
if (v === "false") return false;
|
||||
return v;
|
||||
});
|
||||
|
||||
const goDuration = type("0 | string").pipe((v) => {
|
||||
return v.toString();
|
||||
});
|
||||
|
||||
const databaseConfig = type({
|
||||
type: '"sqlite" | "sqlite3"',
|
||||
sqlite: {
|
||||
path: "string",
|
||||
write_ahead_log: goBool.default(true),
|
||||
wal_autocheckpoint: "number = 1000",
|
||||
},
|
||||
})
|
||||
.or({
|
||||
type: '"postgres"',
|
||||
postgres: {
|
||||
host: "string",
|
||||
port: 'number | ""',
|
||||
name: "string",
|
||||
user: "string",
|
||||
pass: "string",
|
||||
max_open_conns: "number = 10",
|
||||
max_idle_conns: "number = 10",
|
||||
conn_max_idle_time_secs: "number = 3600",
|
||||
ssl: goBool.default(false),
|
||||
},
|
||||
})
|
||||
.merge({
|
||||
debug: goBool.default(false),
|
||||
"gorm?": {
|
||||
prepare_stmt: goBool.default(true),
|
||||
parameterized_queries: goBool.default(true),
|
||||
skip_err_record_not_found: goBool.default(true),
|
||||
slow_threshold: "number = 1000",
|
||||
},
|
||||
});
|
||||
|
||||
// Not as strict parsing because we just need the values
|
||||
// to be slightly truthy enough to safely modify them
|
||||
export type HeadscaleConfig = typeof headscaleConfig.infer;
|
||||
export const headscaleConfig = type({
|
||||
server_url: "string",
|
||||
listen_addr: "string",
|
||||
"metrics_listen_addr?": "string",
|
||||
grpc_listen_addr: 'string = ":50433"',
|
||||
grpc_allow_insecure: goBool.default(false),
|
||||
noise: {
|
||||
private_key_path: "string",
|
||||
},
|
||||
prefixes: {
|
||||
v4: "string?",
|
||||
v6: "string?",
|
||||
allocation: '"sequential" | "random" = "sequential"',
|
||||
},
|
||||
derp: {
|
||||
server: {
|
||||
enabled: goBool.default(true),
|
||||
region_id: "number?",
|
||||
region_code: "string?",
|
||||
region_name: "string?",
|
||||
stun_listen_addr: "string?",
|
||||
private_key_path: "string?",
|
||||
ipv4: "string?",
|
||||
ipv6: "string?",
|
||||
automatically_add_embedded_derp_region: goBool.default(true),
|
||||
},
|
||||
urls: "string[]?",
|
||||
paths: "string[]?",
|
||||
auto_update_enabled: goBool.default(true),
|
||||
update_frequency: goDuration.default("24h"),
|
||||
},
|
||||
|
||||
disable_check_updates: goBool.default(false),
|
||||
ephemeral_node_inactivity_timeout: goDuration.default("30m"),
|
||||
database: databaseConfig,
|
||||
|
||||
acme_url: 'string = "https://acme-v02.api.letsencrypt.org/directory"',
|
||||
acme_email: 'string = ""',
|
||||
tls_letsencrypt_hostname: 'string = ""',
|
||||
tls_letsencrypt_cache_dir: 'string = "/var/lib/headscale/cache"',
|
||||
tls_letsencrypt_challenge_type: 'string = "HTTP-01"',
|
||||
tls_letsencrypt_listen: 'string = ":http"',
|
||||
"tls_cert_path?": "string",
|
||||
"tls_key_path?": "string",
|
||||
|
||||
log: type({
|
||||
format: 'string = "text"',
|
||||
level: 'string = "info"',
|
||||
}).default(() => ({ format: "text", level: "info" })),
|
||||
|
||||
"policy?": {
|
||||
mode: '"database" | "file" = "file"',
|
||||
path: "string?",
|
||||
},
|
||||
|
||||
dns: {
|
||||
magic_dns: goBool.default(true),
|
||||
base_domain: 'string = "headscale.net"',
|
||||
override_local_dns: goBool.default(false),
|
||||
nameservers: type({
|
||||
global: type("string[]").default(() => []),
|
||||
split: type("Record<string, string[]>").default(() => ({})),
|
||||
}).default(() => ({ global: [], split: {} })),
|
||||
search_domains: type("string[]").default(() => []),
|
||||
extra_records: type({
|
||||
name: "string",
|
||||
value: "string",
|
||||
type: 'string | "A"',
|
||||
})
|
||||
.array()
|
||||
.optional(),
|
||||
extra_records_path: "string?",
|
||||
},
|
||||
|
||||
unix_socket: "string?",
|
||||
unix_socket_permission: 'string = "0770"',
|
||||
|
||||
"oidc?": {
|
||||
only_start_if_oidc_is_available: goBool.default(false),
|
||||
issuer: "string",
|
||||
client_id: "string",
|
||||
client_secret: "string?",
|
||||
client_secret_path: "string?",
|
||||
expiry: goDuration.default("180d"),
|
||||
use_expiry_from_token: goBool.default(false),
|
||||
scope: type("string[]").default(() => ["openid", "email", "profile"]),
|
||||
extra_params: "Record<string, string>?",
|
||||
allowed_domains: "string[]?",
|
||||
allowed_groups: "string[]?",
|
||||
allowed_users: "string[]?",
|
||||
"pkce?": {
|
||||
enabled: goBool.default(false),
|
||||
method: 'string = "S256"',
|
||||
},
|
||||
map_legacy_users: goBool.default(false),
|
||||
},
|
||||
|
||||
"logtail?": {
|
||||
enabled: goBool.default(false),
|
||||
},
|
||||
|
||||
randomize_client_port: goBool.default(false),
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import log from "~/utils/log";
|
||||
|
||||
import type { RuntimeApiClient } from "./api/endpoints";
|
||||
import type { HeadscaleClient } from "./api";
|
||||
|
||||
/**
|
||||
* Defines a resource that can be fetched and polled by the live store.
|
||||
@@ -19,7 +19,7 @@ export interface ResourceDefinition<T> {
|
||||
/**
|
||||
* A callback to fire to get the latest data for this resource
|
||||
*/
|
||||
readonly fetch: (client: RuntimeApiClient) => Promise<T>;
|
||||
readonly fetch: (client: HeadscaleClient) => Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,12 +37,12 @@ export function defineResource<T>(
|
||||
|
||||
export const nodesResource = defineResource("nodes", {
|
||||
pollInterval: 5_000,
|
||||
fetch: (api) => api.getNodes(),
|
||||
fetch: (api) => api.nodes.list(),
|
||||
});
|
||||
|
||||
export const usersResource = defineResource("users", {
|
||||
pollInterval: 15_000,
|
||||
fetch: (api) => api.getUsers(),
|
||||
fetch: (api) => api.users.list(),
|
||||
});
|
||||
|
||||
interface Snapshot<T> {
|
||||
@@ -54,8 +54,8 @@ interface Snapshot<T> {
|
||||
type ChangeListener = (resourceKey: string, version: string) => void;
|
||||
|
||||
export interface LiveStore {
|
||||
get<T>(resource: ResourceDefinition<T>, apiClient: RuntimeApiClient): Promise<Snapshot<T>>;
|
||||
refresh<T>(resource: ResourceDefinition<T>, apiClient: RuntimeApiClient): Promise<void>;
|
||||
get<T>(resource: ResourceDefinition<T>, apiClient: HeadscaleClient): Promise<Snapshot<T>>;
|
||||
refresh<T>(resource: ResourceDefinition<T>, apiClient: HeadscaleClient): Promise<void>;
|
||||
getVersions(): Record<string, string>;
|
||||
subscribe(listener: ChangeListener): () => void;
|
||||
dispose(): void;
|
||||
@@ -66,7 +66,7 @@ export function createLiveStore(resources: ResourceDefinition<unknown>[]): LiveS
|
||||
const serializedCache = new Map<string, string>();
|
||||
const listeners = new Set<ChangeListener>();
|
||||
const intervals = new Map<string, ReturnType<typeof setInterval>>();
|
||||
let storedApiClient: RuntimeApiClient | undefined;
|
||||
let storedApiClient: HeadscaleClient | undefined;
|
||||
let versionCounter = 0;
|
||||
|
||||
function notifyListeners(resourceKey: string, version: string) {
|
||||
@@ -77,7 +77,7 @@ export function createLiveStore(resources: ResourceDefinition<unknown>[]): LiveS
|
||||
|
||||
async function fetchResource(
|
||||
resource: ResourceDefinition<unknown>,
|
||||
apiClient: RuntimeApiClient,
|
||||
apiClient: HeadscaleClient,
|
||||
): Promise<void> {
|
||||
const data = await resource.fetch(apiClient);
|
||||
const json = JSON.stringify(data);
|
||||
@@ -138,7 +138,7 @@ export function createLiveStore(resources: ResourceDefinition<unknown>[]): LiveS
|
||||
return {
|
||||
async get<T>(
|
||||
resource: ResourceDefinition<T>,
|
||||
apiClient: RuntimeApiClient,
|
||||
apiClient: HeadscaleClient,
|
||||
): Promise<Snapshot<T>> {
|
||||
storedApiClient = apiClient;
|
||||
const def = findResource(resource.key);
|
||||
@@ -154,7 +154,7 @@ export function createLiveStore(resources: ResourceDefinition<unknown>[]): LiveS
|
||||
return snapshots.get(resource.key) as Snapshot<T>;
|
||||
},
|
||||
|
||||
async refresh<T>(resource: ResourceDefinition<T>, apiClient: RuntimeApiClient): Promise<void> {
|
||||
async refresh<T>(resource: ResourceDefinition<T>, apiClient: HeadscaleClient): Promise<void> {
|
||||
storedApiClient = apiClient;
|
||||
const def = findResource(resource.key);
|
||||
if (!def) {
|
||||
|
||||
+79
-19
@@ -1,5 +1,5 @@
|
||||
import { type ChildProcess, spawn } from "node:child_process";
|
||||
import { access, constants, mkdir, rm, stat } from "node:fs/promises";
|
||||
import { access, constants, mkdir, stat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
|
||||
@@ -11,11 +11,16 @@ import log from "~/utils/log";
|
||||
|
||||
import { HeadplaneConfig } from "./config/config-schema";
|
||||
import { hostInfo } from "./db/schema";
|
||||
import { RuntimeApiClient } from "./headscale/api/endpoints";
|
||||
import type { HeadscaleClient } from "./headscale/api";
|
||||
|
||||
export interface AgentManager {
|
||||
lookup(nodeKeys: string[]): Promise<Record<string, HostInfo>>;
|
||||
lastSync(): { syncedAt: Date | null; nodeCount: number; error?: string };
|
||||
lastSync(): {
|
||||
syncedAt: Date | null;
|
||||
nodeCount: number;
|
||||
error?: string;
|
||||
authUrl?: string;
|
||||
};
|
||||
agentNodeKey(): string | undefined;
|
||||
triggerSync(): Promise<void>;
|
||||
dispose(): void;
|
||||
@@ -32,6 +37,7 @@ interface SyncState {
|
||||
nodeCount: number;
|
||||
selfKey?: string;
|
||||
error?: string;
|
||||
authUrl?: string;
|
||||
}
|
||||
|
||||
async function hasExistingState(workDir: string): Promise<boolean> {
|
||||
@@ -46,7 +52,7 @@ async function hasExistingState(workDir: string): Promise<boolean> {
|
||||
export async function createAgentManager(
|
||||
agentConfig: NonNullable<NonNullable<HeadplaneConfig["integration"]>["agent"]> | undefined,
|
||||
headscaleUrl: string,
|
||||
apiClient: RuntimeApiClient,
|
||||
apiClient: HeadscaleClient,
|
||||
supportsTagOnlyKeys: boolean,
|
||||
db: NodeSQLiteDatabase,
|
||||
): Promise<AgentManager | undefined> {
|
||||
@@ -98,12 +104,17 @@ export async function createAgentManager(
|
||||
let responseHandler: ((line: string) => void) | null = null;
|
||||
let disposed = false;
|
||||
let consecutiveErrors = 0;
|
||||
let approvingAuthId: string | undefined;
|
||||
|
||||
async function generateAuthKey(): Promise<string> {
|
||||
const expiration = new Date(Date.now() + 5 * 60_000);
|
||||
const pak = await apiClient.createPreAuthKey(null, false, false, expiration, [
|
||||
`tag:${hostName}`,
|
||||
]);
|
||||
const pak = await apiClient.preAuthKeys.create({
|
||||
user: null,
|
||||
ephemeral: false,
|
||||
reusable: false,
|
||||
expiration,
|
||||
aclTags: [`tag:${hostName}`],
|
||||
});
|
||||
return pak.key;
|
||||
}
|
||||
|
||||
@@ -118,6 +129,9 @@ export async function createAgentManager(
|
||||
|
||||
if (authKey) {
|
||||
env.HEADPLANE_AGENT_TS_AUTHKEY = authKey;
|
||||
log.info("agent", "Spawning agent with pre-auth key (prefix: %s)", authKey.slice(0, 16));
|
||||
} else {
|
||||
log.info("agent", "Spawning agent without pre-auth key (reusing existing state)");
|
||||
}
|
||||
|
||||
const child = spawn(executablePath, [], {
|
||||
@@ -127,8 +141,40 @@ export async function createAgentManager(
|
||||
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
const text = chunk.toString().trim();
|
||||
if (text) {
|
||||
log.debug("agent", "%s", text);
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug("agent", "%s", text);
|
||||
|
||||
// tsnet prints an auth URL when it falls back to interactive login.
|
||||
// Capture it so the UI can surface the approval link if needed, and try
|
||||
// to auto-approve the agent using Headplane's admin API access.
|
||||
const authMatch = text.match(
|
||||
/To start this tsnet server, restart with TS_AUTHKEY set, or go to: (https:\/\/\S+)/,
|
||||
);
|
||||
if (authMatch) {
|
||||
state.authUrl = authMatch[1];
|
||||
log.warn("agent", "Agent is waiting for interactive approval; visit: %s", state.authUrl);
|
||||
|
||||
const authId = state.authUrl.split("/").pop();
|
||||
if (authId && authId !== approvingAuthId) {
|
||||
approvingAuthId = authId;
|
||||
log.info("agent", "Attempting to auto-approve auth request %s", authId);
|
||||
apiClient.auth
|
||||
.approve(authId)
|
||||
.then(() => {
|
||||
log.info("agent", "Auto-approved auth request %s", authId);
|
||||
})
|
||||
.catch((error) => {
|
||||
log.warn(
|
||||
"agent",
|
||||
"Failed to auto-approve auth request %s: %s",
|
||||
authId,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -144,6 +190,13 @@ export async function createAgentManager(
|
||||
child.on("exit", (code, signal) => {
|
||||
if (!disposed) {
|
||||
log.warn("agent", "Agent process exited (code=%s, signal=%s)", code, signal);
|
||||
} else {
|
||||
log.info(
|
||||
"agent",
|
||||
"Agent process exited during disposal (code=%s, signal=%s)",
|
||||
code,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
proc = null;
|
||||
|
||||
@@ -165,13 +218,18 @@ export async function createAgentManager(
|
||||
}
|
||||
|
||||
const stateExists = await hasExistingState(workDir);
|
||||
const authKey = await generateAuthKey();
|
||||
if (stateExists) {
|
||||
log.debug("agent", "Reusing existing tsnet identity");
|
||||
return spawnAgent("");
|
||||
log.info(
|
||||
"agent",
|
||||
"Existing state found; agent will use it and fall back to the pre-auth key if needed",
|
||||
);
|
||||
} else {
|
||||
log.info("agent", "No tsnet state found, agent will register with a pre-auth key");
|
||||
}
|
||||
|
||||
log.info("agent", "No tsnet state found, generating pre-auth key");
|
||||
return spawnAgent(await generateAuthKey());
|
||||
return spawnAgent(authKey);
|
||||
}
|
||||
|
||||
function sendSync(child: ChildProcess): Promise<string> {
|
||||
@@ -210,10 +268,9 @@ export async function createAgentManager(
|
||||
log.error("agent", "Sync error from agent (%d/5): %s", consecutiveErrors, output.error);
|
||||
|
||||
if (consecutiveErrors >= 5 && proc) {
|
||||
log.warn("agent", "Too many consecutive errors, killing agent and clearing state");
|
||||
log.warn("agent", "Too many consecutive errors, killing agent process for retry");
|
||||
proc.kill("SIGTERM");
|
||||
proc = null;
|
||||
await rm(join(workDir, "tailscaled.state"), { force: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -254,8 +311,10 @@ export async function createAgentManager(
|
||||
log.error("agent", "Sync failed (%d/5): %s", consecutiveErrors, message);
|
||||
|
||||
if (consecutiveErrors >= 5) {
|
||||
log.warn("agent", "Too many consecutive failures, clearing state for next attempt");
|
||||
await rm(join(workDir, "tailscaled.state"), { force: true });
|
||||
log.warn(
|
||||
"agent",
|
||||
"Too many consecutive failures; agent state is being preserved to avoid creating a new host",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
isSyncing = false;
|
||||
@@ -272,7 +331,7 @@ export async function createAgentManager(
|
||||
*/
|
||||
async function pruneStaleHostInfo() {
|
||||
try {
|
||||
const nodes = await apiClient.getNodes();
|
||||
const nodes = await apiClient.nodes.list();
|
||||
const activeKeys = nodes.map((n) => n.nodeKey);
|
||||
|
||||
if (activeKeys.length === 0) {
|
||||
@@ -298,11 +357,11 @@ export async function createAgentManager(
|
||||
|
||||
async function pruneEphemeralNodes() {
|
||||
try {
|
||||
const nodes = await apiClient.getNodes();
|
||||
const nodes = await apiClient.nodes.list();
|
||||
const toPrune = nodes.filter((n) => n.preAuthKey?.ephemeral && !n.online);
|
||||
|
||||
for (const node of toPrune) {
|
||||
await apiClient.deleteNode(node.id);
|
||||
await apiClient.nodes.delete(node.id);
|
||||
log.info("agent", "Pruned offline ephemeral node %s", node.givenName);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -338,6 +397,7 @@ export async function createAgentManager(
|
||||
syncedAt: state.syncedAt,
|
||||
nodeCount: state.nodeCount,
|
||||
error: state.error,
|
||||
authUrl: state.authUrl,
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
+51
-2
@@ -8,23 +8,72 @@
|
||||
// Vite, and the dev-only `runtime/vite-plugin.ts` dispatches requests
|
||||
// straight to `./app`'s default export.
|
||||
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { exit } from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { composeListener, startHttpServer } from "../../runtime/http";
|
||||
import requestListener, { config } from "./app";
|
||||
import log from "~/utils/log";
|
||||
|
||||
import { type StartOptions, composeListener, startHttpServer } from "../../runtime/http";
|
||||
import requestListener, { config, dispose } from "./app";
|
||||
|
||||
// `import.meta.url` resolves to `build/server/index.js`; the built
|
||||
// client lives next to it at `build/client/`.
|
||||
const clientDir = resolve(dirname(fileURLToPath(import.meta.url)), "../client");
|
||||
|
||||
let tls: StartOptions["tls"];
|
||||
const { tls_cert_path: certPath, tls_key_path: keyPath } = config.server;
|
||||
if (certPath || keyPath) {
|
||||
if (!certPath || !keyPath) {
|
||||
log.error(
|
||||
"server",
|
||||
"TLS misconfigured: both `server.tls_cert_path` and `server.tls_key_path` must be provided",
|
||||
);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const [cert, key] = await Promise.all([readFile(certPath), readFile(keyPath)]);
|
||||
tls = { cert, key };
|
||||
} catch (err) {
|
||||
log.error("server", "Failed to read TLS material: %s", err);
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// `HEADPLANE_LISTEN_FILE` is a Docker-specific contract: the
|
||||
// Dockerfile sets it to `/tmp/headplane-listen` so the bundled
|
||||
// `hp_healthcheck` binary can discover the URL to probe. Native
|
||||
// installs don't ship a consumer, so we just skip writing anything
|
||||
// if the var isn't set.
|
||||
const listenFilePath = process.env.HEADPLANE_LISTEN_FILE;
|
||||
const listenFile = listenFilePath
|
||||
? {
|
||||
path: listenFilePath,
|
||||
// Full URL including `__PREFIX__` so the Go binary can GET it
|
||||
// verbatim — no path joining, no basename knowledge needed.
|
||||
url: `${tls ? "https" : "http"}://127.0.0.1:${config.server.port}${__PREFIX__}/healthz`,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const runtimeLogger = {
|
||||
info: (message: string, ...args: unknown[]) => log.info("server", message, ...args),
|
||||
error: (message: string, ...args: unknown[]) => log.error("server", message, ...args),
|
||||
};
|
||||
|
||||
startHttpServer({
|
||||
host: config.server.host,
|
||||
port: config.server.port,
|
||||
tls,
|
||||
logger: runtimeLogger,
|
||||
listenFile,
|
||||
listener: composeListener({
|
||||
basename: __PREFIX__,
|
||||
staticRoot: clientDir,
|
||||
immutableAssets: true,
|
||||
logger: runtimeLogger,
|
||||
requestListener,
|
||||
}),
|
||||
onShutdown: dispose,
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface OidcConfig {
|
||||
usePkce?: boolean;
|
||||
scope?: string;
|
||||
subjectClaims?: string[];
|
||||
roleClaim?: string;
|
||||
allowWeakRsaKeys?: boolean;
|
||||
extraParams?: Record<string, string>;
|
||||
profilePictureSource?: "oidc" | "gravatar";
|
||||
@@ -51,6 +52,7 @@ export interface OidcIdentity {
|
||||
username: string;
|
||||
email?: string;
|
||||
picture?: string;
|
||||
role?: string;
|
||||
idToken?: string;
|
||||
}
|
||||
|
||||
@@ -73,6 +75,13 @@ export interface OidcError {
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export function logOidcError(context: string, error: OidcError): void {
|
||||
log.error("auth", "%s [%s]: %s", context, error.code, error.message);
|
||||
if (error.hint) {
|
||||
log.error("auth", "Hint: %s", error.hint);
|
||||
}
|
||||
}
|
||||
|
||||
type JwksResolver = (
|
||||
protectedHeader?: JWSHeaderParameters,
|
||||
token?: FlattenedJWSInput,
|
||||
@@ -106,6 +115,7 @@ interface OidcClaims extends JWTPayload {
|
||||
preferred_username?: string;
|
||||
email?: string;
|
||||
picture?: string;
|
||||
[claim: string]: unknown;
|
||||
}
|
||||
|
||||
interface TokenResponse {
|
||||
@@ -451,14 +461,15 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
|
||||
body: URLSearchParams,
|
||||
method: "client_secret_basic" | "client_secret_post",
|
||||
): Promise<Result<TokenResponse, OidcError>> {
|
||||
const requestBody = new URLSearchParams(body);
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
};
|
||||
|
||||
if (method === "client_secret_post") {
|
||||
body.set("client_id", config.clientId);
|
||||
body.set("client_secret", config.clientSecret);
|
||||
requestBody.set("client_id", config.clientId);
|
||||
requestBody.set("client_secret", config.clientSecret);
|
||||
} else {
|
||||
const credentials = btoa(
|
||||
`${encodeURIComponent(config.clientId)}:${encodeURIComponent(config.clientSecret)}`,
|
||||
@@ -472,7 +483,7 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
|
||||
response = await fetch(tokenEndpoint, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: body.toString(),
|
||||
body: requestBody.toString(),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
} catch (cause) {
|
||||
@@ -695,7 +706,11 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
|
||||
const needsEnrichment =
|
||||
!claims.name && !claims.email && !claims.picture && !!resolveSubject(claims);
|
||||
const needsSubjectEnrichment = !resolveSubject(claims);
|
||||
if ((!needsEnrichment && !needsSubjectEnrichment) || !ep.userinfoEndpoint) {
|
||||
const needsRoleEnrichment = !!config.roleClaim && claims[config.roleClaim] === undefined;
|
||||
if (
|
||||
(!needsEnrichment && !needsSubjectEnrichment && !needsRoleEnrichment) ||
|
||||
!ep.userinfoEndpoint
|
||||
) {
|
||||
return claims;
|
||||
}
|
||||
|
||||
@@ -714,6 +729,9 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
|
||||
}
|
||||
|
||||
const userInfo = (await response.json()) as Record<string, unknown>;
|
||||
const roleClaimValue = config.roleClaim
|
||||
? (claims[config.roleClaim] ?? userInfo[config.roleClaim])
|
||||
: undefined;
|
||||
const subjectClaimValues = Object.fromEntries(
|
||||
getSubjectClaimOrder()
|
||||
.filter((claim) => claim !== "sub")
|
||||
@@ -725,6 +743,9 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
|
||||
return {
|
||||
...claims,
|
||||
...subjectClaimValues,
|
||||
...(config.roleClaim && roleClaimValue !== undefined
|
||||
? { [config.roleClaim]: roleClaimValue }
|
||||
: {}),
|
||||
name: claims.name ?? (userInfo.name as string | undefined),
|
||||
given_name: claims.given_name ?? (userInfo.given_name as string | undefined),
|
||||
family_name: claims.family_name ?? (userInfo.family_name as string | undefined),
|
||||
@@ -776,6 +797,7 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
|
||||
username,
|
||||
email: claims.email,
|
||||
picture,
|
||||
role: config.roleClaim ? resolveRoleClaim(claims, config.roleClaim) : undefined,
|
||||
idToken,
|
||||
};
|
||||
}
|
||||
@@ -830,6 +852,24 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveRoleClaim(claims: OidcClaims, claimName: string): string | undefined {
|
||||
const value = claims[claimName];
|
||||
if (typeof value === "string") {
|
||||
return value.trim() || undefined;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const roles = new Set(value.filter((v): v is string => typeof v === "string"));
|
||||
for (const role of ["admin", "network_admin", "it_admin", "auditor", "viewer", "member"]) {
|
||||
if (roles.has(role)) {
|
||||
return role;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
discover,
|
||||
|
||||
+351
-79
@@ -1,4 +1,5 @@
|
||||
import { createHash, createHmac } from "node:crypto";
|
||||
import { isIP } from "node:net";
|
||||
|
||||
import { eq, lt, sql } from "drizzle-orm";
|
||||
import { NodeSQLiteDatabase } from "drizzle-orm/node-sqlite";
|
||||
@@ -17,23 +18,36 @@ export type Principal =
|
||||
displayName: string;
|
||||
apiKey: string;
|
||||
}
|
||||
| {
|
||||
kind: "oidc";
|
||||
sessionId: string;
|
||||
idToken?: string;
|
||||
user: {
|
||||
id: string;
|
||||
subject: string;
|
||||
role: Role;
|
||||
headscaleUserId: string | undefined;
|
||||
};
|
||||
profile: {
|
||||
name: string;
|
||||
email?: string;
|
||||
username?: string;
|
||||
picture?: string;
|
||||
};
|
||||
};
|
||||
| UserPrincipal;
|
||||
|
||||
export type UserPrincipal = {
|
||||
kind: "oidc" | "proxy";
|
||||
sessionId: string;
|
||||
idToken?: string;
|
||||
user: {
|
||||
id: string;
|
||||
subject: string;
|
||||
role: Role;
|
||||
headscaleUserId: string | undefined;
|
||||
};
|
||||
profile: {
|
||||
name: string;
|
||||
email?: string;
|
||||
username?: string;
|
||||
picture?: string;
|
||||
};
|
||||
};
|
||||
|
||||
interface ProxyAuthOptions {
|
||||
enabled: boolean;
|
||||
allowedCidrs?: string[];
|
||||
trustedProxyCidrs?: string[];
|
||||
ipHeader?: string;
|
||||
userHeader?: string;
|
||||
emailHeader?: string;
|
||||
nameHeader?: string;
|
||||
pictureHeader?: string;
|
||||
}
|
||||
|
||||
interface CookiePayload {
|
||||
sid: string;
|
||||
@@ -48,6 +62,7 @@ interface CookiePayload {
|
||||
export interface AuthServiceOptions {
|
||||
secret: string;
|
||||
headscaleApiKey?: string;
|
||||
proxyAuth?: ProxyAuthOptions;
|
||||
db: NodeSQLiteDatabase;
|
||||
cookie: {
|
||||
name: string;
|
||||
@@ -58,6 +73,7 @@ export interface AuthServiceOptions {
|
||||
}
|
||||
|
||||
export interface AuthService {
|
||||
registerRequestClientAddress(request: Request, address: string | undefined): void;
|
||||
require(request: Request): Promise<Principal>;
|
||||
can(principal: Principal, capabilities: Capabilities): boolean;
|
||||
canManageNode(principal: Principal, node: Machine): boolean;
|
||||
@@ -73,24 +89,168 @@ export interface AuthService {
|
||||
findOrCreateUser(
|
||||
subject: string,
|
||||
profile?: { name?: string; email?: string; picture?: string },
|
||||
options?: { initialRole?: string; syncRole?: string },
|
||||
): Promise<string>;
|
||||
|
||||
linkHeadscaleUser(userId: string, headscaleUserId: string): Promise<boolean>;
|
||||
unlinkHeadscaleUser(userId: string): Promise<void>;
|
||||
linkHeadscaleUserBySubject(subject: string, headscaleUserId: string): Promise<boolean>;
|
||||
listUsers(): Promise<HeadplaneUser[]>;
|
||||
claimedHeadscaleUserIds(): Promise<Set<string>>;
|
||||
roleForSubject(subject: string): Promise<Role | undefined>;
|
||||
roleForHeadscaleUser(headscaleUserId: string): Promise<Role | undefined>;
|
||||
transferOwnership(currentOwnerSubject: string, newOwnerSubject: string): Promise<boolean>;
|
||||
reassignSubject(subject: string, role: Role): Promise<boolean>;
|
||||
transferOwnership(currentOwnerUserId: string, newOwnerUserId: string): Promise<boolean>;
|
||||
reassignUser(userId: string, role: Role): Promise<boolean>;
|
||||
pruneExpiredSessions(): Promise<void>;
|
||||
start(): void;
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
export function isUserPrincipal(principal: Principal): principal is UserPrincipal {
|
||||
return principal.kind === "oidc" || principal.kind === "proxy";
|
||||
}
|
||||
|
||||
interface CidrRange {
|
||||
family: 4 | 6;
|
||||
base: bigint;
|
||||
mask: bigint;
|
||||
}
|
||||
|
||||
const DEFAULT_PROXY_AUTH_CIDRS = ["127.0.0.1/32", "::1/128"];
|
||||
const DEFAULT_PROXY_AUTH_USER_HEADER = "Remote-User";
|
||||
|
||||
function normalizeIpAddress(address: string): string {
|
||||
if (address.startsWith("::ffff:")) {
|
||||
const mapped = address.slice("::ffff:".length);
|
||||
if (isIP(mapped) === 4) {
|
||||
return mapped;
|
||||
}
|
||||
}
|
||||
|
||||
return address;
|
||||
}
|
||||
|
||||
function parseIpv4(address: string): bigint | undefined {
|
||||
const parts = address.split(".");
|
||||
if (parts.length !== 4) {
|
||||
return;
|
||||
}
|
||||
|
||||
let value = 0n;
|
||||
for (const part of parts) {
|
||||
if (!/^\d+$/.test(part)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const byte = Number(part);
|
||||
if (byte < 0 || byte > 255) {
|
||||
return;
|
||||
}
|
||||
|
||||
value = (value << 8n) + BigInt(byte);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseIpv6(address: string): bigint | undefined {
|
||||
const sections = address.split("::");
|
||||
if (sections.length > 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
const head = sections[0] ? sections[0].split(":") : [];
|
||||
const tail = sections.length === 2 && sections[1] ? sections[1].split(":") : [];
|
||||
const missing = 8 - head.length - tail.length;
|
||||
if (missing < 0 || (sections.length === 1 && missing !== 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const groups = [...head, ...Array<string>(missing).fill("0"), ...tail];
|
||||
if (groups.length !== 8) {
|
||||
return;
|
||||
}
|
||||
|
||||
let value = 0n;
|
||||
for (const group of groups) {
|
||||
if (!/^[0-9a-fA-F]{1,4}$/.test(group)) {
|
||||
return;
|
||||
}
|
||||
|
||||
value = (value << 16n) + BigInt(parseInt(group, 16));
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseIpAddress(address: string): { family: 4 | 6; value: bigint } | undefined {
|
||||
const normalized = normalizeIpAddress(address);
|
||||
const family = isIP(normalized);
|
||||
if (family === 4) {
|
||||
const value = parseIpv4(normalized);
|
||||
return value === undefined ? undefined : { family, value };
|
||||
}
|
||||
if (family === 6) {
|
||||
const value = parseIpv6(normalized);
|
||||
return value === undefined ? undefined : { family, value };
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
function parseCidr(cidr: string): CidrRange {
|
||||
const parts = cidr.trim().split("/");
|
||||
if (parts.length > 2) {
|
||||
throw new Error(`Invalid proxy auth CIDR: ${cidr}`);
|
||||
}
|
||||
|
||||
const [rawAddress, rawPrefix] = parts;
|
||||
const address = parseIpAddress(rawAddress);
|
||||
if (!address) {
|
||||
throw new Error(`Invalid proxy auth CIDR address: ${cidr}`);
|
||||
}
|
||||
|
||||
const maxBits = address.family === 4 ? 32 : 128;
|
||||
const prefix = rawPrefix === undefined ? maxBits : Number(rawPrefix);
|
||||
if (!Number.isInteger(prefix) || prefix < 0 || prefix > maxBits) {
|
||||
throw new Error(`Invalid proxy auth CIDR prefix: ${cidr}`);
|
||||
}
|
||||
|
||||
const bits = BigInt(maxBits);
|
||||
const hostBits = BigInt(maxBits - prefix);
|
||||
const allOnes = (1n << bits) - 1n;
|
||||
const mask = prefix === 0 ? 0n : (allOnes << hostBits) & allOnes;
|
||||
|
||||
return {
|
||||
family: address.family,
|
||||
base: address.value & mask,
|
||||
mask,
|
||||
};
|
||||
}
|
||||
|
||||
function cidrContains(range: CidrRange, address: string): boolean {
|
||||
const parsed = parseIpAddress(address);
|
||||
if (!parsed || parsed.family !== range.family) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (parsed.value & range.mask) === range.base;
|
||||
}
|
||||
|
||||
export function createAuthService(opts: AuthServiceOptions): AuthService {
|
||||
const requestCache = new WeakMap<Request, Promise<Principal>>();
|
||||
const clientAddresses = new WeakMap<Request, string>();
|
||||
const proxyAuthCidrs = opts.proxyAuth?.enabled
|
||||
? (opts.proxyAuth.allowedCidrs?.length
|
||||
? opts.proxyAuth.allowedCidrs
|
||||
: DEFAULT_PROXY_AUTH_CIDRS
|
||||
).map(parseCidr)
|
||||
: [];
|
||||
const trustedProxyCidrs = opts.proxyAuth?.enabled
|
||||
? (opts.proxyAuth.trustedProxyCidrs?.length
|
||||
? opts.proxyAuth.trustedProxyCidrs
|
||||
: DEFAULT_PROXY_AUTH_CIDRS
|
||||
).map(parseCidr)
|
||||
: [];
|
||||
let pruneTimer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
async function encodeCookie(payload: CookiePayload, maxAge: number): Promise<string> {
|
||||
@@ -141,7 +301,139 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
|
||||
return createHash("sha256").update(key).digest("hex");
|
||||
}
|
||||
|
||||
function registerRequestClientAddress(request: Request, address: string | undefined): void {
|
||||
if (address) {
|
||||
clientAddresses.set(request, address);
|
||||
}
|
||||
}
|
||||
|
||||
function getForwardedClientAddress(request: Request): string | undefined {
|
||||
const headerName = opts.proxyAuth?.ipHeader;
|
||||
if (!headerName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = request.headers.get(headerName)?.trim();
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const first = value.split(",")[0]?.trim();
|
||||
if (!first) {
|
||||
return;
|
||||
}
|
||||
|
||||
return parseIpAddress(first) ? first : undefined;
|
||||
}
|
||||
|
||||
function getProxyAuthClientAddress(request: Request): string | undefined {
|
||||
const directAddress = clientAddresses.get(request);
|
||||
if (!directAddress) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!opts.proxyAuth?.ipHeader) {
|
||||
return directAddress;
|
||||
}
|
||||
|
||||
const directPeerTrusted = trustedProxyCidrs.some((cidr) => cidrContains(cidr, directAddress));
|
||||
if (!directPeerTrusted) {
|
||||
return;
|
||||
}
|
||||
|
||||
return getForwardedClientAddress(request);
|
||||
}
|
||||
|
||||
async function resolveUserPrincipal(options: {
|
||||
kind: UserPrincipal["kind"];
|
||||
sessionId: string;
|
||||
userId: string;
|
||||
idToken?: string;
|
||||
profile?: {
|
||||
name?: string;
|
||||
email?: string;
|
||||
username?: string;
|
||||
};
|
||||
}): Promise<UserPrincipal> {
|
||||
const [user] = await opts.db.select().from(users).where(eq(users.id, options.userId)).limit(1);
|
||||
|
||||
if (!user) {
|
||||
throw new Error("User record not found");
|
||||
}
|
||||
|
||||
const role = (user.role in Roles ? user.role : "member") as Role;
|
||||
return {
|
||||
kind: options.kind,
|
||||
sessionId: options.sessionId,
|
||||
idToken: options.idToken,
|
||||
user: {
|
||||
id: user.id,
|
||||
subject: user.sub,
|
||||
role,
|
||||
headscaleUserId: user.headscale_user_id ?? undefined,
|
||||
},
|
||||
profile: {
|
||||
name: options.profile?.name ?? user.name ?? user.sub,
|
||||
email: options.profile?.email ?? user.email ?? undefined,
|
||||
username: options.profile?.username,
|
||||
picture: user.picture ?? undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveProxyAuthPrincipal(request: Request): Promise<Principal | undefined> {
|
||||
if (!opts.proxyAuth?.enabled) {
|
||||
return;
|
||||
}
|
||||
if (!opts.headscaleApiKey) {
|
||||
throw new Error("Proxy authentication requires headscale.api_key to be configured");
|
||||
}
|
||||
|
||||
const clientAddress = getProxyAuthClientAddress(request);
|
||||
if (!clientAddress || !proxyAuthCidrs.some((cidr) => cidrContains(cidr, clientAddress))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userHeader = opts.proxyAuth.userHeader ?? DEFAULT_PROXY_AUTH_USER_HEADER;
|
||||
const proxyUser = request.headers.get(userHeader)?.trim();
|
||||
if (!proxyUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
const email = opts.proxyAuth.emailHeader
|
||||
? request.headers.get(opts.proxyAuth.emailHeader)?.trim()
|
||||
: undefined;
|
||||
const name = opts.proxyAuth.nameHeader
|
||||
? request.headers.get(opts.proxyAuth.nameHeader)?.trim()
|
||||
: undefined;
|
||||
const picture = opts.proxyAuth.pictureHeader
|
||||
? request.headers.get(opts.proxyAuth.pictureHeader)?.trim()
|
||||
: undefined;
|
||||
const subject = `proxy:${proxyUser}`;
|
||||
const userId = await findOrCreateUser(subject, {
|
||||
name: name || proxyUser,
|
||||
email: email || undefined,
|
||||
picture: picture || undefined,
|
||||
});
|
||||
|
||||
return resolveUserPrincipal({
|
||||
kind: "proxy",
|
||||
sessionId: "proxy-auth",
|
||||
userId,
|
||||
profile: {
|
||||
name: name || proxyUser,
|
||||
email: email || undefined,
|
||||
username: proxyUser,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function resolve(request: Request): Promise<Principal> {
|
||||
const proxyPrincipal = await resolveProxyAuthPrincipal(request);
|
||||
if (proxyPrincipal) {
|
||||
return proxyPrincipal;
|
||||
}
|
||||
|
||||
const payload = await decodeCookie(request);
|
||||
|
||||
const [session] = await opts.db
|
||||
@@ -176,30 +468,17 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
|
||||
throw new Error("OIDC session missing user_id");
|
||||
}
|
||||
|
||||
const [user] = await opts.db.select().from(users).where(eq(users.id, session.user_id)).limit(1);
|
||||
|
||||
if (!user) {
|
||||
throw new Error("User record not found");
|
||||
}
|
||||
|
||||
const role = (user.role in Roles ? user.role : "member") as Role;
|
||||
return {
|
||||
return resolveUserPrincipal({
|
||||
kind: "oidc",
|
||||
sessionId: session.id,
|
||||
idToken: session.oidc_id_token ?? undefined,
|
||||
user: {
|
||||
id: user.id,
|
||||
subject: user.sub,
|
||||
role,
|
||||
headscaleUserId: user.headscale_user_id ?? undefined,
|
||||
},
|
||||
userId: session.user_id,
|
||||
profile: {
|
||||
name: payload.profile?.name ?? user.name ?? user.sub,
|
||||
email: payload.profile?.email ?? user.email ?? undefined,
|
||||
name: payload.profile?.name,
|
||||
email: payload.profile?.email,
|
||||
username: payload.profile?.username,
|
||||
picture: user.picture ?? undefined,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function require(request: Request): Promise<Principal> {
|
||||
@@ -242,7 +521,7 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
|
||||
}
|
||||
|
||||
if (!opts.headscaleApiKey) {
|
||||
throw new Error("OIDC sessions require headscale.api_key to be configured");
|
||||
throw new Error("User sessions require headscale.api_key to be configured");
|
||||
}
|
||||
|
||||
return opts.headscaleApiKey;
|
||||
@@ -304,16 +583,21 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
|
||||
async function findOrCreateUser(
|
||||
subject: string,
|
||||
profile?: { name?: string; email?: string; picture?: string },
|
||||
options?: { initialRole?: string; syncRole?: string },
|
||||
): Promise<string> {
|
||||
const [existing] = await opts.db.select().from(users).where(eq(users.sub, subject)).limit(1);
|
||||
|
||||
if (existing) {
|
||||
const syncedRole = normalizeInitialRole(options?.syncRole);
|
||||
await opts.db
|
||||
.update(users)
|
||||
.set({
|
||||
name: profile?.name,
|
||||
email: profile?.email,
|
||||
picture: profile?.picture,
|
||||
...(syncedRole && existing.role !== "owner"
|
||||
? { role: syncedRole, caps: capsForRole(syncedRole) }
|
||||
: {}),
|
||||
last_login_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
})
|
||||
@@ -321,6 +605,7 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
|
||||
return existing.id;
|
||||
}
|
||||
|
||||
const initialRole = normalizeInitialRole(options?.initialRole) ?? "member";
|
||||
const id = ulid();
|
||||
await opts.db.insert(users).values({
|
||||
id,
|
||||
@@ -328,8 +613,8 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
|
||||
name: profile?.name,
|
||||
email: profile?.email,
|
||||
picture: profile?.picture,
|
||||
role: "member",
|
||||
caps: capsForRole("member"),
|
||||
role: initialRole,
|
||||
caps: capsForRole(initialRole),
|
||||
});
|
||||
|
||||
const [{ count }] = await opts.db.select({ count: sql<number>`count(*)` }).from(users);
|
||||
@@ -344,6 +629,14 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
|
||||
return id;
|
||||
}
|
||||
|
||||
function normalizeInitialRole(role: string | undefined): Exclude<Role, "owner"> | undefined {
|
||||
if (role && role !== "owner" && role in Roles) {
|
||||
return role as Exclude<Role, "owner">;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function linkHeadscaleUser(userId: string, headscaleUserId: string): Promise<boolean> {
|
||||
const [existing] = await opts.db
|
||||
.select({ id: users.id })
|
||||
@@ -370,23 +663,6 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
|
||||
.where(eq(users.id, userId));
|
||||
}
|
||||
|
||||
async function linkHeadscaleUserBySubject(
|
||||
subject: string,
|
||||
headscaleUserId: string,
|
||||
): Promise<boolean> {
|
||||
const [user] = await opts.db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.sub, subject))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return linkHeadscaleUser(user.id, headscaleUserId);
|
||||
}
|
||||
|
||||
async function listUsers(): Promise<HeadplaneUser[]> {
|
||||
return opts.db.select().from(users);
|
||||
}
|
||||
@@ -428,13 +704,17 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
|
||||
}
|
||||
|
||||
async function transferOwnership(
|
||||
currentOwnerSubject: string,
|
||||
newOwnerSubject: string,
|
||||
currentOwnerUserId: string,
|
||||
newOwnerUserId: string,
|
||||
): Promise<boolean> {
|
||||
if (currentOwnerUserId === newOwnerUserId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [current] = await opts.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.sub, currentOwnerSubject))
|
||||
.where(eq(users.id, currentOwnerUserId))
|
||||
.limit(1);
|
||||
|
||||
if (!current || current.role !== "owner") {
|
||||
@@ -444,10 +724,10 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
|
||||
const [target] = await opts.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.sub, newOwnerSubject))
|
||||
.where(eq(users.id, newOwnerUserId))
|
||||
.limit(1);
|
||||
|
||||
if (!target || target.id === current.id) {
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -464,24 +744,16 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
|
||||
return true;
|
||||
}
|
||||
|
||||
async function reassignSubject(subject: string, role: Role): Promise<boolean> {
|
||||
const currentRole = await roleForSubject(subject);
|
||||
if (currentRole === "owner") {
|
||||
async function reassignUser(userId: string, role: Role): Promise<boolean> {
|
||||
const [user] = await opts.db.select().from(users).where(eq(users.id, userId)).limit(1);
|
||||
if (!user || user.role === "owner") {
|
||||
return false;
|
||||
}
|
||||
|
||||
await opts.db
|
||||
.insert(users)
|
||||
.values({
|
||||
id: ulid(),
|
||||
sub: subject,
|
||||
role,
|
||||
caps: capsForRole(role),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: users.sub,
|
||||
set: { role, caps: capsForRole(role), updated_at: new Date() },
|
||||
});
|
||||
.update(users)
|
||||
.set({ role, caps: capsForRole(role), updated_at: new Date() })
|
||||
.where(eq(users.id, userId));
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -502,6 +774,7 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
|
||||
}
|
||||
|
||||
return {
|
||||
registerRequestClientAddress,
|
||||
require: require,
|
||||
can,
|
||||
canManageNode,
|
||||
@@ -512,13 +785,12 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
|
||||
findOrCreateUser,
|
||||
linkHeadscaleUser,
|
||||
unlinkHeadscaleUser,
|
||||
linkHeadscaleUserBySubject,
|
||||
listUsers,
|
||||
claimedHeadscaleUserIds,
|
||||
roleForSubject,
|
||||
roleForHeadscaleUser,
|
||||
transferOwnership,
|
||||
reassignSubject,
|
||||
reassignUser,
|
||||
pruneExpiredSessions,
|
||||
start,
|
||||
stop,
|
||||
|
||||
+34
-20
@@ -1,44 +1,58 @@
|
||||
// MARK: Side-Effects
|
||||
// This module contains a side-effect because everything running here
|
||||
// is static and logger is later modified in `app/server/index.ts` to
|
||||
// disable debug logging if the `HEADPLANE_DEBUG_LOG` specifies as such.
|
||||
// This module contains a side-effect because log levels are read from
|
||||
// the environment once at module initialization.
|
||||
|
||||
import pino from "pino";
|
||||
|
||||
const levels = ["info", "warn", "error", "debug"] as const;
|
||||
type Category = "server" | "config" | "agent" | "api" | "auth" | "sse";
|
||||
type Level = (typeof levels)[number];
|
||||
|
||||
export interface Logger extends Record<
|
||||
(typeof levels)[number],
|
||||
Level,
|
||||
(category: Category, message: string, ...args: unknown[]) => void
|
||||
> {
|
||||
debugEnabled: boolean;
|
||||
}
|
||||
|
||||
const logLevels = getLogLevels();
|
||||
const rootLogger = createRootLogger();
|
||||
export default {
|
||||
debugEnabled: logLevels.includes("debug"),
|
||||
debug: (..._: Parameters<Logger["debug"]>) => {},
|
||||
...Object.fromEntries(
|
||||
logLevels.map((level) => [
|
||||
level,
|
||||
(category: Category, message: string, ...args: unknown[]) => {
|
||||
const date = new Date().toISOString();
|
||||
console.log(`${date} [${category}] ${level.toUpperCase()}: ${message}`, ...args);
|
||||
},
|
||||
]),
|
||||
),
|
||||
debugEnabled: rootLogger.isLevelEnabled("debug"),
|
||||
info: (category, msg, ...args) => rootLogger.info({ component: category }, msg, ...args),
|
||||
warn: (category, msg, ...args) => rootLogger.warn({ component: category }, msg, ...args),
|
||||
error: (category, msg, ...args) => rootLogger.error({ component: category }, msg, ...args),
|
||||
debug: (category, msg, ...args) => rootLogger.debug({ component: category }, msg, ...args),
|
||||
} as Logger;
|
||||
|
||||
function getLogLevels() {
|
||||
function createRootLogger() {
|
||||
const options = {
|
||||
level: getLogLevel(),
|
||||
timestamp: () => `,"timestamp":"${new Date().toISOString()}"`,
|
||||
formatters: {
|
||||
level: (label: string) => ({ level: label }),
|
||||
},
|
||||
} satisfies pino.LoggerOptions;
|
||||
|
||||
if (process.env.NODE_ENV === "test") {
|
||||
return pino(options);
|
||||
}
|
||||
|
||||
const destination = pino.destination({ dest: 1, sync: false });
|
||||
process.on("exit", () => destination.flushSync());
|
||||
return pino(options, destination);
|
||||
}
|
||||
|
||||
function getLogLevel(): Level {
|
||||
const debugLog = process.env.HEADPLANE_DEBUG_LOG;
|
||||
if (debugLog == null) {
|
||||
return ["info", "warn", "error"];
|
||||
return "info";
|
||||
}
|
||||
|
||||
const normalized = debugLog.trim().toLowerCase();
|
||||
const truthyValues = ["1", "true", "yes", "on"];
|
||||
if (!truthyValues.includes(normalized)) {
|
||||
return ["info", "warn", "error"];
|
||||
return "info";
|
||||
}
|
||||
|
||||
return ["info", "warn", "error", "debug"];
|
||||
return "debug";
|
||||
}
|
||||
|
||||
@@ -48,3 +48,94 @@ export function mapNodes(
|
||||
export function sortNodeTags(nodes: Machine[]): string[] {
|
||||
return Array.from(new Set(nodes.flatMap((node) => node.tags))).sort();
|
||||
}
|
||||
|
||||
export function sortAssignableTags(nodes: Machine[], policy?: string): string[] {
|
||||
return Array.from(new Set([...sortNodeTags(nodes), ...extractTagOwnerTags(policy)])).sort();
|
||||
}
|
||||
|
||||
export function extractTagOwnerTags(policy: string | undefined): string[] {
|
||||
if (!policy) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(stripJsonCommentsAndTrailingCommas(policy)) as unknown;
|
||||
if (parsed == null || typeof parsed !== "object" || !("tagOwners" in parsed)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const tagOwners = (parsed as { tagOwners?: unknown }).tagOwners;
|
||||
if (tagOwners == null || typeof tagOwners !== "object" || Array.isArray(tagOwners)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.keys(tagOwners)
|
||||
.filter((tag) => tag.startsWith("tag:"))
|
||||
.sort();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function stripJsonCommentsAndTrailingCommas(input: string): string {
|
||||
let output = "";
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
let inLineComment = false;
|
||||
let inBlockComment = false;
|
||||
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const char = input[i];
|
||||
const next = input[i + 1];
|
||||
|
||||
if (inLineComment) {
|
||||
if (char === "\n" || char === "\r") {
|
||||
inLineComment = false;
|
||||
output += char;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inBlockComment) {
|
||||
if (char === "*" && next === "/") {
|
||||
inBlockComment = false;
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inString) {
|
||||
output += char;
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
} else if (char === "\\") {
|
||||
escaped = true;
|
||||
} else if (char === '"') {
|
||||
inString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"') {
|
||||
inString = true;
|
||||
output += char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "/" && next === "/") {
|
||||
inLineComment = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "/" && next === "*") {
|
||||
inBlockComment = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
output += char;
|
||||
}
|
||||
|
||||
return output.replace(/,\s*([}\]])/g, "$1");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
const authRequestKeyPattern = /^hskey-authreq-[A-Za-z0-9_-]{16,}$/;
|
||||
const authRequestKeySearchPattern = /hskey-authreq-[A-Za-z0-9_-]{16,}/;
|
||||
const legacyMachineKeyPattern = /^mkey:[A-Za-z0-9_-]{16,}$/;
|
||||
const registerUrlPattern = /\/register\/([^\s?#]+)/;
|
||||
const registrationKeySuffixPattern = /^[A-Za-z0-9_-]{24,}$/;
|
||||
|
||||
export function normalizeRegistrationKey(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const registerUrlMatch = trimmed.match(registerUrlPattern);
|
||||
if (registerUrlMatch) {
|
||||
const token = normalizeRegistrationToken(registerUrlMatch[1]);
|
||||
if (token) {
|
||||
return token;
|
||||
}
|
||||
}
|
||||
|
||||
const authRequestKeyMatch = trimmed.match(authRequestKeySearchPattern);
|
||||
if (authRequestKeyMatch) {
|
||||
return authRequestKeyMatch[0];
|
||||
}
|
||||
|
||||
return normalizeRegistrationToken(trimmed);
|
||||
}
|
||||
|
||||
function normalizeRegistrationToken(value: string): string | null {
|
||||
let token: string;
|
||||
try {
|
||||
token = decodeURIComponent(value.trim());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (authRequestKeyPattern.test(token) || legacyMachineKeyPattern.test(token)) {
|
||||
return token;
|
||||
}
|
||||
|
||||
if (registrationKeySuffixPattern.test(token)) {
|
||||
return `hskey-authreq-${token}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -138,8 +138,8 @@ build_wasm() {
|
||||
"$(dirname "$WASM_OUTPUT")/wasm_exec.js"
|
||||
|
||||
# Vendor dependencies and apply the DERP port patch.
|
||||
# Tailscale's derphttp WebSocket URL builder ignores DERPPort,
|
||||
# which breaks WASM connections to non-443 DERP servers.
|
||||
# Tailscale's browser WebSocket and netcheck URL builders ignore
|
||||
# DERPPort, which breaks WASM connections to non-443 DERP servers.
|
||||
echo "==> Vendoring Go dependencies for WASM patch"
|
||||
go mod vendor
|
||||
|
||||
|
||||
@@ -30,6 +30,17 @@ func main() {
|
||||
}
|
||||
|
||||
log.SetDebug(cfg.Debug)
|
||||
|
||||
if cfg.TSAuthKey == "" {
|
||||
log.Info("No TS_AUTHKEY provided; tsnet will use existing state or prompt for interactive login")
|
||||
} else {
|
||||
prefix := cfg.TSAuthKey
|
||||
if len(prefix) > 16 {
|
||||
prefix = prefix[:16]
|
||||
}
|
||||
log.Info("TS_AUTHKEY provided (prefix: %s); connecting with pre-auth key", prefix)
|
||||
}
|
||||
|
||||
agent := tsnet.NewAgent(cfg)
|
||||
defer agent.Shutdown()
|
||||
|
||||
|
||||
@@ -1,18 +1,46 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// hp_healthcheck pings the Headplane healthz endpoint. It's invoked
|
||||
// from the Docker HEALTHCHECK directive inside the same container
|
||||
// as Headplane itself.
|
||||
//
|
||||
// To stay fully zero-config, Headplane writes the exact URL the
|
||||
// healthcheck should hit — scheme, port, and basename included — to
|
||||
// listenFile when it starts accepting connections (see
|
||||
// runtime/http.ts and app/server/main.ts). This binary just reads
|
||||
// that file and GETs the URL verbatim. No env vars, no YAML
|
||||
// parsing, no path-joining, no compile-time knowledge of the
|
||||
// basename.
|
||||
//
|
||||
// If the file is missing (e.g. the server hasn't finished booting
|
||||
// on the very first probe, or this is an old image being run with
|
||||
// a new healthcheck) we fall back to the historical default.
|
||||
const (
|
||||
listenFile = "/tmp/headplane-listen"
|
||||
defaultURL = "http://localhost:3000/admin/healthz"
|
||||
)
|
||||
|
||||
func main() {
|
||||
url := readListenFile()
|
||||
|
||||
client := http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
// Self-signed certs are normal for in-process TLS termination
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := client.Get("http://localhost:3000/admin/healthz")
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Health check failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
@@ -25,5 +53,16 @@ func main() {
|
||||
}
|
||||
|
||||
fmt.Println("Health check passed.")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func readListenFile() string {
|
||||
data, err := os.ReadFile(listenFile)
|
||||
if err != nil {
|
||||
return defaultURL
|
||||
}
|
||||
url := strings.TrimSpace(string(data))
|
||||
if url == "" {
|
||||
return defaultURL
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ services:
|
||||
- "./test/caddy/config:/config"
|
||||
- "./test/caddy/certs:/certs"
|
||||
headscale:
|
||||
image: "headscale/headscale:0.28.0"
|
||||
image: "headscale/headscale:0.29.0"
|
||||
container_name: "headscale"
|
||||
labels:
|
||||
me.tale.headplane.target: headscale
|
||||
|
||||
+66
-14
@@ -17,8 +17,19 @@ server:
|
||||
# Whether cookies should be marked as Secure
|
||||
# * Should be false if running without HTTPs
|
||||
# * Should be true if running behind a reverse proxy with HTTPs
|
||||
# When `tls_cert_path`/`tls_key_path` are set below this is forced
|
||||
# to true automatically.
|
||||
cookie_secure: true
|
||||
|
||||
# Optional TLS termination. When both `tls_cert_path` and
|
||||
# `tls_key_path` are set, Headplane serves HTTPS on `server.port`.
|
||||
# Both files must be PEM encoded.
|
||||
#
|
||||
# If you are terminating TLS at a reverse proxy (recommended for most
|
||||
# deployments) leave these unset.
|
||||
# tls_cert_path: "/var/lib/headplane/tls/fullchain.pem"
|
||||
# tls_key_path: "/var/lib/headplane/tls/privkey.pem"
|
||||
|
||||
# The maximum age of the session cookie in seconds
|
||||
cookie_max_age: 86400 # 1 day in seconds
|
||||
|
||||
@@ -27,6 +38,36 @@ server:
|
||||
# This may not work as expected if not using a reverse proxy.
|
||||
# cookie_domain: ""
|
||||
|
||||
# Optional proxy authentication mode. When enabled, Headplane trusts identity
|
||||
# headers from requests that come directly from one of `allowed_cidrs` and
|
||||
# uses `headscale.api_key` for Headscale API access.
|
||||
#
|
||||
# Only enable this when a trusted reverse proxy in front of Headplane already
|
||||
# performs authentication (for example, nginx basic auth, Authelia, Authentik,
|
||||
# etc.). The source IP check uses the direct client address connected to
|
||||
# Headplane by default, not X-Forwarded-For headers.
|
||||
#
|
||||
# If `allowed_cidrs` is omitted, only localhost is trusted.
|
||||
# proxy_auth:
|
||||
# enabled: false
|
||||
#
|
||||
# # Optional. If set, Headplane checks allowed_cidrs against the first IP in
|
||||
# # this header (for example, "X-Forwarded-For" or "X-Real-IP") instead of
|
||||
# # the direct client address. The direct client must still match
|
||||
# # trusted_proxy_cidrs before this header is used.
|
||||
# ip_header: "X-Forwarded-For"
|
||||
# trusted_proxy_cidrs:
|
||||
# - "127.0.0.1/32"
|
||||
# - "::1/128"
|
||||
#
|
||||
# user_header: "Remote-User"
|
||||
# email_header: "Remote-Email"
|
||||
# name_header: "Remote-Name"
|
||||
# picture_header: "Remote-Picture"
|
||||
# allowed_cidrs:
|
||||
# - "127.0.0.1/32"
|
||||
# - "::1/128"
|
||||
|
||||
# The path to persist Headplane specific data. All data going forward
|
||||
# is stored in this directory, including the internal database and
|
||||
# any cache related files.
|
||||
@@ -58,6 +99,11 @@ headscale:
|
||||
# If you use the TLS configuration in Headscale, and you are not using
|
||||
# Let's Encrypt for your certificate, pass in the path to the certificate.
|
||||
# (This has no effect if `url` does not start with `https://`)
|
||||
#
|
||||
# Note: this pins Headscale's connection to exactly this one cert. If you
|
||||
# have a private CA you want trusted everywhere (Headscale, OIDC, Docker,
|
||||
# etc.), set `NODE_EXTRA_CA_CERTS=/path/to/ca.pem` on the Headplane
|
||||
# process instead — see https://headplane.net/configuration/tls#custom-certificate-authorities
|
||||
# tls_cert_path: "/var/lib/headplane/tls.crt"
|
||||
|
||||
# Optional, public URL if its different from the `headscale.url`
|
||||
@@ -71,25 +117,17 @@ headscale:
|
||||
config_path: "/etc/headscale/config.yaml"
|
||||
|
||||
# The API key used by Headplane for server-side operations.
|
||||
# This is required for OIDC authentication and the Headplane agent.
|
||||
# This is required for OIDC authentication, proxy authentication, and the
|
||||
# Headplane agent.
|
||||
# Generate one with: headscale apikeys create
|
||||
# api_key: "<your-api-key>"
|
||||
|
||||
# Whether the Headscale configuration should be strictly validated
|
||||
# when reading from `config_path`. If true, Headplane will not interact
|
||||
# with Headscale if there are any issues with the configuration file.
|
||||
#
|
||||
# This is recommended to be true for production deployments to, however it
|
||||
# may not work if you are using a version of Headscale that has configuration
|
||||
# options unknown to Headplane.
|
||||
config_strict: true
|
||||
|
||||
# If you are using `dns.extra_records_path` in your Headscale
|
||||
# configuration, you need to set this to the path for Headplane
|
||||
# to be able to read the DNS records.
|
||||
# configuration, Headplane will read that path automatically. Set
|
||||
# this only when Headplane needs to access the same file at a
|
||||
# different path, such as in Docker bind mounts.
|
||||
#
|
||||
# Pass it in if using Docker and ensure that the file is both
|
||||
# readable and writable to the Headplane process.
|
||||
# Ensure that the file is both readable and writable to the Headplane process.
|
||||
# When using this, Headplane will no longer need to automatically
|
||||
# restart Headscale for DNS record changes.
|
||||
# dns_records_path: "/var/lib/headscale/extra_records.json"
|
||||
@@ -243,6 +281,20 @@ integration:
|
||||
# - "open_id"
|
||||
# - "email"
|
||||
#
|
||||
# # Role assigned to new OIDC users after the first owner is bootstrapped.
|
||||
# # This is useful with Headscale's OIDC allowed_domains / allowed_groups
|
||||
# # restrictions when every permitted SSO user should receive the same
|
||||
# # Headplane permissions. Valid values: admin, network_admin, it_admin,
|
||||
# # auditor, viewer, member. The owner role is only granted to the first user.
|
||||
# default_role: "member"
|
||||
#
|
||||
# # Optional OIDC claim to read a Headplane role from when creating or syncing a user.
|
||||
# # This can be a string claim, or an array claim containing one of the valid
|
||||
# # roles above. If present and valid, it takes precedence over default_role.
|
||||
# # Configure your IdP to map groups or client roles into this claim. Existing
|
||||
# # non-owner users are updated on their next OIDC sign-in when this claim changes.
|
||||
# role_claim: "headplane_role"
|
||||
#
|
||||
# # Allow ID token verification with legacy RSA keys smaller than 2048 bits.
|
||||
# # This is disabled by default because it lowers token verification security and
|
||||
# # should only be used as a temporary compatibility workaround.
|
||||
|
||||
@@ -38,6 +38,7 @@ export default defineConfig({
|
||||
link: "/configuration",
|
||||
items: [
|
||||
{ text: "Common Issues", link: "/configuration/common-issues" },
|
||||
{ text: "TLS & Certificates", link: "/configuration/tls" },
|
||||
{
|
||||
text: "Sensitive Values",
|
||||
link: "/configuration#sensitive-values",
|
||||
@@ -49,7 +50,11 @@ export default defineConfig({
|
||||
{
|
||||
text: "Features",
|
||||
items: [
|
||||
{ text: "Single Sign-On (SSO)", link: "/features/sso" },
|
||||
{
|
||||
text: "Single Sign-On (SSO)",
|
||||
link: "/features/sso",
|
||||
items: [{ text: "Proxy Authentication", link: "/features/proxy-auth" }],
|
||||
},
|
||||
{ text: "Headplane Agent", link: "/features/agent" },
|
||||
{ text: "Browser SSH", link: "/features/ssh" },
|
||||
],
|
||||
|
||||
+1
-9
@@ -85,15 +85,7 @@ The full (generated by `mise run nixos-docs`) list of `services.headplane.settin
|
||||
{config, pkgs, ...}:
|
||||
let
|
||||
format = pkgs.formats.yaml {};
|
||||
|
||||
# A workaround generate a valid Headscale config accepted by Headplane when `config_strict == true`.
|
||||
settings = lib.recursiveUpdate config.services.headscale.settings {
|
||||
tls_cert_path = "/dev/null";
|
||||
tls_key_path = "/dev/null";
|
||||
policy.path = "/dev/null";
|
||||
};
|
||||
|
||||
headscaleConfig = format.generate "headscale.yml" settings;
|
||||
headscaleConfig = format.generate "headscale.yml" config.services.headscale.settings;
|
||||
in {
|
||||
services.headplane = {
|
||||
enable = true;
|
||||
|
||||
+101
-4
@@ -73,9 +73,7 @@ _Example:_ `"/etc/headscale/config.yaml"`
|
||||
|
||||
## settings.headscale.config_strict
|
||||
|
||||
_Description:_ Headplane internally validates the Headscale configuration
|
||||
to ensure that it changes the configuration in a safe way.
|
||||
If you want to disable this validation, set this to false.
|
||||
_Description:_ Deprecated. Headplane no longer validates the complete Headscale configuration and this option has no effect.
|
||||
|
||||
_Type:_ boolean
|
||||
|
||||
@@ -83,7 +81,7 @@ _Default:_ `true`
|
||||
|
||||
## settings.headscale.dns_records_path
|
||||
|
||||
_Description:_ If you are using `dns.extra_records_path` in your Headscale configuration, you need to set this to the path for Headplane to be able to read the DNS records.
|
||||
_Description:_ If you are using `dns.extra_records_path` in your Headscale configuration, Headplane reads that path automatically. Set this only when Headplane needs to access the same file at a different path.
|
||||
Ensure that the file is both readable and writable by the Headplane process.
|
||||
When using this, Headplane will no longer need to automatically restart Headscale for DNS record changes.
|
||||
|
||||
@@ -252,6 +250,15 @@ _Type:_ boolean
|
||||
|
||||
_Default:_ `false`
|
||||
|
||||
## settings.oidc.default_role
|
||||
|
||||
_Description:_ Role assigned to newly created OIDC users after the first owner is bootstrapped.
|
||||
The owner role is reserved for the first-login bootstrap.
|
||||
|
||||
_Type:_ one of "admin", "network_admin", "it_admin", "auditor", "viewer", "member"
|
||||
|
||||
_Default:_ `"member"`
|
||||
|
||||
## settings.oidc.headscale_api_key_path
|
||||
|
||||
_Description:_ DEPRECATED: Use `headscale.api_key_path` instead.
|
||||
@@ -284,6 +291,17 @@ _Default:_ `""`
|
||||
|
||||
_Example:_ `"https://headscale.example.com/admin/oidc/callback"`
|
||||
|
||||
## settings.oidc.role_claim
|
||||
|
||||
_Description:_ Optional OIDC claim containing the Headplane role to assign to newly created users.
|
||||
A valid role claim takes precedence over default_role.
|
||||
|
||||
_Type:_ null or string
|
||||
|
||||
_Default:_ `null`
|
||||
|
||||
_Example:_ `"headplane_role"`
|
||||
|
||||
## settings.oidc.token_endpoint_auth_method
|
||||
|
||||
_Description:_ The token endpoint authentication method.
|
||||
@@ -350,3 +368,82 @@ _Description:_ The port to listen on.
|
||||
_Type:_ 16 bit unsigned integer; between 0 and 65535 (both inclusive)
|
||||
|
||||
_Default:_ `3000`
|
||||
|
||||
## settings.server.proxy_auth
|
||||
|
||||
_Description:_ Proxy authentication configuration.
|
||||
|
||||
_Type:_ submodule
|
||||
|
||||
_Default:_ `{ }`
|
||||
|
||||
## settings.server.proxy_auth.allowed_cidrs
|
||||
|
||||
_Description:_ Direct client CIDR ranges allowed to bypass Headplane's login flow.
|
||||
These should be the addresses your trusted reverse proxy uses to connect
|
||||
to Headplane. Requires headscale.api_key_path.
|
||||
|
||||
_Type:_ list of string
|
||||
|
||||
_Default:_ `[ "127.0.0.1/32" "::1/128" ]`
|
||||
|
||||
_Example:_ `[ "10.0.0.0/24" ]`
|
||||
|
||||
## settings.server.proxy_auth.email_header
|
||||
|
||||
_Description:_ Optional header containing the authenticated user's email address.
|
||||
|
||||
_Type:_ null or string
|
||||
|
||||
_Default:_ `null`
|
||||
|
||||
## settings.server.proxy_auth.enabled
|
||||
|
||||
_Description:_ Whether to trust reverse proxy authentication for allowed client CIDRs.
|
||||
|
||||
_Type:_ boolean
|
||||
|
||||
_Default:_ `false`
|
||||
|
||||
## settings.server.proxy_auth.ip_header
|
||||
|
||||
_Description:_ Optional header containing the original client IP, such as X-Forwarded-For or X-Real-IP.
|
||||
|
||||
_Type:_ null or string
|
||||
|
||||
_Default:_ `null`
|
||||
|
||||
## settings.server.proxy_auth.name_header
|
||||
|
||||
_Description:_ Optional header containing the authenticated user's display name.
|
||||
|
||||
_Type:_ null or string
|
||||
|
||||
_Default:_ `null`
|
||||
|
||||
## settings.server.proxy_auth.picture_header
|
||||
|
||||
_Description:_ Optional header containing the authenticated user's profile picture URL.
|
||||
|
||||
_Type:_ null or string
|
||||
|
||||
_Default:_ `null`
|
||||
|
||||
## settings.server.proxy_auth.user_header
|
||||
|
||||
_Description:_ Header containing the stable authenticated proxy user identity.
|
||||
|
||||
_Type:_ string
|
||||
|
||||
_Default:_ `"Remote-User"`
|
||||
|
||||
## settings.server.proxy_auth.trusted_proxy_cidrs
|
||||
|
||||
_Description:_ Direct proxy CIDR ranges trusted to supply ip_header.
|
||||
Only used when ip_header is set.
|
||||
|
||||
_Type:_ list of string
|
||||
|
||||
_Default:_ `[ "127.0.0.1/32" "::1/128" ]`
|
||||
|
||||
_Example:_ `[ "127.0.0.1/32" ]`
|
||||
|
||||
@@ -58,8 +58,8 @@ The following configuration options in Headplane are treated as secret paths:
|
||||
- _Note:_ Either `cookie_secret` or `cookie_secret_path` must be provided for web session security.
|
||||
|
||||
- **Headscale Connection Settings (`headscale.*`):**
|
||||
- `api_key_path` (Headscale API key for server-side operations like OIDC and agent sync)
|
||||
- _Note:_ Either `api_key` or `api_key_path` must be provided when using OIDC or the agent.
|
||||
- `api_key_path` (Headscale API key for server-side operations like OIDC, proxy authentication, and agent sync)
|
||||
- _Note:_ Either `api_key` or `api_key_path` must be provided when using OIDC, proxy authentication, or the agent.
|
||||
- `tls_cert_path` (custom TLS certificate for connecting to Headscale)
|
||||
- _Note:_ This is treated as a regular path, not a secret path, so it will not have its content loaded.
|
||||
|
||||
@@ -82,10 +82,21 @@ The path-based secret loading mechanism also works with environment variables. F
|
||||
|
||||
## Debugging
|
||||
|
||||
Headplane writes server logs as newline-delimited JSON using Pino. Each entry
|
||||
includes a timestamp, level, component, and message (`msg`) so log aggregation
|
||||
tools can filter and index it directly.
|
||||
|
||||
To enable debug logging, set the **`HEADPLANE_DEBUG_LOG=true`** environment variable.
|
||||
This will enable all debug logs for Headplane, which could fill up log space very quickly.
|
||||
This is not recommended in production environments.
|
||||
|
||||
## TLS & Certificates
|
||||
|
||||
Anything to do with TLS — terminating HTTPS in-process, trusting a private
|
||||
CA for outbound connections to Headscale or your OIDC provider, the
|
||||
interaction with `cookie_secure` and the Docker healthcheck — lives on its
|
||||
own page: [TLS & Certificates](./tls).
|
||||
|
||||
## Reverse Proxying
|
||||
|
||||
Reverse proxying is very common when deploying web applications. Headscale and
|
||||
|
||||
@@ -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.
|
||||
@@ -171,7 +171,7 @@ export interface AppRuntime {
|
||||
db: DbClient;
|
||||
auth: AuthService;
|
||||
oidc?: OidcService;
|
||||
hsApi: HeadscaleInterface;
|
||||
headscale: Headscale;
|
||||
agents?: AgentManager;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
@@ -300,7 +300,7 @@ function createTestRuntime(overrides: Partial<AppRuntime> = {}): AppRuntime {
|
||||
config: testConfig,
|
||||
db: createTestDb(),
|
||||
auth: createTestAuth(),
|
||||
hsApi: createTestHsApi(),
|
||||
headscale: createTestHeadscale(),
|
||||
stop: async () => {},
|
||||
...overrides,
|
||||
};
|
||||
|
||||
+20
-4
@@ -7,8 +7,9 @@ description: Configure the Headplane Agent for enhanced functionality.
|
||||
|
||||
The Headplane Agent is an optional component that periodically syncs node
|
||||
information (such as version and OS details) from the Tailnet. Unlike previous
|
||||
versions, the agent no longer runs as a persistent Tailnet node — it
|
||||
auto-generates pre-auth keys and performs periodic syncs instead.
|
||||
versions, the agent does not require you to manually create or manage pre-auth
|
||||
keys — Headplane generates a fresh key for each agent startup and reuses the
|
||||
agent's existing Tailnet state across restarts.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -18,8 +19,9 @@ Before enabling the agent, ensure the following:
|
||||
keys which are only available in Headscale 0.28+.
|
||||
|
||||
2. **`headscale.api_key`** must be set in your Headplane configuration file.
|
||||
The agent uses this key to auto-generate ephemeral pre-auth keys for
|
||||
connecting to the Tailnet.
|
||||
The agent uses this key to auto-generate pre-auth keys for connecting to the
|
||||
Tailnet and to auto-approve its own registration when Headscale is configured
|
||||
to require manual approval.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -55,6 +57,20 @@ default. You can change this location by defining
|
||||
that the specified directory exists and is writable by the user running
|
||||
Headplane.
|
||||
|
||||
Headplane preserves the agent's `tailscaled.state` in this directory. This lets
|
||||
the agent retain its Tailnet identity across Headplane restarts instead of
|
||||
registering as a new host each time. If the agent's state is lost or unusable,
|
||||
Headplane falls back to the pre-auth key and registers a new agent node.
|
||||
|
||||
## Interactive approval
|
||||
|
||||
Under normal circumstances, the agent connects headlessly using the auto-generated
|
||||
pre-auth key and no manual interaction is required. If your Headscale server is
|
||||
configured to require interactive approval, Headplane detects the auth URL the
|
||||
agent prints and automatically approves the request using the configured
|
||||
`headscale.api_key`. The Settings page still shows the approval link as a
|
||||
fallback in case auto-approval fails.
|
||||
|
||||
## Usage
|
||||
|
||||
<figure>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user