Compare commits

...

37 Commits

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

Closes HP-558
2026-07-13 11:00:43 -04:00
Sandro 3d9b9b0f7e Fix typo url in code to docs (#586) 2026-07-12 18:20:07 -04:00
Sandro f52c4161cc Fix wasm ssh in nix build (#588) 2026-07-12 18:18:48 -04:00
Tommy Nevtelen fb73181cba fix: treat Go pseudo-versions as unknown server versions (#590)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 18:18:21 -04:00
Aarnav Tale 17e7b35478 chore: v0.7.0 2026-07-04 11:10:57 -04:00
Aarnav Tale 51a4613295 chore: fix unit tests 2026-07-04 00:55:42 -04:00
Aarnav Tale 1345b7ca13 fix(ui): fix assigning ACL tags to non-user nodes 2026-07-04 00:50:57 -04:00
Aarnav Tale 948cfad58c fix: handle registration keys on headscale 0.29+
Closes HP-555.
2026-07-04 00:07:53 -04:00
Aarnav Tale 990e1e9ff5 chore(ssh): warn about broken SSH on certain versions 2026-07-01 11:23:35 -04:00
Aarnav Tale 18263e4878 fix(ui): show display names in lists
Closes HP-549.
2026-06-22 20:23:41 -04:00
Aarnav Tale 12cee9763e fix(ui): stop split DNS from crashing the page when undefined
Closes HP-548.
2026-06-22 20:23:40 -04:00
github-actions[bot] 4d0c0cacca chore: update nix pnpm deps hash 2026-06-22 20:44:09 +00:00
Aarnav Tale 83671ff3b0 chore: add audit fix 2026-06-22 16:42:10 -04:00
Aarnav Tale 476a0419f1 chore: update dev stuff 2026-06-22 16:40:29 -04:00
Aarnav Tale 0439175e73 feat(auth): support role sync on re-login 2026-06-22 16:40:29 -04:00
Aarnav Tale a3cd8444a3 chore(auth): add more informative logs 2026-06-22 16:40:29 -04:00
Aarnav Tale 10055541cc feat(config): simplify required headscale config 2026-06-22 16:40:29 -04:00
Aarnav Tale dc32427cff feat(config): use minimal headscale config 2026-06-22 16:40:28 -04:00
github-actions[bot] 5aebc6d932 chore: update nix pnpm deps hash 2026-06-20 22:14:47 +00:00
Aarnav Tale e29221e5f7 feat: add support for headscale 0.29+ 2026-06-20 18:03:14 -04:00
Aarnav Tale 846c030bc1 chore: update to react router v8 2026-06-20 13:33:24 -04:00
Aarnav Tale 5d6eef5843 feat: update to the v8 middleware api 2026-06-20 12:38:09 -04:00
Aarnav Tale 3f9dcd5eb4 feat: update build for react router v8 2026-06-20 12:09:42 -04:00
Aarnav Tale 0c4d175eb7 feat(auth): support deriving roles from the IDP
Closes HP-352.
2026-06-20 11:53:09 -04:00
Aarnav Tale 96f2721272 feat(auth): support reverse-proxy driven proxy auth
Closes HP-353.
2026-06-20 11:41:44 -04:00
eccgecko c7822e3ec2 fix(wasm): make whole vendored tailscale tree writable before patching (#567) 2026-06-18 10:53:21 -04:00
github-actions[bot] 4179e3e604 chore: update nix pnpm deps hash 2026-06-17 20:06:57 +00:00
Aarnav Tale f00d8ab87e chore: changelog 2026-06-17 16:05:21 -04:00
Aarnav Tale 3252482e0b feat: switch logging to pino
Closes HP-279
2026-06-17 15:58:48 -04:00
Aarnav Tale 57c8046f99 feat(ui): add support for showing existing tag options 2026-06-17 11:35:38 -04:00
Aarnav Tale c8b1a30f34 fix(wasm): account for custom DERP ports when checking the probe
Closes HP-540
2026-06-17 11:30:34 -04:00
Aarnav Tale 8017436bb6 fix(ui): increase ssh pre-auth key expiry time and present errors
Closes HP-546
2026-06-17 11:27:07 -04:00
Aarnav Tale dea19f9330 fix(ui): validate machine names before rename submission
Closes HP-545
2026-06-17 11:15:30 -04:00
Aarnav Tale 21806caa05 fix(oidc): correctly handle client_secret_basic fallback 2026-06-17 11:13:46 -04:00
Aarnav Tale 2f3a440de5 fix: auto read dns.extra_records_path instead of making it required
Closes HP-538
2026-06-17 11:11:20 -04:00
Aarnav Tale e74e0d4542 fix: don't require postgres pass when password_file is supplied
Closes HP-528
2026-06-17 11:09:12 -04:00
Aarnav Tale db39b5f2bd chore: add pullfrog.yml workflow 2026-06-17 10:26:20 -04:00
96 changed files with 5122 additions and 2864 deletions
+57
View File
@@ -0,0 +1,57 @@
# PULLFROG ACTION — DO NOT EDIT EXCEPT WHERE INDICATED
name: Pullfrog
run-name: ${{ inputs.name || github.workflow }}
on:
workflow_dispatch:
inputs:
prompt:
type: string
description: Agent prompt
name:
type: string
description: Run name
permissions:
contents: read
jobs:
pullfrog:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Run agent
uses: pullfrog/pullfrog@v0
with:
prompt: ${{ inputs.prompt }}
env:
# add at least one provider API key
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
GOOGLE_GENERATIVE_AI_API_KEY:
${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
# for Amazon Bedrock (https://docs.pullfrog.com/bedrock)
# AWS_BEARER_TOKEN_BEDROCK: ${{ secrets.AWS_BEARER_TOKEN_BEDROCK }}
# AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
# AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
# AWS_REGION: us-east-1
# BEDROCK_MODEL_ID: <bedrock-model-id>
# for Google Vertex AI (https://docs.pullfrog.com/vertex)
# VERTEX_SERVICE_ACCOUNT_JSON: ${{ secrets.VERTEX_SERVICE_ACCOUNT_JSON }}
# GOOGLE_CLOUD_PROJECT: my-project
# VERTEX_LOCATION: global
# VERTEX_MODEL_ID: <vertex-model-id>
+23
View File
@@ -1,3 +1,26 @@
# 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.
+5 -3
View File
@@ -4,16 +4,18 @@ import { createReadableStreamFromReadable } from "@react-router/node";
import { isbot } from "isbot";
import type { RenderToPipeableStreamOptions } from "react-dom/server";
import { renderToPipeableStream } from "react-dom/server";
import type { AppLoadContext, EntryContext } from "react-router";
import type { EntryContext, RouterContextProvider } from "react-router";
import { ServerRouter } from "react-router";
import log from "~/utils/log";
export const streamTimeout = 5_000;
export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
_loadContext: AppLoadContext,
_loadContext: RouterContextProvider,
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
@@ -52,7 +54,7 @@ export default function handleRequest(
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
log.error("server", "Streaming render error: %o", error);
}
},
},
+45 -29
View File
@@ -2,8 +2,17 @@ import { Outlet, redirect, type ShouldRevalidateFunction } from "react-router";
import { ErrorBanner } from "~/components/error-banner";
import StatusBanner from "~/components/status-banner";
import {
appConfigContext,
authContext,
headscaleConfigContext,
headscaleContext,
headscaleLiveStoreContext,
requestApiContext,
} from "~/server/context";
import { isDataUnauthorizedError } from "~/server/headscale/api/error-client";
import { usersResource } from "~/server/headscale/live-store";
import { isUserPrincipal } from "~/server/web/auth";
import { Capabilities } from "~/server/web/roles";
import log from "~/utils/log";
@@ -30,33 +39,40 @@ export const shouldRevalidate: ShouldRevalidateFunction = ({
};
export async function loader({ request, context }: Route.LoaderArgs) {
try {
const { principal, api } = await context.apiForRequest(request);
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);
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" };
try {
const { principal, api } = await getRequestApi(request);
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 context.headscale.health();
const isHealthy = await headscale.health();
if (isHealthy) {
try {
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),
},
});
}
@@ -64,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
@@ -78,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),
},
});
}
+7 -3
View File
@@ -1,5 +1,6 @@
import { data } from "react-router";
import { authContext, requestApiContext } from "~/server/context";
import { isDataWithApiError } from "~/server/headscale/api/error-client";
import { Capabilities } from "~/server/web/roles";
@@ -9,8 +10,11 @@ import type { Route } from "./+types/overview";
// If it isn't, it'll gracefully error anyways, since this means some
// fishy client manipulation is happening.
export async function aclAction({ request, context }: Route.ActionArgs) {
const principal = await context.auth.require(request);
const check = context.auth.can(principal, Capabilities.write_policy);
const auth = context.get(authContext);
const getRequestApi = context.get(requestApiContext);
const principal = await auth.require(request);
const check = auth.can(principal, Capabilities.write_policy);
if (!check) {
throw data("You do not have permission to write to the ACL policy", {
status: 403,
@@ -26,7 +30,7 @@ export async function aclAction({ request, context }: Route.ActionArgs) {
});
}
const { api } = await context.apiForRequest(request);
const { api } = await getRequestApi(request);
try {
const { policy, updatedAt } = await api.policy.set(policyData);
return data({
+9 -5
View File
@@ -1,5 +1,6 @@
import { data } from "react-router";
import { authContext, requestApiContext } from "~/server/context";
import { isDataWithApiError } from "~/server/headscale/api/error-client";
import { Capabilities } from "~/server/web/roles";
@@ -11,10 +12,13 @@ import type { Route } from "./+types/overview";
// 2. Does the user have permission to write to the policy?
// 3. Is the Headscale policy in file or database mode?
// If database, we can read/write easily via the API.
// If in file mode, we can only write if context.config is available.
// If in file mode, we can only write if the Headscale config is available.
export async function aclLoader({ request, context }: Route.LoaderArgs) {
const principal = await context.auth.require(request);
const check = context.auth.can(principal, Capabilities.read_policy);
const auth = context.get(authContext);
const getRequestApi = context.get(requestApiContext);
const principal = await auth.require(request);
const check = auth.can(principal, Capabilities.read_policy);
if (!check) {
throw data("You do not have permission to read the ACL policy.", {
status: 403,
@@ -23,13 +27,13 @@ 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 { api } = await context.apiForRequest(request);
const { api } = await getRequestApi(request);
try {
const { policy, updatedAt } = await api.policy.get();
flags.writable = updatedAt !== null;
+6 -2
View File
@@ -1,11 +1,15 @@
import { redirect } from "react-router";
import { authContext, headscaleContext } from "~/server/context";
import { isDataWithApiError } from "~/server/headscale/api/error-client";
import log from "~/utils/log";
import type { Route } from "./+types/page";
export async function loginAction({ request, context }: Route.LoaderArgs) {
const auth = context.get(authContext);
const headscale = context.get(headscaleContext);
const formData = await request.formData();
const apiKey = formData.has("api_key") ? String(formData.get("api_key")) : undefined;
@@ -35,7 +39,7 @@ export async function loginAction({ request, context }: Route.LoaderArgs) {
// Build a client with the candidate API key the user just submitted, so the
// GET /api/v1/apikey call below validates the key against Headscale itself.
const api = context.headscale.client(apiKey);
const api = headscale.client(apiKey);
try {
const apiKeys = await api.apiKeys.list();
@@ -70,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(),
+34 -12
View File
@@ -7,7 +7,10 @@ import Card from "~/components/card";
import Code from "~/components/code";
import Input from "~/components/input";
import Link from "~/components/link";
import { appConfigContext, authContext, oidcContext } from "~/server/context";
import type { OidcError, OidcService } from "~/server/oidc/provider";
import { useLiveData } from "~/utils/live-data";
import log from "~/utils/log";
import type { Route } from "./+types/page";
import { loginAction } from "./action";
@@ -15,26 +18,38 @@ 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.state === "enabled" ? context.oidc.value : undefined;
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);
}
oidcStatus = oidcService.status();
}
if (
oidcService &&
context.config.oidc?.disable_api_key_login &&
config.oidc?.disable_api_key_login &&
oidcStatus?.state === "ready" &&
urlState !== "logout"
) {
@@ -45,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,
@@ -54,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;
+12 -12
View File
@@ -1,34 +1,34 @@
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.state === "enabled" &&
context.config.oidc?.use_end_session
) {
const service = context.oidc.value;
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
@@ -44,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),
},
});
}
+43 -20
View File
@@ -1,23 +1,38 @@
import { data, redirect } from "react-router";
import {
appConfigContext,
authContext,
headscaleApiKeyContext,
headscaleContext,
oidcContext,
} from "~/server/context";
import { logOidcError } from "~/server/oidc/provider";
import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity";
import { Roles } from "~/server/web/roles";
import log from "~/utils/log";
import { createOidcStateCookie } from "~/utils/oidc-state";
import type { Route } from "./+types/oidc-callback";
export async function loader({ request, context }: Route.LoaderArgs) {
if (context.oidc.state !== "enabled") {
throw data(`OIDC is unavailable: ${context.oidc.reason}`, { status: 501 });
}
const service = context.oidc.value;
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,30 +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 {
// Looks up the Headscale user that matches this OIDC identity. We use
// the configured admin API key here — not a per-request one — because
// there is no per-request key yet (the session is being created).
const hsApi = context.headscale.client(context.headscaleApiKey!);
const 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));
@@ -71,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.config.oidc?.use_end_session ? identity.idToken : undefined;
const idToken = config.oidc?.use_end_session ? identity.idToken : undefined;
return redirect("/", {
headers: {
"Set-Cookie": await context.auth.createOidcSession(
"Set-Cookie": await auth.createOidcSession(
userId,
{
name: identity.name,
+12 -5
View File
@@ -1,27 +1,34 @@
import { data, redirect } from "react-router";
import { appConfigContext, authContext, oidcContext } from "~/server/context";
import { logOidcError } from "~/server/oidc/provider";
import { createOidcStateCookie } from "~/utils/oidc-state";
import type { Route } from "./+types/oidc-start";
export async function loader({ request, context }: Route.LoaderArgs) {
const auth = context.get(authContext);
const config = context.get(appConfigContext);
const oidc = context.get(oidcContext);
try {
await context.auth.require(request);
await auth.require(request);
return redirect("/");
} catch {}
if (context.oidc.state !== "enabled") {
throw data(`OIDC is unavailable: ${context.oidc.reason}`, { status: 501 });
if (oidc.state !== "enabled") {
throw data(`OIDC is unavailable: ${oidc.reason}`, { status: 501 });
}
const service = context.oidc.value;
const service = oidc.value;
const result = await service.startFlow();
if (!result.ok) {
logOidcError("OIDC start failed", result.error);
return redirect(`/login?s=${result.error.code}`);
}
const { url, flowState } = result.value;
const cookie = createOidcStateCookie(context.config);
const cookie = createOidcStateCookie(config);
return redirect(url, {
status: 302,
+45 -37
View File
@@ -1,18 +1,29 @@
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);
}
@@ -29,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(context.headscale);
await integration?.onConfigChange(headscale);
return { message: "Tailnet renamed successfully" };
}
case "toggle_magic": {
@@ -45,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(context.headscale);
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();
@@ -65,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,
@@ -85,11 +96,11 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
]);
}
await context.integration?.onConfigChange(context.headscale);
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();
@@ -98,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,
@@ -120,45 +129,44 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
]);
}
await context.integration?.onConfigChange(context.headscale);
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(context.headscale);
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(context.headscale);
await integration?.onConfigChange(headscale);
return { message: "Domain added successfully" };
}
case "remove_record": {
@@ -170,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: "",
@@ -180,7 +188,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
return;
}
await context.integration?.onConfigChange(context.headscale);
await integration?.onConfigChange(headscale);
return { message: "DNS record removed successfully" };
}
case "add_record": {
@@ -192,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,
@@ -202,7 +210,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
return;
}
await context.integration?.onConfigChange(context.headscale);
await integration?.onConfigChange(headscale);
return { message: "DNS record added successfully" };
}
case "override_dns": {
@@ -212,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(context.headscale);
await integration?.onConfigChange(headscale);
return { message: "DNS override updated successfully" };
}
default:
+13 -19
View File
@@ -1,12 +1,13 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router";
import type { ActionFunctionArgs } from "react-router";
import { useLoaderData } from "react-router";
import Code from "~/components/code";
import Notice from "~/components/notice";
import PageError from "~/components/page-error";
import type { AppContext } from "~/server/context";
import { authContext, headscaleConfigContext } from "~/server/context";
import { Capabilities } from "~/server/web/roles";
import type { Route } from "./+types/overview";
import ManageDomains from "./components/manage-domains";
import ManageNS from "./components/manage-ns";
import ManageRecords from "./components/manage-records";
@@ -15,13 +16,16 @@ import ToggleMagic from "./components/toggle-magic";
import { dnsAction } from "./dns-actions";
// We do not want to expose every config value
export async function loader({ request, context }: LoaderFunctionArgs<AppContext>) {
if (!context.hs.readable()) {
export async function loader({ request, context }: Route.LoaderArgs) {
const auth = context.get(authContext);
const headscaleConfig = context.get(headscaleConfigContext);
if (!headscaleConfig.readable()) {
throw new Error("No configuration is available");
}
const principal = await context.auth.require(request);
const check = context.auth.can(principal, Capabilities.read_network);
const principal = await auth.require(request);
const check = auth.can(principal, Capabilities.read_network);
if (!check) {
// Not authorized to view this page
throw new Error(
@@ -29,24 +33,14 @@ export async function loader({ request, context }: LoaderFunctionArgs<AppContext
);
}
const writablePermission = context.auth.can(principal, Capabilities.write_network);
const writablePermission = auth.can(principal, Capabilities.write_network);
const config = context.hs.c!;
const dns = {
prefixes: config.prefixes,
magicDns: config.dns.magic_dns,
baseDomain: config.dns.base_domain,
nameservers: config.dns.nameservers.global,
splitDns: config.dns.nameservers.split,
searchDomains: config.dns.search_domains,
overrideDns: config.dns.override_local_dns,
extraRecords: context.hs.d,
};
const dns = headscaleConfig.getDNSConfig();
return {
...dns,
access: writablePermission,
writable: context.hs.writable(),
writable: headscaleConfig.writable(),
};
}
+29 -19
View File
@@ -10,7 +10,14 @@ import Card from "~/components/card";
import CodeBlock from "~/components/code-block";
import Link from "~/components/link";
import LinkAccount from "~/layout/link-account";
import {
authContext,
headscaleConfigContext,
headscaleLiveStoreContext,
requestApiContext,
} from "~/server/context";
import { usersResource } from "~/server/headscale/live-store";
import { isUserPrincipal } from "~/server/web/auth";
import { Capabilities } from "~/server/web/roles";
import cn from "~/utils/cn";
import { getUserDisplayName } from "~/utils/user";
@@ -18,23 +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 (
context.oidc.state === "enabled" &&
principal.kind === "oidc" &&
!principal.user.headscaleUserId
) {
const { api } = await context.apiForRequest(request);
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;
@@ -53,22 +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 { api } = await context.apiForRequest(request);
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 {
@@ -80,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("/");
}
@@ -89,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("/");
+14 -3
View File
@@ -12,10 +12,11 @@ import Text from "~/components/text";
import Title from "~/components/title";
import { useForm } from "~/hooks/use-form";
import type { User } from "~/types";
import { normalizeRegistrationKey } from "~/utils/register-key";
import { getUserDisplayName } from "~/utils/user";
const registerSchema = type({
register_key: "string == 24",
register_key: "string > 0",
user: "string > 0",
});
@@ -28,7 +29,16 @@ export interface NewMachineProps {
export default function NewMachine(data: NewMachineProps) {
const [pushDialog, setPushDialog] = useState(false);
const form = useForm({ schema: registerSchema });
const form = useForm({
schema: registerSchema,
validate: (values) =>
normalizeRegistrationKey(String(values.register_key ?? ""))
? undefined
: {
register_key:
"Paste the registration URL or full hskey-authreq-... key from tailscale up.",
},
});
const navigate = useNavigate();
return (
@@ -43,7 +53,8 @@ export default function NewMachine(data: NewMachineProps) {
{...form.field("register_key")}
required
label="Machine Key"
placeholder="AbCd..."
placeholder="hskey-authreq-XXXXXXXXXXXXXXXXXXXXXXXX"
description="Paste the registration URL or full key shown by tailscale up."
/>
<Select
required
+13 -1
View File
@@ -12,6 +12,17 @@ const renameSchema = type({
name: "string > 0",
});
const dnsLabelPattern = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
function validateMachineName(values: Record<string, unknown>) {
const name = String(values.name ?? "").toLowerCase();
if (!dnsLabelPattern.test(name)) {
return {
name: "Use a valid DNS label: lowercase letters, numbers, and hyphens only. It must start and end with a letter or number.",
};
}
}
interface RenameProps {
machine: Machine;
isOpen: boolean;
@@ -23,12 +34,13 @@ export default function Rename({ machine, magic, isOpen, setIsOpen }: RenameProp
const form = useForm({
schema: renameSchema,
defaultValues: { name: machine.givenName },
validate: validateMachineName,
});
const name = form.values.name as string;
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<DialogPanel>
<DialogPanel isDisabled={!form.canSubmit}>
<Title>Edit machine name for {machine.givenName}</Title>
<Text className="mb-6">
This name is shown in the admin panel, in Tailscale clients, and used when generating
+21
View File
@@ -24,6 +24,10 @@ export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsP
const submittingRef = useRef(false);
const [tags, setTags] = useState([...machine.tags]);
const [tag, setTag] = useState("tag:");
const tagOptions = useMemo(
() => (existingTags ?? []).filter((existingTag) => !tags.includes(existingTag)),
[existingTags, tags],
);
const tagIsInvalid = useMemo(
() => tag.length === 0 || !tag.startsWith("tag:") || tags.includes(tag),
[tag, tags],
@@ -98,6 +102,7 @@ export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsP
onClick={() => {
setTags(tags.filter((tag) => tag !== item));
}}
type="button"
>
<X className="p-1" />
</Button>
@@ -124,10 +129,26 @@ export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsP
setTags([...tags, tag]);
setTag("tag:");
}}
type="button"
>
<Plus className="p-1" size={30} />
</Button>
</div>
{tagOptions.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{tagOptions.map((option) => (
<Button
className="px-2 py-1 font-mono text-xs"
key={option}
onClick={() => setTags([...tags, option])}
type="button"
variant="ghost"
>
{option}
</Button>
))}
</div>
) : null}
<p className="mt-2 text-sm opacity-50">
Not seeing the tags you expect? Tags need to be defined in your access control policy
before they can be assigned to machines.
+44 -12
View File
@@ -1,13 +1,19 @@
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, api } = await context.apiForRequest(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();
@@ -20,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,7 +54,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
}
const node = await api.nodes.register(user, registrationKey);
await context.hsLive.refresh(nodesResource, api);
await headscaleLiveStore.refresh(nodesResource, api);
return redirect(`/machines/${node.id}`);
}
@@ -60,7 +73,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
});
}
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,
});
@@ -76,20 +89,27 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
}
const name = String(formData.get("name"));
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 context.hsLive.refresh(nodesResource, api);
await headscaleLiveStore.refresh(nodesResource, api);
return { message: "Machine renamed" };
}
case "delete": {
await api.nodes.delete(nodeId);
await context.hsLive.refresh(nodesResource, api);
await headscaleLiveStore.refresh(nodesResource, api);
return redirect("/machines");
}
case "expire": {
await api.nodes.expire(nodeId);
await context.hsLive.refresh(nodesResource, api);
await headscaleLiveStore.refresh(nodesResource, api);
return { message: "Machine expired" };
}
@@ -107,7 +127,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
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) {
@@ -115,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,7 +193,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
}
await api.nodes.approveRoutes(nodeId, newApproved);
await context.hsLive.refresh(nodesResource, api);
await headscaleLiveStore.refresh(nodesResource, api);
return { message: "Routes updated" };
}
@@ -190,7 +211,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
});
}
await api.nodes.reassignUser(nodeId, user);
await context.hsLive.refresh(nodesResource, api);
await headscaleLiveStore.refresh(nodesResource, api);
return { message: "Machine reassigned" };
}
@@ -200,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;
}
+29 -16
View File
@@ -9,10 +9,17 @@ import Chip from "~/components/chip";
import Link from "~/components/link";
import StatusCircle from "~/components/status-circle";
import Tooltip from "~/components/tooltip";
import {
agentsContext,
headscaleConfigContext,
headscaleContext,
headscaleLiveStoreContext,
requestApiContext,
} from "~/server/context";
import { nodesResource, usersResource } from "~/server/headscale/live-store";
import cn from "~/utils/cn";
import { getOSInfo, getTSVersion } from "~/utils/host-info";
import { isNoExpiry, mapNodes, sortNodeTags } from "~/utils/node-info";
import { isNoExpiry, mapNodes, sortAssignableTags } from "~/utils/node-info";
import { getUserDisplayName } from "~/utils/user";
import type { Route } from "./+types/machine";
@@ -22,6 +29,12 @@ import Routes from "./dialogs/routes";
import { machineAction } from "./machine-actions";
export async function loader({ request, params, context }: Route.LoaderArgs) {
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");
}
@@ -30,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 } = await context.apiForRequest(request);
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;
@@ -49,12 +57,17 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
throw data(null, { status: 404 });
}
const agents = context.agents.state === "enabled" ? context.agents.value : undefined;
const lookup = await 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.headscale.capabilities.nodeOwnerIsImmutable;
const supportsNodeOwnerChange = !headscale.capabilities.nodeOwnerIsImmutable;
const agentSync = agents?.lastSync();
const policy = policyResult.status === "fulfilled" ? policyResult.value.policy : undefined;
return {
agent: agentSync
@@ -64,10 +77,10 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
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,
+40 -21
View File
@@ -7,10 +7,20 @@ import Input from "~/components/input";
import Link from "~/components/link";
import PageError from "~/components/page-error";
import Tooltip from "~/components/tooltip";
import {
agentsContext,
appConfigContext,
authContext,
headscaleConfigContext,
headscaleContext,
headscaleLiveStoreContext,
requestApiContext,
} from "~/server/context";
import { nodesResource, usersResource } from "~/server/headscale/live-store";
import { isUserPrincipal } from "~/server/web/auth";
import { Capabilities } from "~/server/web/roles";
import cn from "~/utils/cn";
import { mapNodes, sortNodeTags, type PopulatedNode } from "~/utils/node-info";
import { mapNodes, sortAssignableTags, type PopulatedNode } from "~/utils/node-info";
import type { Route } from "./+types/overview";
import { MachineFilters } from "./components/machine-filters";
@@ -20,35 +30,43 @@ 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 } = await context.apiForRequest(request);
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 agents = context.agents.state === "enabled" ? context.agents.value : undefined;
const stats = await 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.headscale.capabilities.nodeOwnerIsImmutable;
const supportsNodeOwnerChange = !headscale.capabilities.nodeOwnerIsImmutable;
const agentSync = agents?.lastSync();
return {
@@ -59,13 +77,14 @@ export async function loader({ request, context }: Route.LoaderArgs) {
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,
@@ -423,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
+39 -13
View File
@@ -6,36 +6,48 @@ import Notice from "~/components/notice";
import StatusCircle from "~/components/status-circle";
import Text from "~/components/text";
import Title from "~/components/title";
import { agentsContext, authContext } from "~/server/context";
import { formatTimeDelta } from "~/utils/time";
import type { Route } from "./+types/agent";
export async function loader({ request, context }: Route.LoaderArgs) {
await context.auth.require(request);
const agents = context.get(agentsContext);
const auth = context.get(authContext);
if (context.agents.state !== "enabled") {
return { enabled: false as const, reason: context.agents.reason };
await auth.require(request);
if (agents.state !== "enabled") {
return { enabled: false as const, reason: agents.reason };
}
const sync = context.agents.value.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.state !== "enabled") {
return { success: false, error: context.agents.reason };
await auth.require(request);
if (agents.state !== "enabled") {
return { success: false, error: agents.reason };
}
await context.agents.value.triggerSync();
const sync = context.agents.value.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) {
@@ -48,7 +60,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
<Title>Headplane Agent</Title>
<Notice title="Agent Not Enabled">
{loaderData.reason}. To learn how to set up the agent, visit the{" "}
<Link external styled to="https://headplane.dev/docs/agent">
<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}
+12 -4
View File
@@ -1,5 +1,7 @@
import { data } from "react-router";
import { authContext, requestApiContext } from "~/server/context";
import { isUserPrincipal } from "~/server/web/auth";
import { getOidcSubject } from "~/server/web/headscale-identity";
import { Capabilities } from "~/server/web/roles";
import type { PreAuthKey } from "~/types";
@@ -7,10 +9,13 @@ import type { PreAuthKey } from "~/types";
import type { Route } from "./+types/overview";
export async function authKeysAction({ request, context }: Route.ActionArgs) {
const { principal, api } = await context.apiForRequest(request);
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", {
@@ -25,7 +30,10 @@ export async function authKeysAction({ request, context }: Route.ActionArgs) {
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,
});
@@ -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("");
+30 -7
View File
@@ -6,7 +6,14 @@ import Link from "~/components/link";
import Notice from "~/components/notice";
import Select from "~/components/select";
import TableList from "~/components/table-list";
import {
appConfigContext,
authContext,
headscaleLiveStoreContext,
requestApiContext,
} from "~/server/context";
import { usersResource } from "~/server/headscale/live-store";
import { isUserPrincipal } from "~/server/web/auth";
import { Capabilities } from "~/server/web/roles";
import type { PreAuthKey } from "~/types";
import type { User } from "~/types/User";
@@ -19,9 +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, api } = await context.apiForRequest(request);
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[] }[];
@@ -85,16 +97,17 @@ export async function loader({ request, context }: Route.LoaderArgs) {
.map(({ user, error }) => ({ error, user }));
}
const canGenerateAny = context.auth.can(principal, Capabilities.generate_authkeys);
const canGenerateOwn = context.auth.can(principal, Capabilities.generate_own_authkeys);
const canGenerateAny = auth.can(principal, Capabilities.generate_authkeys);
const canGenerateOwn = auth.can(principal, Capabilities.generate_own_authkeys);
return {
access: canGenerateAny || canGenerateOwn,
currentSubject: principal.kind === "oidc" ? principal.user.subject : undefined,
currentHeadscaleUserId: isUserPrincipal(principal) ? principal.user.headscaleUserId : undefined,
currentSubject: isUserPrincipal(principal) ? principal.user.subject : undefined,
keys,
missing,
selfServiceOnly: !canGenerateAny && canGenerateOwn,
url: context.config.headscale.public_url ?? context.config.headscale.url,
url: config.headscale.public_url ?? config.headscale.url,
users,
};
}
@@ -103,7 +116,16 @@ export const action = authKeysAction;
type Status = "all" | "active" | "expired" | "reusable" | "ephemeral";
export default function Page({
loaderData: { keys, missing, users, url, access, selfServiceOnly, currentSubject },
loaderData: {
keys,
missing,
users,
url,
access,
selfServiceOnly,
currentHeadscaleUserId,
currentSubject,
},
}: Route.ComponentProps) {
const [selectedUser, setSelectedUser] = useState("__headplane_all");
const [status, setStatus] = useState<Status>("active");
@@ -199,6 +221,7 @@ export default function Page({
</Link>
</p>
<AddAuthKey
currentHeadscaleUserId={currentHeadscaleUserId}
currentSubject={currentSubject}
selfServiceOnly={selfServiceOnly}
url={url}
+6 -3
View File
@@ -2,14 +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.state === "enabled" && context.oidc.value.status().state === "ready",
config: headscaleConfig.writable(),
isOidcEnabled: oidc.state === "enabled" && oidc.value.status().state === "ready",
};
}
+36 -21
View File
@@ -1,12 +1,23 @@
import { data } from "react-router";
import {
authContext,
headscaleConfigContext,
headscaleContext,
integrationContext,
} from "~/server/context";
import { Capabilities } from "~/server/web/roles";
import type { Route } from "./+types/overview";
export async function restrictionAction({ request, context }: Route.ActionArgs) {
const principal = await context.auth.require(request);
const check = context.auth.can(principal, Capabilities.configure_iam);
const auth = context.get(authContext);
const headscale = context.get(headscaleContext);
const headscaleConfig = context.get(headscaleConfigContext);
const integration = context.get(integrationContext);
const principal = await auth.require(request);
const check = auth.can(principal, Capabilities.configure_iam);
if (!check) {
throw data("You do not have permission to modify IAM settings.", {
@@ -14,7 +25,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
});
}
if (!context.hs.writable()) {
if (!headscaleConfig.writable()) {
throw data("The Headscale configuration file is not editable.", {
status: 403,
});
@@ -37,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(context.headscale);
integration?.onConfigChange(headscale);
return data("Domain added successfully.");
}
@@ -58,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.`, {
@@ -68,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(context.headscale);
integration?.onConfigChange(headscale);
return data("Domain removed successfully.");
}
@@ -86,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(context.headscale);
integration?.onConfigChange(headscale);
return data("Group added successfully.");
}
@@ -107,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.`, {
@@ -117,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(context.headscale);
integration?.onConfigChange(headscale);
return data("Group removed successfully.");
}
@@ -136,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(context.headscale);
integration?.onConfigChange(headscale);
return data("User added successfully.");
}
@@ -157,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.`, {
@@ -167,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(context.headscale);
integration?.onConfigChange(headscale);
return data("User removed successfully.");
}
+13 -8
View File
@@ -2,6 +2,7 @@ import { data } from "react-router";
import Link from "~/components/link";
import Notice from "~/components/notice";
import { authContext, headscaleConfigContext } from "~/server/context";
import { Capabilities } from "~/server/web/roles";
import type { Route } from "./+types/overview";
@@ -12,28 +13,32 @@ import AddUser from "./dialogs/add-user";
import RestrictionTable from "./table";
export async function loader({ request, context }: Route.LoaderArgs) {
const principal = await context.auth.require(request);
const check = context.auth.can(principal, Capabilities.read_users);
const auth = context.get(authContext);
const headscaleConfig = context.get(headscaleConfigContext);
const principal = await auth.require(request);
const check = auth.can(principal, Capabilities.read_users);
if (!check) {
throw data("You do not have permission to view IAM settings.", {
status: 403,
});
}
if (!context.hs.c?.oidc) {
const oidc = headscaleConfig.getOIDCConfig();
if (!oidc) {
throw data("OIDC is not configured on this Headscale instance.", {
status: 501,
});
}
return {
access: context.auth.can(principal, Capabilities.configure_iam),
access: auth.can(principal, Capabilities.configure_iam),
settings: {
domains: [...new Set(context.hs.c.oidc.allowed_domains)],
groups: [...new Set(context.hs.c.oidc.allowed_groups)],
users: [...new Set(context.hs.c.oidc.allowed_users)],
domains: [...new Set(oidc.allowedDomains)],
groups: [...new Set(oidc.allowedGroups)],
users: [...new Set(oidc.allowedUsers)],
},
writable: context.hs.writable(),
writable: headscaleConfig.writable(),
};
}
+104 -28
View File
@@ -5,6 +5,13 @@ import { data, isRouteErrorResponse, type ShouldRevalidateFunction } from "react
import Button from "~/components/button";
import Card from "~/components/card";
import Code from "~/components/code";
import StatusBanner from "~/components/status-banner";
import {
agentsContext,
appConfigContext,
headscaleContext,
requestApiContext,
} from "~/server/context";
import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity";
import type { Route } from "./+types/page";
@@ -16,13 +23,20 @@ import { loadHeadplaneWASM } from "./wasm.client";
const WASM_MODULE_URL = `${__PREFIX__}/hp_ssh.wasm`;
const WASM_HELPER_URL = `${__PREFIX__}/wasm_exec.js`;
const SSH_PREAUTH_KEY_TTL_MS = 10 * 60 * 1000;
export const shouldRevalidate: ShouldRevalidateFunction = () => {
return false;
};
export async function loader({ request, params, context }: Route.LoaderArgs) {
const origin = new URL(request.url).origin;
export async function loader({ request, params, context, url }: Route.LoaderArgs) {
const agents = context.get(agentsContext);
const config = context.get(appConfigContext);
const headscale = context.get(headscaleContext);
const getRequestApi = context.get(requestApiContext);
const compatibilityWarning = getBrowserSSHCompatibilityWarning(headscale.version);
const origin = url.origin;
const assets = [WASM_HELPER_URL, WASM_MODULE_URL];
const missing: string[] = [];
@@ -37,17 +51,17 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
throw data(sshErrors.wasm_missing, 405);
}
if (context.agents.state !== "enabled") {
if (agents.state !== "enabled") {
throw data(sshErrors.agent_required, 400);
}
const { principal, api } = await context.apiForRequest(request);
const { principal, api } = await getRequestApi(request);
if (principal.kind === "api_key") {
throw data(sshErrors.oidc_required, 403);
}
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.nodes.list();
const node = nodes.find((n) => n.givenName === hostname);
@@ -56,16 +70,24 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
}
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.users.list();
const hsUser = findHeadscaleUserBySubject(users, principal.user.subject, principal.profile.email);
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);
@@ -75,11 +97,11 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
user: hsUser.id,
ephemeral: true,
reusable: false,
expiration: new Date(Date.now() + 60 * 1000), // 1 minute expiry
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,
@@ -90,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}`;
@@ -109,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({
@@ -173,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>
@@ -17,22 +17,22 @@ export default function HeadscaleUserRow({ user, writable }: HeadscaleUserRowPro
(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>
+26 -17
View File
@@ -1,7 +1,15 @@
import { createHash } from "node:crypto";
import PageError from "~/components/page-error";
import {
appConfigContext,
authContext,
headscaleConfigContext,
headscaleLiveStoreContext,
requestApiContext,
} from "~/server/context";
import { nodesResource, usersResource } from "~/server/headscale/live-store";
import { isUserPrincipal } from "~/server/web/auth";
import { Capabilities, Roles } from "~/server/web/roles";
import type { Role } from "~/server/web/roles";
import type { Machine, User } from "~/types";
@@ -35,18 +43,24 @@ export interface UnlinkedHeadscaleUser extends User {
}
export async function loader({ request, context }: Route.LoaderArgs) {
const principal = await context.auth.require(request);
const check = await context.auth.can(principal, Capabilities.read_users);
const auth = context.get(authContext);
const config = context.get(appConfigContext);
const getRequestApi = context.get(requestApiContext);
const headscaleConfig = context.get(headscaleConfigContext);
const headscaleLiveStore = context.get(headscaleLiveStoreContext);
const principal = await auth.require(request);
const check = await auth.can(principal, Capabilities.read_users);
if (!check) {
throw new Error(
"You do not have permission to view this page. Please contact your administrator.",
);
}
const writablePermission = await context.auth.can(principal, Capabilities.write_users);
const writablePermission = await auth.can(principal, Capabilities.write_users);
// Primary data: Headplane users from the database (always available)
const hpUsers = await context.auth.listUsers();
const hpUsers = await auth.listUsers();
// Secondary data: Headscale API (may fail)
let apiUsers: User[] = [];
@@ -54,10 +68,10 @@ export async function loader({ request, context }: Route.LoaderArgs) {
let apiError: string | undefined;
try {
const { api } = await context.apiForRequest(request);
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;
@@ -67,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;
@@ -124,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,
+16 -10
View File
@@ -1,14 +1,20 @@
import { data } from "react-router";
import { 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 { 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,
@@ -23,7 +29,7 @@ export async function userAction({ request, context }: Route.ActionArgs) {
});
}
const { api } = await context.apiForRequest(request);
const { api } = await getRequestApi(request);
switch (action) {
case "create_user": {
const name = formData.get("username")?.toString();
@@ -37,7 +43,7 @@ export async function userAction({ request, context }: Route.ActionArgs) {
}
await api.users.create({ name, email, displayName });
await context.hsLive.refresh(usersResource, api);
await headscaleLiveStore.refresh(usersResource, api);
return { message: "User created successfully" };
}
case "delete_user": {
@@ -49,7 +55,7 @@ export async function userAction({ request, context }: Route.ActionArgs) {
}
await api.users.delete(headscaleUserId);
await context.hsLive.refresh(usersResource, api);
await headscaleLiveStore.refresh(usersResource, api);
return { message: "User deleted successfully" };
}
case "rename_user": {
@@ -73,7 +79,7 @@ export async function userAction({ request, context }: Route.ActionArgs) {
}
await api.users.rename(headscaleUserId, newName);
await context.hsLive.refresh(usersResource, api);
await headscaleLiveStore.refresh(usersResource, api);
return { message: "User renamed successfully" };
}
case "reassign_user": {
@@ -85,7 +91,7 @@ export async function userAction({ request, context }: Route.ActionArgs) {
});
}
const result = await context.auth.reassignUser(headplaneUserId, newRole as Role);
const result = await auth.reassignUser(headplaneUserId, newRole as Role);
if (!result) {
throw data("Failed to reassign user role.", { status: 500 });
}
@@ -93,7 +99,7 @@ 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 });
}
@@ -102,7 +108,7 @@ export async function userAction({ request, context }: Route.ActionArgs) {
throw data("Missing `headplane_user_id` in the form data.", { status: 400 });
}
const result = await context.auth.transferOwnership(principal.user.id, headplaneUserId);
const result = await auth.transferOwnership(principal.user.id, headplaneUserId);
if (!result) {
throw data("Failed to transfer ownership.", { status: 500 });
}
@@ -118,7 +124,7 @@ export async function userAction({ request, context }: Route.ActionArgs) {
});
}
const linked = await context.auth.linkHeadscaleUser(headplaneUserId, headscaleUserId);
const linked = await auth.linkHeadscaleUser(headplaneUserId, headscaleUserId);
if (!linked) {
throw data("That Headscale user is already linked to another account.", { status: 409 });
}
+5 -1
View File
@@ -1,7 +1,11 @@
import { headscaleContext } from "~/server/context";
import type { Route } from "./+types/healthz";
export async function loader({ context }: Route.LoaderArgs) {
const healthy = await context.headscale.health();
const headscale = context.get(headscaleContext);
const healthy = await headscale.health();
return new Response(JSON.stringify({ status: healthy ? "OK" : "ERROR" }), {
status: healthy ? 200 : 500,
+9 -4
View File
@@ -2,10 +2,15 @@ import { versions } from "node:process";
import { data } from "react-router";
import { appConfigContext, headscaleContext } from "~/server/context";
import type { Route } from "./+types/info";
export async function loader({ request, context }: Route.LoaderArgs) {
if (context.config.server.info_secret == null) {
const config = context.get(appConfigContext);
const headscale = context.get(headscaleContext);
if (config.server.info_secret == null) {
throw data(
{
status: "Forbidden",
@@ -25,7 +30,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
}
const token = bearer.slice("Bearer ".length).trim();
if (token !== context.config.server.info_secret) {
if (token !== config.server.info_secret) {
throw data(
{
status: "Forbidden",
@@ -34,12 +39,12 @@ export async function loader({ request, context }: Route.LoaderArgs) {
);
}
const healthy = await context.headscale.health();
const healthy = await headscale.health();
const body = {
status: healthy ? "healthy" : "unhealthy",
headplane_version: __VERSION__,
headscale_canonical_version: healthy ? context.headscale.version.raw : "unknown",
headscale_canonical_version: healthy ? headscale.version.raw : "unknown",
internal_versions: {
node: versions.node,
v8: versions.v8,
+9 -5
View File
@@ -1,15 +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 { api } = await context.apiForRequest(request);
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({
@@ -25,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 });
});
+16 -13
View File
@@ -9,7 +9,7 @@ runs only on the Node process — never in the browser.
app/server/
├── app.ts ← The Headplane application (load context, RR listener)
├── main.ts ← Production bootstrap (binds an http(s) server)
├── context.ts ← createAppContext() — assembles the AppLoadContext
├── context.ts ← createAppContext() — assembles the RouterContextProvider data
├── result.ts ← Result<T, E> helper used across the server modules
├── config/ ← YAML config loading, schema, env-overrides, integrations
@@ -26,9 +26,10 @@ There are two SSR entries; both are picked up by Vite via `vite.config.ts`.
### `app.ts` — the application module
Loads config → builds the `AppLoadContext` (via [`context.ts`](./context.ts))
exports the React Router `RequestListener` as `default`, plus the
resolved `config` as a named export.
Loads config → builds the application context (via [`context.ts`](./context.ts))
seeds React Router's `RouterContextProvider` with the named service contexts
→ exports the React Router `RequestListener` as `default`, plus the resolved
`config` as a named export.
This module has no opinions about how the server is hosted. It does not
listen on a socket, doesn't compose static-asset serving, and doesn't
@@ -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.
+38 -2
View File
@@ -12,6 +12,7 @@
import { exit, versions } from "node:process";
import { createRequestListener } from "@react-router/node";
import { RouterContextProvider } from "react-router";
import * as build from "virtual:react-router/server-build";
import log from "~/utils/log";
@@ -19,7 +20,20 @@ import log from "~/utils/log";
import type { HeadplaneConfig } from "./config/config-schema";
import { ConfigError } from "./config/error";
import { loadConfig } from "./config/load";
import { createAppContext } from "./context";
import {
agentsContext,
appConfigContext,
authContext,
createAppContext,
dbContext,
headscaleApiKeyContext,
headscaleConfigContext,
headscaleContext,
headscaleLiveStoreContext,
integrationContext,
oidcContext,
requestApiContext,
} from "./context";
log.info("server", "Running Node.js %s", versions.node);
@@ -60,8 +74,30 @@ export async function dispose(): Promise<void> {
// 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,
});
+28
View File
@@ -48,6 +48,17 @@ const serverConfig = type({
// 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({
@@ -64,6 +75,17 @@ const partialServerConfig = type({
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({
@@ -92,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",
@@ -125,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>?",
@@ -152,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"?',
+26 -5
View File
@@ -1,5 +1,7 @@
import { join } from "node:path";
import { createContext } from "react-router";
import log from "~/utils/log";
import type { HeadplaneConfig } from "./config/config-schema";
@@ -14,10 +16,17 @@ 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"));
@@ -40,6 +49,18 @@ export async function createAppContext(config: HeadplaneConfig) {
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,7 +75,6 @@ export async function createAppContext(config: HeadplaneConfig) {
const hsLive = createLiveStore([nodesResource, usersResource]);
const hs = await loadHeadscaleConfig(
config.headscale.config_path,
config.headscale.config_strict,
config.headscale.dns_records_path,
);
const integration = await loadIntegration(config.integration);
@@ -139,6 +159,7 @@ function buildOidc(
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,
+1 -1
View File
@@ -32,7 +32,7 @@ export type HeadplaneUserInsert = typeof users.$inferInsert;
export const authSessions = sqliteTable("auth_sessions", {
id: text("id").primaryKey(),
kind: text("kind").notNull(), // 'oidc' | 'api_key'
kind: text("kind").notNull(), // 'oidc' | 'api_key' (proxy auth is request-scoped)
user_id: text("user_id"),
api_key_hash: text("api_key_hash"),
api_key_display: text("api_key_display"),
+9
View File
@@ -36,6 +36,14 @@ export interface Capabilities {
* 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 {
@@ -43,5 +51,6 @@ export function capabilitiesFor(version: ServerVersion): Capabilities {
preAuthKeysHaveStableIds: gte(version, "0.28.0"),
nodeTagsAreFlat: gte(version, "0.28.0"),
nodeOwnerIsImmutable: gte(version, "0.28.0"),
registerKeyIncludesAuthReqPrefix: gte(version, "0.29.0"),
};
}
+3
View File
@@ -22,6 +22,7 @@ import log from "~/utils/log";
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";
@@ -48,6 +49,7 @@ export interface HeadscaleClient {
policy: PolicyApi;
preAuthKeys: PreAuthKeyApi;
apiKeys: ApiKeyApi;
auth: AuthApi;
}
export interface CreateHeadscaleOptions {
@@ -149,6 +151,7 @@ export async function createHeadscale(opts: CreateHeadscaleOptions): Promise<Hea
policy: makePolicyApi(transport, capabilities, apiKey),
preAuthKeys: makePreAuthKeyApi(transport, capabilities, apiKey),
apiKeys: makeApiKeyApi(transport, capabilities, apiKey),
auth: makeAuthApi(transport, capabilities, apiKey),
};
},
async dispose() {
@@ -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 },
});
},
};
}
+7 -2
View File
@@ -62,14 +62,19 @@ export function makeNodeApi(
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", key);
qp.append("key", registerKey);
const { node } = await transport.request<{ node: RawMachine }>({
method: "POST",
path: `v1/node/register?${qp.toString()}`,
apiKey,
body: { user, key },
body: { user, key: registerKey },
});
return normalize(node);
},
@@ -15,6 +15,13 @@
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;
@@ -47,6 +54,26 @@ export function parseServerVersion(raw: string): ServerVersion {
};
}
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),
+1 -1
View File
@@ -99,7 +99,7 @@ async function validateConfigPath(path: string) {
try {
await access(path, constants.F_OK | constants.W_OK);
return { w: true, r: true };
} catch (error) {
} catch {
log.warn("config", "Headscale DNS file at %s is not writable", path);
return { w: false, r: true };
}
+324 -266
View File
@@ -1,258 +1,381 @@
import { constants, access, readFile, writeFile } from "node:fs/promises";
import { exit } from "node:process";
import { setTimeout } from "node:timers/promises";
import { type } from "arktype";
import * as v from "valibot";
import { Document, parseDocument } from "yaml";
import log from "~/utils/log";
import { DNSRecord, HeadscaleDNSConfig, loadHeadscaleDNS } from "./config-dns";
import { headscaleConfig } from "./config-schema";
interface PatchConfig {
path: string;
value: unknown;
}
// We need a class for the config because we need to be able to
// support retrieving it via a getter but also be able to
// patch it and to query it for its mode
class HeadscaleConfig {
private config?: typeof headscaleConfig.infer;
private document?: Document;
private access: "rw" | "ro" | "no";
private path?: string;
private writeLock = false;
private dns?: HeadscaleDNSConfig;
interface DNSConfigView {
magicDns: boolean;
baseDomain: string;
nameservers: string[];
splitDns: Record<string, string[]>;
searchDomains: string[];
overrideDns: boolean;
extraRecords: DNSRecord[];
}
constructor(
access: "rw" | "ro" | "no",
dns?: HeadscaleDNSConfig,
config?: typeof headscaleConfig.infer,
document?: Document,
path?: string,
) {
this.access = access;
this.config = config;
this.document = document;
this.path = path;
this.dns = dns;
interface OIDCConfigView {
issuer: string;
allowedDomains: string[];
allowedGroups: string[];
allowedUsers: string[];
}
interface ParsedDNSConfig {
magic_dns: boolean;
base_domain: string;
nameservers: {
global: string[];
split: Record<string, string[]>;
};
search_domains: string[];
override_local_dns: boolean;
extra_records: DNSRecord[];
extra_records_path?: string;
}
const DNS_CONFIG_DEFAULTS: ParsedDNSConfig = {
magic_dns: true,
base_domain: "",
nameservers: {
global: [],
split: {},
},
search_domains: [],
override_local_dns: true,
extra_records: [],
};
const stringSchema = v.string();
const stringArraySchema = v.array(v.string());
const stringArrayRecordSchema = v.record(v.string(), stringArraySchema);
const goBooleanSchema = v.pipe(
v.union([v.boolean(), v.picklist(["true", "false"])]),
v.transform((value) => value === true || value === "true"),
);
const dnsRecordsSchema = v.array(
v.object({
name: v.string(),
type: v.string(),
value: v.string(),
}),
);
const nameserversSchema = v.object({
global: v.optional(v.fallback(stringArraySchema, []), []),
split: v.optional(v.fallback(stringArrayRecordSchema, {}), {}),
});
const dnsConfigSchema = v.object({
magic_dns: v.optional(
v.fallback(goBooleanSchema, DNS_CONFIG_DEFAULTS.magic_dns),
DNS_CONFIG_DEFAULTS.magic_dns,
),
base_domain: v.optional(
v.fallback(stringSchema, DNS_CONFIG_DEFAULTS.base_domain),
DNS_CONFIG_DEFAULTS.base_domain,
),
nameservers: v.optional(
v.fallback(nameserversSchema, DNS_CONFIG_DEFAULTS.nameservers),
DNS_CONFIG_DEFAULTS.nameservers,
),
search_domains: v.optional(
v.fallback(stringArraySchema, DNS_CONFIG_DEFAULTS.search_domains),
DNS_CONFIG_DEFAULTS.search_domains,
),
override_local_dns: v.optional(
v.fallback(goBooleanSchema, DNS_CONFIG_DEFAULTS.override_local_dns),
DNS_CONFIG_DEFAULTS.override_local_dns,
),
extra_records: v.optional(
v.fallback(dnsRecordsSchema, DNS_CONFIG_DEFAULTS.extra_records),
DNS_CONFIG_DEFAULTS.extra_records,
),
extra_records_path: v.optional(v.string()),
});
const headscaleConfigSchema = v.fallback(
v.object({
dns: v.optional(v.fallback(dnsConfigSchema, DNS_CONFIG_DEFAULTS), DNS_CONFIG_DEFAULTS),
}),
{ dns: DNS_CONFIG_DEFAULTS },
);
const oidcConfigSchema = v.object({
issuer: v.string(),
allowed_domains: v.optional(v.fallback(stringArraySchema, []), []),
allowed_groups: v.optional(v.fallback(stringArraySchema, []), []),
allowed_users: v.optional(v.fallback(stringArraySchema, []), []),
});
const rawOIDCConfigSchema = v.fallback(
v.object({
oidc: v.optional(v.unknown()),
}),
{},
);
const extraRecordsConflictSchema = v.object({
dns: v.optional(
v.object({
extra_records: v.optional(v.array(v.unknown())),
extra_records_path: v.optional(v.string()),
}),
),
});
interface HeadscaleConfigState {
document?: Document;
config: unknown;
access: "rw" | "ro" | "no";
path?: string;
writeQueue: Promise<void>;
dns?: HeadscaleDNSConfig;
}
interface HeadscaleConfig {
readable: () => boolean;
writable: () => boolean;
getDNSConfig: () => DNSConfigView;
getMagicDNSBaseDomain: () => string | undefined;
getOIDCConfig: () => OIDCConfigView | undefined;
hasOIDCConfig: () => boolean;
dnsRecords: () => DNSRecord[];
patch: (patches: PatchConfig[]) => Promise<void>;
addDNS: (record: DNSRecord) => Promise<boolean | void>;
removeDNS: (record: DNSRecord) => Promise<boolean | void>;
}
function createHeadscaleConfig(
access: "rw" | "ro" | "no",
dns?: HeadscaleDNSConfig,
document?: Document,
path?: string,
): HeadscaleConfig {
const state: HeadscaleConfigState = {
access,
config: document?.toJSON() ?? {},
document,
path,
writeQueue: Promise.resolve(),
dns,
};
return {
readable: () => readable(state),
writable: () => writable(state),
getDNSConfig: () => getDNSConfig(state),
getMagicDNSBaseDomain: () => getMagicDNSBaseDomain(state),
getOIDCConfig: () => getOIDCConfig(state),
hasOIDCConfig: () => hasOIDCConfig(state),
dnsRecords: () => dnsRecords(state),
patch: (patches) => patchHeadscaleConfig(state, patches),
addDNS: (record) => addDNS(state, record),
removeDNS: (record) => removeDNS(state, record),
};
}
function readable(config: HeadscaleConfigState) {
return config.access !== "no";
}
function writable(config: HeadscaleConfigState) {
return config.access === "rw";
}
function getDNSConfig(config: HeadscaleConfigState): DNSConfigView {
const dns = v.parse(headscaleConfigSchema, config.config).dns;
return {
magicDns: dns.magic_dns,
baseDomain: dns.base_domain,
nameservers: dns.nameservers.global,
splitDns: dns.nameservers.split ?? {},
searchDomains: dns.search_domains,
overrideDns: dns.override_local_dns,
extraRecords: dnsRecords(config),
};
}
function getMagicDNSBaseDomain(config: HeadscaleConfigState) {
if (!readable(config)) return;
const dns = getDNSConfig(config);
return dns.magicDns && dns.baseDomain ? dns.baseDomain : undefined;
}
function getOIDCConfig(config: HeadscaleConfigState): OIDCConfigView | undefined {
const oidc = v.safeParse(oidcConfigSchema, v.parse(rawOIDCConfigSchema, config.config).oidc);
if (!oidc.success) return;
return {
issuer: oidc.output.issuer,
allowedDomains: oidc.output.allowed_domains,
allowedGroups: oidc.output.allowed_groups,
allowedUsers: oidc.output.allowed_users,
};
}
function hasOIDCConfig(config: HeadscaleConfigState) {
return getOIDCConfig(config) !== undefined;
}
function dnsRecords(config: HeadscaleConfigState) {
if (config.dns) {
return config.dns.r;
}
readable() {
return this.access !== "no";
}
return v.parse(headscaleConfigSchema, config.config).dns.extra_records;
}
writable() {
return this.access === "rw";
}
get c() {
return this.config;
}
get d() {
if (this.dns) {
return this.dns.r;
}
return this.config?.dns.extra_records ?? [];
}
async patch(patches: PatchConfig[]) {
if (!this.path || !this.document || !this.readable() || !this.writable()) {
return;
}
log.debug("config", "Patching Headscale configuration");
for (const patch of patches) {
const { path, value } = patch;
log.debug("config", "Patching %s with %o", path, value);
// If the key is something like `test.bar."foo.bar"`, then we treat
// the foo.bar as a single key, and not as two keys, so that needs
// to be split correctly.
// Iterate through each character, and if we find a dot, we check if
// the next character is a quote, and if it is, we skip until the next
// quote, and then we skip the next character, which should be a dot.
// If it's not a quote, we split it.
const key = [];
let current = "";
let quote = false;
for (const char of path) {
if (char === '"') {
quote = !quote;
}
if (char === "." && !quote) {
key.push(current);
current = "";
continue;
}
current += char;
}
key.push(current.replaceAll('"', ""));
if (value === null) {
this.document.deleteIn(key);
continue;
}
this.document.setIn(key, value);
}
// Revalidate our configuration and update the config
// object with the new configuration
log.info("config", "Revalidating Headscale configuration");
const config = validateConfig(this.document.toJSON());
if (!config) {
return;
}
log.debug("config", "Writing updated Headscale configuration to %s", this.path);
// We need to lock the writeLock so that we don't try to write
// to the file while we're already writing to it
while (this.writeLock) {
await setTimeout(100);
}
this.writeLock = true;
await writeFile(this.path, this.document.toString(), "utf8");
this.config = config;
this.writeLock = false;
async function patchHeadscaleConfig(config: HeadscaleConfigState, patches: PatchConfig[]) {
if (!config.path || !config.document || !readable(config) || !writable(config)) {
return;
}
/**
* Adds a DNS record to the Headscale configuration.
* Differentiates between the file mode and config mode automatically.
* @param record The DNS record to add.
* @returns True if we need to restart the integration.
*/
async addDNS(record: DNSRecord) {
if (this.dns) {
if (!this.dns.readable() || !this.dns.writable()) {
log.debug("config", "DNS config is not writable");
return;
}
const write = config.writeQueue.then(() => writePatches(config, patches));
config.writeQueue = write.catch(() => undefined);
await write;
}
const records = this.dns.r;
if (records.some((i) => i.name === record.name && i.type === record.type)) {
log.debug("config", "DNS record already exists");
return;
}
async function writePatches(config: HeadscaleConfigState, patches: PatchConfig[]) {
if (!config.path || !config.document) return;
return this.dns.patch([...records, record]);
log.debug("config", "Patching Headscale configuration");
for (const patch of patches) {
const { path, value } = patch;
log.debug("config", "Patching %s with %o", path, value);
const key = splitPatchPath(path);
if (value === null) {
config.document.deleteIn(key);
continue;
}
// If we get here, we need to add to the main config instead of
// a separate file (which requires an integration restart)
const existing = this.config?.dns.extra_records ?? [];
if (existing.some((i) => i.name === record.name && i.type === record.type)) {
config.document.setIn(key, value);
}
log.debug("config", "Writing updated Headscale configuration to %s", config.path);
await writeFile(config.path, config.document.toString(), "utf8");
config.config = config.document.toJSON();
}
function splitPatchPath(path: string) {
const key = [];
let current = "";
let quote = false;
for (const char of path) {
if (char === '"') {
quote = !quote;
continue;
}
if (char === "." && !quote) {
key.push(current);
current = "";
continue;
}
current += char;
}
key.push(current);
return key;
}
async function addDNS(config: HeadscaleConfigState, record: DNSRecord) {
if (config.dns) {
if (!config.dns.readable() || !config.dns.writable()) {
log.debug("config", "DNS config is not writable");
return;
}
const records = config.dns.r;
if (records.some((i) => i.name === record.name && i.type === record.type)) {
log.debug("config", "DNS record already exists");
return;
}
await this.patch([
{
path: "dns.extra_records",
value: Array.from(new Set([...existing, record])),
},
]);
return true;
return config.dns.patch([...records, record]);
}
/**
* Removes a DNS record from the Headscale configuration.
* Differentiates between the file mode and config mode automatically.
* @param records The DNS record to remove.
* @returns True if we need to restart the integration.
*/
async removeDNS(record: DNSRecord) {
// In this case we need to check both the main config and the DNS config
// to see if the record exists, and if it does, we need to remove it
// from both places.
const existing = dnsRecords(config);
if (existing.some((i) => i.name === record.name && i.type === record.type)) {
log.debug("config", "DNS record already exists");
return;
}
if (this.dns) {
if (!this.dns.readable() || !this.dns.writable()) {
log.debug("config", "DNS config is not writable");
return;
}
await patchHeadscaleConfig(config, [
{
path: "dns.extra_records",
value: [...existing, record],
},
]);
const records = this.dns.r.filter((i) => i.name !== record.name || i.type !== record.type);
return true;
}
return this.dns.patch(records);
}
// If we get here, we need to remove from the main config instead of
// a separate file (which requires an integration restart)
const existing = this.config?.dns.extra_records ?? [];
const filtered = existing.filter((i) => i.name !== record.name || i.type !== record.type);
// If the length of the existing records is the same as the filtered
// records, then we don't need to do anything
if (existing.length === filtered.length) {
async function removeDNS(config: HeadscaleConfigState, record: DNSRecord) {
if (config.dns) {
if (!config.dns.readable() || !config.dns.writable()) {
log.debug("config", "DNS config is not writable");
return;
}
await this.patch([
{
path: "dns.extra_records",
value: existing.filter((i) => i.name !== record.name || i.type !== record.type),
},
]);
return true;
const records = config.dns.r.filter((i) => i.name !== record.name || i.type !== record.type);
return config.dns.patch(records);
}
const existing = dnsRecords(config);
const filtered = existing.filter((i) => i.name !== record.name || i.type !== record.type);
if (existing.length === filtered.length) {
return;
}
await patchHeadscaleConfig(config, [
{
path: "dns.extra_records",
value: filtered,
},
]);
return true;
}
export async function loadHeadscaleConfig(path?: string, strict = true, dnsPath?: string) {
export async function loadHeadscaleConfig(path?: string, dnsPath?: string) {
if (!path) {
log.debug("config", "No Headscale configuration file was provided");
return new HeadscaleConfig("no");
return createHeadscaleConfig("no");
}
log.debug("config", "Loading Headscale configuration file: %s", path);
const { r, w } = await validateConfigPath(path);
if (!r) {
return new HeadscaleConfig("no");
return createHeadscaleConfig("no");
}
const document = await loadConfigFile(path);
if (!document) {
return new HeadscaleConfig("no");
return createHeadscaleConfig("no");
}
if (!strict) {
return new HeadscaleConfig(
w ? "rw" : "ro",
new HeadscaleDNSConfig("no"),
augmentUnstrictConfig(document.toJSON()),
document,
path,
const rawConfig = document.toJSON();
const parsedConfig = v.parse(headscaleConfigSchema, rawConfig);
const conflict = v.safeParse(extraRecordsConflictSchema, rawConfig);
const extraRecordsPath = parsedConfig.dns.extra_records_path;
if (conflict.success && conflict.output.dns?.extra_records && extraRecordsPath) {
log.warn(
"config",
"Both dns.extra_records and dns.extra_records_path are set; Headplane will use the JSON records file",
);
}
const config = validateConfig(document.toJSON());
if (!config) {
return new HeadscaleConfig("no");
}
if (config.dns.extra_records && config.dns.extra_records_path) {
log.warn("config", "Both extra_records and extra_records_path are set, Headscale will crash");
log.warn("config", "Please remove one of them from the configuration file");
return new HeadscaleConfig("no");
}
const dns = await loadHeadscaleDNS(dnsPath);
if (dns && !config.dns.extra_records_path) {
const dns = await loadHeadscaleDNS(dnsPath ?? extraRecordsPath);
if (dns && !extraRecordsPath) {
log.error(
"config",
"Using separate DNS config file but dns.extra_records_path is not set in Headscale config",
@@ -263,7 +386,7 @@ export async function loadHeadscaleConfig(path?: string, strict = true, dnsPath?
exit(1);
}
return new HeadscaleConfig(w ? "rw" : "ro", dns, config, document, path);
return createHeadscaleConfig(w ? "rw" : "ro", dns, document, path);
}
async function validateConfigPath(path: string) {
@@ -279,7 +402,7 @@ async function validateConfigPath(path: string) {
try {
await access(path, constants.F_OK | constants.W_OK);
return { w: true, r: true };
} catch (error) {
} catch {
log.warn("config", "Headscale configuration file at %s is not writable", path);
return { w: false, r: true };
}
@@ -306,68 +429,3 @@ async function loadConfigFile(path: string) {
return false;
}
}
export function validateConfig(config: unknown) {
log.debug("config", "Validating Headscale configuration");
const result = headscaleConfig(config);
if (result instanceof type.errors) {
log.error("config", "Error validating Headscale configuration:");
for (const [number, error] of result.entries()) {
log.error("config", ` - (${number}): ${error.toString()}`);
}
return;
}
return result;
}
// If config_strict is false, we set the defaults and disable
// the schema checking for the values that are not present
function augmentUnstrictConfig(loaded: Partial<typeof headscaleConfig.infer>) {
log.debug("config", "Augmenting Headscale configuration in non-strict mode");
const config = {
...loaded,
tls_letsencrypt_cache_dir: loaded.tls_letsencrypt_cache_dir ?? "/var/www/cache",
tls_letsencrypt_challenge_type: loaded.tls_letsencrypt_challenge_type ?? "HTTP-01",
grpc_listen_addr: loaded.grpc_listen_addr ?? ":50443",
grpc_allow_insecure: loaded.grpc_allow_insecure ?? false,
randomize_client_port: loaded.randomize_client_port ?? false,
unix_socket: loaded.unix_socket ?? "/var/run/headscale/headscale.sock",
unix_socket_permission: loaded.unix_socket_permission ?? "0770",
log: loaded.log ?? {
level: "info",
format: "text",
},
logtail: loaded.logtail ?? {
enabled: false,
},
prefixes: loaded.prefixes ?? {
allocation: "sequential",
v4: "",
v6: "",
},
dns: loaded.dns ?? {
nameservers: {
global: [],
split: {},
},
search_domains: [],
extra_records: [],
magic_dns: false,
base_domain: "headscale.net",
},
};
log.warn("config", "Headscale configuration was loaded in non-strict mode");
log.warn("config", "This is very dangerous and comes with a few caveats:");
log.warn("config", " - Headplane could very easily crash");
log.warn("config", " - Headplane could break your Headscale installation");
log.warn("config", " - The UI could throw random errors/show incorrect data");
return config as typeof headscaleConfig.infer;
}
-150
View File
@@ -1,150 +0,0 @@
import { type } from "arktype";
const goBool = type('boolean | "true" | "false"').pipe((v) => {
if (v === "true") return true;
if (v === "false") return false;
return v;
});
const goDuration = type("0 | string").pipe((v) => {
return v.toString();
});
const databaseConfig = type({
type: '"sqlite" | "sqlite3"',
sqlite: {
path: "string",
write_ahead_log: goBool.default(true),
wal_autocheckpoint: "number = 1000",
},
})
.or({
type: '"postgres"',
postgres: {
host: "string",
port: 'number | ""',
name: "string",
user: "string",
pass: "string",
max_open_conns: "number = 10",
max_idle_conns: "number = 10",
conn_max_idle_time_secs: "number = 3600",
ssl: goBool.default(false),
},
})
.merge({
debug: goBool.default(false),
"gorm?": {
prepare_stmt: goBool.default(true),
parameterized_queries: goBool.default(true),
skip_err_record_not_found: goBool.default(true),
slow_threshold: "number = 1000",
},
});
// Not as strict parsing because we just need the values
// to be slightly truthy enough to safely modify them
export type HeadscaleConfig = typeof headscaleConfig.infer;
export const headscaleConfig = type({
server_url: "string",
listen_addr: "string",
"metrics_listen_addr?": "string",
grpc_listen_addr: 'string = ":50433"',
grpc_allow_insecure: goBool.default(false),
noise: {
private_key_path: "string",
},
prefixes: {
v4: "string?",
v6: "string?",
allocation: '"sequential" | "random" = "sequential"',
},
derp: {
server: {
enabled: goBool.default(true),
region_id: "number?",
region_code: "string?",
region_name: "string?",
stun_listen_addr: "string?",
private_key_path: "string?",
ipv4: "string?",
ipv6: "string?",
automatically_add_embedded_derp_region: goBool.default(true),
},
urls: "string[]?",
paths: "string[]?",
auto_update_enabled: goBool.default(true),
update_frequency: goDuration.default("24h"),
},
disable_check_updates: goBool.default(false),
ephemeral_node_inactivity_timeout: goDuration.default("30m"),
database: databaseConfig,
acme_url: 'string = "https://acme-v02.api.letsencrypt.org/directory"',
acme_email: 'string = ""',
tls_letsencrypt_hostname: 'string = ""',
tls_letsencrypt_cache_dir: 'string = "/var/lib/headscale/cache"',
tls_letsencrypt_challenge_type: 'string = "HTTP-01"',
tls_letsencrypt_listen: 'string = ":http"',
"tls_cert_path?": "string",
"tls_key_path?": "string",
log: type({
format: 'string = "text"',
level: 'string = "info"',
}).default(() => ({ format: "text", level: "info" })),
"policy?": {
mode: '"database" | "file" = "file"',
path: "string?",
},
dns: {
magic_dns: goBool.default(true),
base_domain: 'string = "headscale.net"',
override_local_dns: goBool.default(false),
nameservers: type({
global: type("string[]").default(() => []),
split: type("Record<string, string[]>").default(() => ({})),
}).default(() => ({ global: [], split: {} })),
search_domains: type("string[]").default(() => []),
extra_records: type({
name: "string",
value: "string",
type: 'string | "A"',
})
.array()
.optional(),
extra_records_path: "string?",
},
unix_socket: "string?",
unix_socket_permission: 'string = "0770"',
"oidc?": {
only_start_if_oidc_is_available: goBool.default(false),
issuer: "string",
client_id: "string",
client_secret: "string?",
client_secret_path: "string?",
expiry: goDuration.default("180d"),
use_expiry_from_token: goBool.default(false),
scope: type("string[]").default(() => ["openid", "email", "profile"]),
extra_params: "Record<string, string>?",
allowed_domains: "string[]?",
allowed_groups: "string[]?",
allowed_users: "string[]?",
"pkce?": {
enabled: goBool.default(false),
method: 'string = "S256"',
},
map_legacy_users: goBool.default(false),
},
"logtail?": {
enabled: goBool.default(false),
},
randomize_client_port: goBool.default(false),
});
+67 -11
View File
@@ -1,5 +1,5 @@
import { type ChildProcess, spawn } from "node:child_process";
import { access, constants, mkdir, rm, stat } from "node:fs/promises";
import { access, constants, mkdir, stat } from "node:fs/promises";
import { join } from "node:path";
import { createInterface } from "node:readline";
@@ -15,7 +15,12 @@ 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> {
@@ -98,6 +104,7 @@ 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);
@@ -122,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, [], {
@@ -131,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),
);
});
}
}
});
@@ -148,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;
@@ -169,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> {
@@ -214,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;
}
@@ -258,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;
@@ -342,6 +397,7 @@ export async function createAgentManager(
syncedAt: state.syncedAt,
nodeCount: state.nodeCount,
error: state.error,
authUrl: state.authUrl,
};
},
+7
View File
@@ -57,15 +57,22 @@ const listenFile = listenFilePath
}
: undefined;
const runtimeLogger = {
info: (message: string, ...args: unknown[]) => log.info("server", message, ...args),
error: (message: string, ...args: unknown[]) => log.error("server", message, ...args),
};
startHttpServer({
host: config.server.host,
port: config.server.port,
tls,
logger: runtimeLogger,
listenFile,
listener: composeListener({
basename: __PREFIX__,
staticRoot: clientDir,
immutableAssets: true,
logger: runtimeLogger,
requestListener,
}),
onShutdown: dispose,
+44 -4
View File
@@ -23,6 +23,7 @@ export interface OidcConfig {
usePkce?: boolean;
scope?: string;
subjectClaims?: string[];
roleClaim?: string;
allowWeakRsaKeys?: boolean;
extraParams?: Record<string, string>;
profilePictureSource?: "oidc" | "gravatar";
@@ -51,6 +52,7 @@ export interface OidcIdentity {
username: string;
email?: string;
picture?: string;
role?: string;
idToken?: string;
}
@@ -73,6 +75,13 @@ export interface OidcError {
hint?: string;
}
export function logOidcError(context: string, error: OidcError): void {
log.error("auth", "%s [%s]: %s", context, error.code, error.message);
if (error.hint) {
log.error("auth", "Hint: %s", error.hint);
}
}
type JwksResolver = (
protectedHeader?: JWSHeaderParameters,
token?: FlattenedJWSInput,
@@ -106,6 +115,7 @@ interface OidcClaims extends JWTPayload {
preferred_username?: string;
email?: string;
picture?: string;
[claim: string]: unknown;
}
interface TokenResponse {
@@ -451,14 +461,15 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
body: URLSearchParams,
method: "client_secret_basic" | "client_secret_post",
): Promise<Result<TokenResponse, OidcError>> {
const requestBody = new URLSearchParams(body);
const headers: Record<string, string> = {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
};
if (method === "client_secret_post") {
body.set("client_id", config.clientId);
body.set("client_secret", config.clientSecret);
requestBody.set("client_id", config.clientId);
requestBody.set("client_secret", config.clientSecret);
} else {
const credentials = btoa(
`${encodeURIComponent(config.clientId)}:${encodeURIComponent(config.clientSecret)}`,
@@ -472,7 +483,7 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
response = await fetch(tokenEndpoint, {
method: "POST",
headers,
body: body.toString(),
body: requestBody.toString(),
signal: AbortSignal.timeout(10_000),
});
} catch (cause) {
@@ -695,7 +706,11 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
const needsEnrichment =
!claims.name && !claims.email && !claims.picture && !!resolveSubject(claims);
const needsSubjectEnrichment = !resolveSubject(claims);
if ((!needsEnrichment && !needsSubjectEnrichment) || !ep.userinfoEndpoint) {
const needsRoleEnrichment = !!config.roleClaim && claims[config.roleClaim] === undefined;
if (
(!needsEnrichment && !needsSubjectEnrichment && !needsRoleEnrichment) ||
!ep.userinfoEndpoint
) {
return claims;
}
@@ -714,6 +729,9 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
}
const userInfo = (await response.json()) as Record<string, unknown>;
const roleClaimValue = config.roleClaim
? (claims[config.roleClaim] ?? userInfo[config.roleClaim])
: undefined;
const subjectClaimValues = Object.fromEntries(
getSubjectClaimOrder()
.filter((claim) => claim !== "sub")
@@ -725,6 +743,9 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
return {
...claims,
...subjectClaimValues,
...(config.roleClaim && roleClaimValue !== undefined
? { [config.roleClaim]: roleClaimValue }
: {}),
name: claims.name ?? (userInfo.name as string | undefined),
given_name: claims.given_name ?? (userInfo.given_name as string | undefined),
family_name: claims.family_name ?? (userInfo.family_name as string | undefined),
@@ -776,6 +797,7 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
username,
email: claims.email,
picture,
role: config.roleClaim ? resolveRoleClaim(claims, config.roleClaim) : undefined,
idToken,
};
}
@@ -830,6 +852,24 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
return undefined;
}
function resolveRoleClaim(claims: OidcClaims, claimName: string): string | undefined {
const value = claims[claimName];
if (typeof value === "string") {
return value.trim() || undefined;
}
if (Array.isArray(value)) {
const roles = new Set(value.filter((v): v is string => typeof v === "string"));
for (const role of ["admin", "network_admin", "it_admin", "auditor", "viewer", "member"]) {
if (roles.has(role)) {
return role;
}
}
}
return undefined;
}
return {
status,
discover,
+333 -38
View File
@@ -1,4 +1,5 @@
import { createHash, createHmac } from "node:crypto";
import { isIP } from "node:net";
import { eq, lt, sql } from "drizzle-orm";
import { NodeSQLiteDatabase } from "drizzle-orm/node-sqlite";
@@ -17,23 +18,36 @@ export type Principal =
displayName: string;
apiKey: string;
}
| {
kind: "oidc";
sessionId: string;
idToken?: string;
user: {
id: string;
subject: string;
role: Role;
headscaleUserId: string | undefined;
};
profile: {
name: string;
email?: string;
username?: string;
picture?: string;
};
};
| UserPrincipal;
export type UserPrincipal = {
kind: "oidc" | "proxy";
sessionId: string;
idToken?: string;
user: {
id: string;
subject: string;
role: Role;
headscaleUserId: string | undefined;
};
profile: {
name: string;
email?: string;
username?: string;
picture?: string;
};
};
interface ProxyAuthOptions {
enabled: boolean;
allowedCidrs?: string[];
trustedProxyCidrs?: string[];
ipHeader?: string;
userHeader?: string;
emailHeader?: string;
nameHeader?: string;
pictureHeader?: string;
}
interface CookiePayload {
sid: string;
@@ -48,6 +62,7 @@ interface CookiePayload {
export interface AuthServiceOptions {
secret: string;
headscaleApiKey?: string;
proxyAuth?: ProxyAuthOptions;
db: NodeSQLiteDatabase;
cookie: {
name: string;
@@ -58,6 +73,7 @@ export interface AuthServiceOptions {
}
export interface AuthService {
registerRequestClientAddress(request: Request, address: string | undefined): void;
require(request: Request): Promise<Principal>;
can(principal: Principal, capabilities: Capabilities): boolean;
canManageNode(principal: Principal, node: Machine): boolean;
@@ -73,6 +89,7 @@ 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>;
@@ -88,8 +105,152 @@ export interface AuthService {
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> {
@@ -140,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
@@ -175,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> {
@@ -241,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;
@@ -303,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(),
})
@@ -320,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,
@@ -327,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);
@@ -343,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 })
@@ -480,6 +774,7 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
}
return {
registerRequestClientAddress,
require: require,
can,
canManageNode,
+34 -20
View File
@@ -1,44 +1,58 @@
// MARK: Side-Effects
// This module contains a side-effect because everything running here
// is static and logger is later modified in `app/server/index.ts` to
// disable debug logging if the `HEADPLANE_DEBUG_LOG` specifies as such.
// This module contains a side-effect because log levels are read from
// the environment once at module initialization.
import pino from "pino";
const levels = ["info", "warn", "error", "debug"] as const;
type Category = "server" | "config" | "agent" | "api" | "auth" | "sse";
type Level = (typeof levels)[number];
export interface Logger extends Record<
(typeof levels)[number],
Level,
(category: Category, message: string, ...args: unknown[]) => void
> {
debugEnabled: boolean;
}
const logLevels = getLogLevels();
const rootLogger = createRootLogger();
export default {
debugEnabled: logLevels.includes("debug"),
debug: (..._: Parameters<Logger["debug"]>) => {},
...Object.fromEntries(
logLevels.map((level) => [
level,
(category: Category, message: string, ...args: unknown[]) => {
const date = new Date().toISOString();
console.log(`${date} [${category}] ${level.toUpperCase()}: ${message}`, ...args);
},
]),
),
debugEnabled: rootLogger.isLevelEnabled("debug"),
info: (category, msg, ...args) => rootLogger.info({ component: category }, msg, ...args),
warn: (category, msg, ...args) => rootLogger.warn({ component: category }, msg, ...args),
error: (category, msg, ...args) => rootLogger.error({ component: category }, msg, ...args),
debug: (category, msg, ...args) => rootLogger.debug({ component: category }, msg, ...args),
} as Logger;
function getLogLevels() {
function createRootLogger() {
const options = {
level: getLogLevel(),
timestamp: () => `,"timestamp":"${new Date().toISOString()}"`,
formatters: {
level: (label: string) => ({ level: label }),
},
} satisfies pino.LoggerOptions;
if (process.env.NODE_ENV === "test") {
return pino(options);
}
const destination = pino.destination({ dest: 1, sync: false });
process.on("exit", () => destination.flushSync());
return pino(options, destination);
}
function getLogLevel(): Level {
const debugLog = process.env.HEADPLANE_DEBUG_LOG;
if (debugLog == null) {
return ["info", "warn", "error"];
return "info";
}
const normalized = debugLog.trim().toLowerCase();
const truthyValues = ["1", "true", "yes", "on"];
if (!truthyValues.includes(normalized)) {
return ["info", "warn", "error"];
return "info";
}
return ["info", "warn", "error", "debug"];
return "debug";
}
+91
View File
@@ -48,3 +48,94 @@ export function mapNodes(
export function sortNodeTags(nodes: Machine[]): string[] {
return Array.from(new Set(nodes.flatMap((node) => node.tags))).sort();
}
export function sortAssignableTags(nodes: Machine[], policy?: string): string[] {
return Array.from(new Set([...sortNodeTags(nodes), ...extractTagOwnerTags(policy)])).sort();
}
export function extractTagOwnerTags(policy: string | undefined): string[] {
if (!policy) {
return [];
}
try {
const parsed = JSON.parse(stripJsonCommentsAndTrailingCommas(policy)) as unknown;
if (parsed == null || typeof parsed !== "object" || !("tagOwners" in parsed)) {
return [];
}
const tagOwners = (parsed as { tagOwners?: unknown }).tagOwners;
if (tagOwners == null || typeof tagOwners !== "object" || Array.isArray(tagOwners)) {
return [];
}
return Object.keys(tagOwners)
.filter((tag) => tag.startsWith("tag:"))
.sort();
} catch {
return [];
}
}
function stripJsonCommentsAndTrailingCommas(input: string): string {
let output = "";
let inString = false;
let escaped = false;
let inLineComment = false;
let inBlockComment = false;
for (let i = 0; i < input.length; i++) {
const char = input[i];
const next = input[i + 1];
if (inLineComment) {
if (char === "\n" || char === "\r") {
inLineComment = false;
output += char;
}
continue;
}
if (inBlockComment) {
if (char === "*" && next === "/") {
inBlockComment = false;
i++;
}
continue;
}
if (inString) {
output += char;
if (escaped) {
escaped = false;
} else if (char === "\\") {
escaped = true;
} else if (char === '"') {
inString = false;
}
continue;
}
if (char === '"') {
inString = true;
output += char;
continue;
}
if (char === "/" && next === "/") {
inLineComment = true;
i++;
continue;
}
if (char === "/" && next === "*") {
inBlockComment = true;
i++;
continue;
}
output += char;
}
return output.replace(/,\s*([}\]])/g, "$1");
}
+46
View File
@@ -0,0 +1,46 @@
const authRequestKeyPattern = /^hskey-authreq-[A-Za-z0-9_-]{16,}$/;
const authRequestKeySearchPattern = /hskey-authreq-[A-Za-z0-9_-]{16,}/;
const legacyMachineKeyPattern = /^mkey:[A-Za-z0-9_-]{16,}$/;
const registerUrlPattern = /\/register\/([^\s?#]+)/;
const registrationKeySuffixPattern = /^[A-Za-z0-9_-]{24,}$/;
export function normalizeRegistrationKey(value: string): string | null {
const trimmed = value.trim();
if (trimmed.length === 0) {
return null;
}
const registerUrlMatch = trimmed.match(registerUrlPattern);
if (registerUrlMatch) {
const token = normalizeRegistrationToken(registerUrlMatch[1]);
if (token) {
return token;
}
}
const authRequestKeyMatch = trimmed.match(authRequestKeySearchPattern);
if (authRequestKeyMatch) {
return authRequestKeyMatch[0];
}
return normalizeRegistrationToken(trimmed);
}
function normalizeRegistrationToken(value: string): string | null {
let token: string;
try {
token = decodeURIComponent(value.trim());
} catch {
return null;
}
if (authRequestKeyPattern.test(token) || legacyMachineKeyPattern.test(token)) {
return token;
}
if (registrationKeySuffixPattern.test(token)) {
return `hskey-authreq-${token}`;
}
return null;
}
+2 -2
View File
@@ -138,8 +138,8 @@ build_wasm() {
"$(dirname "$WASM_OUTPUT")/wasm_exec.js"
# Vendor dependencies and apply the DERP port patch.
# Tailscale's derphttp WebSocket URL builder ignores DERPPort,
# which breaks WASM connections to non-443 DERP servers.
# Tailscale's browser WebSocket and netcheck URL builders ignore
# DERPPort, which breaks WASM connections to non-443 DERP servers.
echo "==> Vendoring Go dependencies for WASM patch"
go mod vendor
+11
View File
@@ -30,6 +30,17 @@ func main() {
}
log.SetDebug(cfg.Debug)
if cfg.TSAuthKey == "" {
log.Info("No TS_AUTHKEY provided; tsnet will use existing state or prompt for interactive login")
} else {
prefix := cfg.TSAuthKey
if len(prefix) > 16 {
prefix = prefix[:16]
}
log.Info("TS_AUTHKEY provided (prefix: %s); connecting with pre-auth key", prefix)
}
agent := tsnet.NewAgent(cfg)
defer agent.Shutdown()
+1 -1
View File
@@ -24,7 +24,7 @@ services:
- "./test/caddy/config:/config"
- "./test/caddy/certs:/certs"
headscale:
image: "headscale/headscale:0.28.0"
image: "headscale/headscale:0.29.0"
container_name: "headscale"
labels:
me.tale.headplane.target: headscale
+50 -14
View File
@@ -38,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.
@@ -87,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"
@@ -259,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.
+5 -1
View File
@@ -50,7 +50,11 @@ export default defineConfig({
{
text: "Features",
items: [
{ text: "Single Sign-On (SSO)", link: "/features/sso" },
{
text: "Single Sign-On (SSO)",
link: "/features/sso",
items: [{ text: "Proxy Authentication", link: "/features/proxy-auth" }],
},
{ text: "Headplane Agent", link: "/features/agent" },
{ text: "Browser SSH", link: "/features/ssh" },
],
+1 -9
View File
@@ -85,15 +85,7 @@ The full (generated by `mise run nixos-docs`) list of `services.headplane.settin
{config, pkgs, ...}:
let
format = pkgs.formats.yaml {};
# A workaround generate a valid Headscale config accepted by Headplane when `config_strict == true`.
settings = lib.recursiveUpdate config.services.headscale.settings {
tls_cert_path = "/dev/null";
tls_key_path = "/dev/null";
policy.path = "/dev/null";
};
headscaleConfig = format.generate "headscale.yml" settings;
headscaleConfig = format.generate "headscale.yml" config.services.headscale.settings;
in {
services.headplane = {
enable = true;
+101 -4
View File
@@ -73,9 +73,7 @@ _Example:_ `"/etc/headscale/config.yaml"`
## settings.headscale.config_strict
_Description:_ Headplane internally validates the Headscale configuration
to ensure that it changes the configuration in a safe way.
If you want to disable this validation, set this to false.
_Description:_ Deprecated. Headplane no longer validates the complete Headscale configuration and this option has no effect.
_Type:_ boolean
@@ -83,7 +81,7 @@ _Default:_ `true`
## settings.headscale.dns_records_path
_Description:_ If you are using `dns.extra_records_path` in your Headscale configuration, you need to set this to the path for Headplane to be able to read the DNS records.
_Description:_ If you are using `dns.extra_records_path` in your Headscale configuration, Headplane reads that path automatically. Set this only when Headplane needs to access the same file at a different path.
Ensure that the file is both readable and writable by the Headplane process.
When using this, Headplane will no longer need to automatically restart Headscale for DNS record changes.
@@ -252,6 +250,15 @@ _Type:_ boolean
_Default:_ `false`
## settings.oidc.default_role
_Description:_ Role assigned to newly created OIDC users after the first owner is bootstrapped.
The owner role is reserved for the first-login bootstrap.
_Type:_ one of "admin", "network_admin", "it_admin", "auditor", "viewer", "member"
_Default:_ `"member"`
## settings.oidc.headscale_api_key_path
_Description:_ DEPRECATED: Use `headscale.api_key_path` instead.
@@ -284,6 +291,17 @@ _Default:_ `""`
_Example:_ `"https://headscale.example.com/admin/oidc/callback"`
## settings.oidc.role_claim
_Description:_ Optional OIDC claim containing the Headplane role to assign to newly created users.
A valid role claim takes precedence over default_role.
_Type:_ null or string
_Default:_ `null`
_Example:_ `"headplane_role"`
## settings.oidc.token_endpoint_auth_method
_Description:_ The token endpoint authentication method.
@@ -350,3 +368,82 @@ _Description:_ The port to listen on.
_Type:_ 16 bit unsigned integer; between 0 and 65535 (both inclusive)
_Default:_ `3000`
## settings.server.proxy_auth
_Description:_ Proxy authentication configuration.
_Type:_ submodule
_Default:_ `{ }`
## settings.server.proxy_auth.allowed_cidrs
_Description:_ Direct client CIDR ranges allowed to bypass Headplane's login flow.
These should be the addresses your trusted reverse proxy uses to connect
to Headplane. Requires headscale.api_key_path.
_Type:_ list of string
_Default:_ `[ "127.0.0.1/32" "::1/128" ]`
_Example:_ `[ "10.0.0.0/24" ]`
## settings.server.proxy_auth.email_header
_Description:_ Optional header containing the authenticated user's email address.
_Type:_ null or string
_Default:_ `null`
## settings.server.proxy_auth.enabled
_Description:_ Whether to trust reverse proxy authentication for allowed client CIDRs.
_Type:_ boolean
_Default:_ `false`
## settings.server.proxy_auth.ip_header
_Description:_ Optional header containing the original client IP, such as X-Forwarded-For or X-Real-IP.
_Type:_ null or string
_Default:_ `null`
## settings.server.proxy_auth.name_header
_Description:_ Optional header containing the authenticated user's display name.
_Type:_ null or string
_Default:_ `null`
## settings.server.proxy_auth.picture_header
_Description:_ Optional header containing the authenticated user's profile picture URL.
_Type:_ null or string
_Default:_ `null`
## settings.server.proxy_auth.user_header
_Description:_ Header containing the stable authenticated proxy user identity.
_Type:_ string
_Default:_ `"Remote-User"`
## settings.server.proxy_auth.trusted_proxy_cidrs
_Description:_ Direct proxy CIDR ranges trusted to supply ip_header.
Only used when ip_header is set.
_Type:_ list of string
_Default:_ `[ "127.0.0.1/32" "::1/128" ]`
_Example:_ `[ "127.0.0.1/32" ]`
+6 -2
View File
@@ -58,8 +58,8 @@ The following configuration options in Headplane are treated as secret paths:
- _Note:_ Either `cookie_secret` or `cookie_secret_path` must be provided for web session security.
- **Headscale Connection Settings (`headscale.*`):**
- `api_key_path` (Headscale API key for server-side operations like OIDC and agent sync)
- _Note:_ Either `api_key` or `api_key_path` must be provided when using OIDC or the agent.
- `api_key_path` (Headscale API key for server-side operations like OIDC, proxy authentication, and agent sync)
- _Note:_ Either `api_key` or `api_key_path` must be provided when using OIDC, proxy authentication, or the agent.
- `tls_cert_path` (custom TLS certificate for connecting to Headscale)
- _Note:_ This is treated as a regular path, not a secret path, so it will not have its content loaded.
@@ -82,6 +82,10 @@ 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.
+20 -4
View File
@@ -7,8 +7,9 @@ description: Configure the Headplane Agent for enhanced functionality.
The Headplane Agent is an optional component that periodically syncs node
information (such as version and OS details) from the Tailnet. Unlike previous
versions, the agent no longer runs as a persistent Tailnet node — it
auto-generates pre-auth keys and performs periodic syncs instead.
versions, the agent does not require you to manually create or manage pre-auth
keys — Headplane generates a fresh key for each agent startup and reuses the
agent's existing Tailnet state across restarts.
## Prerequisites
@@ -18,8 +19,9 @@ Before enabling the agent, ensure the following:
keys which are only available in Headscale 0.28+.
2. **`headscale.api_key`** must be set in your Headplane configuration file.
The agent uses this key to auto-generate ephemeral pre-auth keys for
connecting to the Tailnet.
The agent uses this key to auto-generate pre-auth keys for connecting to the
Tailnet and to auto-approve its own registration when Headscale is configured
to require manual approval.
## Configuration
@@ -55,6 +57,20 @@ default. You can change this location by defining
that the specified directory exists and is writable by the user running
Headplane.
Headplane preserves the agent's `tailscaled.state` in this directory. This lets
the agent retain its Tailnet identity across Headplane restarts instead of
registering as a new host each time. If the agent's state is lost or unusable,
Headplane falls back to the pre-auth key and registers a new agent node.
## Interactive approval
Under normal circumstances, the agent connects headlessly using the auto-generated
pre-auth key and no manual interaction is required. If your Headscale server is
configured to require interactive approval, Headplane detects the auth URL the
agent prints and automatically approves the request using the configured
`headscale.api_key`. The Settings page still shows the approval link as a
fallback in case auto-approval fails.
## Usage
<figure>
+99
View File
@@ -0,0 +1,99 @@
---
title: Proxy Authentication
description: Delegate Headplane authentication to a trusted reverse proxy.
outline: [2, 3]
---
:::warning
Proxy authentication is **dangerously powerful**. If misconfigured, it can allow
anyone to impersonate users and gain access to Headplane.
It is recommended to use Headplane's built-in SSO integrations over proxy
authentication if possible. No guarantees are made about the security of proxy
authentication.
:::
# Proxy Authentication
Proxy authentication lets Headplane delegate user authentication to a trusted
reverse proxy. This is useful when Headplane is already protected by middleware
such as nginx basic auth, Authelia, Authentik, or another SSO-aware proxy and
you do not want users to log in to Headplane separately.
Proxy authentication is intentionally opt-in and requires `headscale.api_key`.
When enabled, Headplane trusts identity headers only on requests whose client IP
matches `server.proxy_auth.allowed_cidrs`; all Headscale API calls then use the
configured `headscale.api_key`.
## Basic Configuration
```yaml
headscale:
api_key: "<your-headscale-api-key>"
server:
proxy_auth:
enabled: true
user_header: "Remote-User"
email_header: "Remote-Email"
name_header: "Remote-Name"
allowed_cidrs:
- "127.0.0.1/32"
- "::1/128"
```
`user_header` is required for a request to authenticate and defaults to
`Remote-User`. The value becomes the stable proxy identity in Headplane as
`proxy:<value>`. `email_header`, `name_header`, and `picture_header` are
optional profile metadata headers.
The first proxy-authenticated user is created as the Headplane owner, matching
the normal SSO first-user behavior. Subsequent users are created as members and
can be reassigned from the Users page.
## Client IP Checks
If `allowed_cidrs` is omitted, Headplane trusts only localhost. By default, this
CIDR check uses the socket address connected to Headplane, not
`X-Forwarded-For`, `X-Real-IP`, or other forwarded headers. Configure
`allowed_cidrs` for the direct address range your proxy uses to connect to
Headplane.
## Forwarded Client IP Headers
If you need to check the original client IP from a proxy header, set `ip_header`
to `X-Forwarded-For`, `X-Real-IP`, or another header your proxy controls. When
`ip_header` is set, Headplane only reads that header if the direct socket peer
matches `trusted_proxy_cidrs` (default localhost). The first IP in the header is
then checked against `allowed_cidrs`:
```yaml
server:
proxy_auth:
enabled: true
ip_header: "X-Forwarded-For"
trusted_proxy_cidrs:
- "127.0.0.1/32"
allowed_cidrs:
- "10.0.0.0/8"
```
::: warning
Only enable proxy authentication when Headplane is not directly reachable by
untrusted clients. Anyone who can connect to Headplane from an allowed CIDR will
be able to spoof the configured identity headers. Only configure `ip_header`
for headers set or overwritten by your trusted reverse proxy.
:::
## Header Reference
| Field | Description |
| --------------------------------------- | ------------------------------------------------------------------------------------ |
| `server.proxy_auth.enabled` | Enables proxy authentication. |
| `server.proxy_auth.user_header` | Header containing the stable authenticated user identity. Defaults to `Remote-User`. |
| `server.proxy_auth.email_header` | Optional header containing the authenticated user's email address. |
| `server.proxy_auth.name_header` | Optional header containing the authenticated user's display name. |
| `server.proxy_auth.picture_header` | Optional header containing the authenticated user's profile picture URL. |
| `server.proxy_auth.allowed_cidrs` | Client CIDRs allowed to authenticate. Defaults to localhost. |
| `server.proxy_auth.ip_header` | Optional original-client-IP header such as `X-Forwarded-For` or `X-Real-IP`. |
| `server.proxy_auth.trusted_proxy_cidrs` | Direct proxy CIDRs trusted to supply `ip_header`. Defaults to localhost. |
+14 -1
View File
@@ -21,11 +21,20 @@ joins the tailnet for the duration of the SSH session.
## Prerequisites
- **Headscale 0.28 or newer** is required.
- **Headscale 0.28 or newer** is required. Browser SSH is broken in the
Headscale 0.29 beta releases through 0.29.1; use Headscale 0.28.x or 0.29.2
and newer.
- Target nodes must have **Tailscale SSH** enabled (`tailscale up --ssh`).
- Users must be logged-in via **OIDC** (API key logins cannot use browser SSH).
- The **Headplane Agent** must be [enabled and configured](/features/agent).
:::warning Headscale 0.29.0 beta through 0.29.1
Browser SSH does not work with Headscale 0.29 beta releases through 0.29.1 due
to a `/ts2021` WebSocket routing regression. These versions reject Tailscale's
browser/WASM control-plane WebSocket request with `405 Method Not Allowed`.
Upgrade Headscale to 0.29.2 or newer, or use Headscale 0.28.x.
:::
## How It Works
:::tip
@@ -172,6 +181,10 @@ to Headscale, then retry.
### Connection fails with EOF or hangs
- **Check your Headscale version.** Browser SSH is broken in Headscale 0.29 beta
releases through 0.29.1 due to a `/ts2021` WebSocket routing regression. If
the browser console shows `405 Method Not Allowed` for `/ts2021`, upgrade to
Headscale 0.29.2 or newer, or use Headscale 0.28.x.
- **Check `server_url` in your Headscale config.** If Headscale runs on a
non-standard port, `server_url` must include it (e.g.
`https://hs.example.com:8443`). The embedded DERP server derives its
+41
View File
@@ -17,6 +17,9 @@ Identity Provider (IdP) using the OpenID Connect (OIDC) protocol. When enabled,
users sign in through your IdP and Headplane automatically links them to their
Headscale identity, assigns a role, and manages their session.
If your reverse proxy already performs authentication and can pass trusted user
headers to Headplane, see [Proxy Authentication](./proxy-auth.md) instead.
## Getting Started
### Requirements
@@ -71,6 +74,8 @@ oidc:
# userinfo_endpoint: ""
# scope: "openid email profile"
# subject_claims: ["open_id", "email"]
# default_role: "member"
# role_claim: "headplane_role"
# allow_weak_rsa_keys: false
# extra_params:
# foo: "bar"
@@ -223,6 +228,42 @@ role. All subsequent users are assigned the **Member** role (no access) by
default. An owner or admin must then assign them an appropriate role through
the Users page.
### Automatic Role Assignment
You can change the role assigned to newly created OIDC users with
`oidc.default_role`:
```yaml
oidc:
# Valid values: admin, network_admin, it_admin, auditor, viewer, member
default_role: "viewer"
```
This is useful when Headscale already restricts who can authenticate by domain,
group, or user. For example, if Headscale only allows `@example.com` users to
sign in and all of those users should be able to view Headplane, set
`default_role: "viewer"`.
For per-user roles from your IdP, configure `oidc.role_claim` with the OIDC
claim that contains a Headplane role:
```yaml
oidc:
role_claim: "headplane_role"
```
The claim may be a string, such as `"admin"`, or an array containing one of the
valid roles. This lets providers such as Keycloak map groups or client roles to
a final Headplane role before login. When both `role_claim` and `default_role`
are configured, a valid role claim takes precedence for new users.
For users that already exist in Headplane, a valid `role_claim` is synced on
each OIDC login. If their IdP groups or client roles start matching a different
Headplane role, their Headplane permissions are updated at their next sign-in.
`default_role` remains a creation-time fallback only and does not overwrite
existing roles. The **Owner** role is reserved for the first-login bootstrap and
cannot be granted or overwritten by `default_role` or `role_claim`.
### API Key Sessions
Users who sign in with a Headscale API key (instead of OIDC) are treated as
+4 -4
View File
@@ -123,10 +123,10 @@ services:
# Headplane config.yaml file.
- "/path/to/headscale/config.yaml:/etc/headscale/config.yaml"
# If you are using dns.extra_records in Headscale (recommended), you
# should also mount that file here so Headplane can read and write it.
# Ensure that the path matches `headscale.dns_records_path` in your
# Headplane config.yaml file.
# If you are using dns.extra_records_path in Headscale (recommended),
# also mount that file here so Headplane can read and write it. If the
# in-container path differs from Headscale's dns.extra_records_path,
# set `headscale.dns_records_path` in your Headplane config.yaml file.
- "/path/to/headscale/dns_records.json:/etc/headscale/dns_records.json"
# Read-only access to the Docker socket (or a proxy)
+1
View File
@@ -52,6 +52,7 @@ func (s *TSAgent) Connect() {
log := util.GetLogger()
// Waits until the agent is up and running.
log.Info("Connecting to Tailnet at %s as %s", s.ControlURL, s.Hostname)
status, err := s.Up(context.Background())
if err != nil {
log.Fatal("Failed to connect to Tailnet: %s", err)
+1 -1
View File
@@ -2,7 +2,7 @@ pre-commit:
commands:
lint:
glob: "*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc}"
run: pnpm lint --fix {staged_files}
run: pnpm lint --fix --no-error-on-unmatched-pattern {staged_files}
stage_fixed: true
format:
glob: "*"
+99 -3
View File
@@ -90,6 +90,77 @@ in {
example = "headscale.example.com";
};
proxy_auth = mkOption {
type = types.submodule {
options = {
enabled = mkOption {
type = types.bool;
default = false;
description = "Whether to trust reverse proxy authentication for allowed client CIDRs.";
};
user_header = mkOption {
type = types.str;
default = "Remote-User";
description = "Header containing the stable authenticated proxy user identity.";
};
ip_header = mkOption {
type = types.nullOr types.str;
default = null;
description = "Optional header containing the original client IP, such as X-Forwarded-For or X-Real-IP.";
};
trusted_proxy_cidrs = mkOption {
type = types.listOf types.str;
default = [
"127.0.0.1/32"
"::1/128"
];
description = ''
Direct proxy CIDR ranges trusted to supply ip_header.
Only used when ip_header is set.
'';
example = ["127.0.0.1/32"];
};
email_header = mkOption {
type = types.nullOr types.str;
default = null;
description = "Optional header containing the authenticated user's email address.";
};
name_header = mkOption {
type = types.nullOr types.str;
default = null;
description = "Optional header containing the authenticated user's display name.";
};
picture_header = mkOption {
type = types.nullOr types.str;
default = null;
description = "Optional header containing the authenticated user's profile picture URL.";
};
allowed_cidrs = mkOption {
type = types.listOf types.str;
default = [
"127.0.0.1/32"
"::1/128"
];
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.
'';
example = ["10.0.0.0/24"];
};
};
};
default = {};
description = "Proxy authentication configuration.";
};
data_path = mkOption {
type = types.path;
default = "/var/lib/headplane";
@@ -160,9 +231,8 @@ in {
type = types.bool;
default = true;
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.
Deprecated. Headplane no longer validates the complete
Headscale configuration and this option has no effect.
'';
};
@@ -352,6 +422,32 @@ in {
default = false;
description = "Whether to use PKCE when authenticating users.";
};
default_role = mkOption {
type = types.enum [
"admin"
"network_admin"
"it_admin"
"auditor"
"viewer"
"member"
];
default = "member";
description = ''
Role assigned to newly created OIDC users after the first owner is bootstrapped.
The owner role is reserved for the first-login bootstrap.
'';
};
role_claim = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
Optional OIDC claim containing the Headplane role to assign to newly created users.
A valid role claim takes precedence over default_role.
'';
example = "headplane_role";
};
};
};
default = {};
+4 -5
View File
@@ -33,14 +33,14 @@ in
inherit (finalAttrs) pname version src;
fetcherVersion = 3;
pnpm = pnpm_10;
hash = "sha256-msrc7poYMCAdhny0qyOUYo1Qt6FKIobtxLJ479E5O0g=";
hash = "sha256-2F7DplZ+PAMkDepsoeUxS04+IefWy3ARgE8G6Fz+YnQ=";
};
buildPhase = ''
runHook preBuild
cp ${headplane-ssh-wasm}/hp_ssh.wasm app/hp_ssh.wasm
cp ${headplane-ssh-wasm}/wasm_exec.js app/wasm_exec.js
pnpm react-router build
cp ${headplane-ssh-wasm}/hp_ssh.wasm public/hp_ssh.wasm
cp ${headplane-ssh-wasm}/wasm_exec.js public/wasm_exec.js
pnpm build
runHook postBuild
'';
@@ -49,7 +49,6 @@ in
mkdir -p $out/{bin,share/headplane}
cp -r build $out/share/headplane/
cp -r drizzle $out/share/headplane/
sed -i "s;$PWD;../..;" $out/share/headplane/build/server/index.js
makeWrapper ${lib.getExe nodejs_24} $out/bin/headplane \
--chdir $out/share/headplane \
--add-flags $out/share/headplane/build/server/index.js
+1 -1
View File
@@ -26,7 +26,7 @@ in
# Patch Tailscale's derphttp to include DERPPort in WebSocket URLs.
# Without this, DERP servers on non-443 ports fail in WASM builds.
if [ -f patches/tailscale-derp-port.patch ]; then
chmod -R +w vendor/tailscale.com/derp/derphttp
chmod -R +w vendor/tailscale.com
patch -d vendor/tailscale.com -p1 < patches/tailscale-derp-port.patch
fi
+46 -36
View File
@@ -1,15 +1,17 @@
{
"name": "headplane",
"version": "0.7.0-beta.4",
"version": "0.7.0",
"private": true,
"type": "module",
"sideEffects": false,
"scripts": {
"preinstall": "npx only-allow pnpm",
"build": "react-router build",
"dev": "HEADPLANE_CONFIG_PATH=./config.example.yaml react-router dev",
"dev:app": "HEADPLANE_CONFIG_PATH=./config.example.yaml react-router dev | pino-pretty -c",
"dev:docker": "docker compose up",
"dev": "pnpm run --stream /^dev:/",
"start": "node build/server/index.js",
"typecheck": "react-router typegen && tsgo",
"typecheck": "react-router typegen && tsc",
"test:unit": "vitest run --project unit",
"test:integration": "vitest run --project integration:*",
"docs:dev": "vitepress dev docs",
@@ -19,9 +21,9 @@
"format": "oxfmt"
},
"dependencies": {
"@base-ui/react": "^1.3.0",
"@base-ui/react": "^1.6.0",
"@codemirror/language": "^6.12.3",
"@codemirror/view": "^6.41.0",
"@codemirror/view": "^6.43.1",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^7.0.0",
"@dnd-kit/sortable": "^8.0.0",
@@ -30,50 +32,51 @@
"@iconify/react": "^6.0.2",
"@kubernetes/client-node": "^1.4.0",
"@lezer/highlight": "^1.2.3",
"@react-router/node": "^7.14.0",
"@uiw/react-codemirror": "4.25.9",
"arktype": "^2.2.0",
"@react-router/node": "^8.0.1",
"@uiw/react-codemirror": "4.25.10",
"arktype": "^2.2.1",
"clsx": "^2.1.1",
"drizzle-orm": "1.0.0-beta.21",
"isbot": "5.1.37",
"jose": "6.2.2",
"js-yaml": "^4.1.1",
"lucide-react": "^1.8.0",
"isbot": "5.1.43",
"jose": "6.2.3",
"js-yaml": "^4.2.0",
"lucide-react": "^1.21.0",
"mime": "^4.1.0",
"react": "19.2.5",
"react-codemirror-merge": "4.25.9",
"react-dom": "19.2.5",
"react-error-boundary": "^6.1.1",
"react-router": "^7.14.0",
"react-router-dom": "^7.14.0",
"pino": "^10.3.1",
"react": "19.2.7",
"react-codemirror-merge": "4.25.10",
"react-dom": "19.2.7",
"react-error-boundary": "^6.1.2",
"react-router": "^8.0.1",
"restty": "^0.1.35",
"tailwind-merge": "3.5.0",
"tailwind-merge": "3.6.0",
"ulidx": "2.4.1",
"undici": "8.0.2",
"yaml": "2.8.3"
"undici": "8.5.0",
"valibot": "^1.4.1",
"yaml": "2.9.0"
},
"devDependencies": {
"@react-router/dev": "^7.14.0",
"@react-router/dev": "^8.0.1",
"@shopify/lang-jsonc": "^1.0.1",
"@tailwindcss/vite": "^4.2.2",
"@tailwindcss/vite": "^4.3.1",
"@types/js-yaml": "^4.0.9",
"@types/node": "^25.6.0",
"@types/react": "^19.2.14",
"@types/node": "^26.0.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@typescript/native-preview": "7.0.0-dev.20260410.1",
"drizzle-kit": "1.0.0-beta.21",
"lefthook": "^2.1.5",
"oxfmt": "^0.44.0",
"oxlint": "^1.59.0",
"oxlint-tsgolint": "^0.20.0",
"tailwindcss": "^4.2.2",
"oxfmt": "^0.55.0",
"oxlint": "^1.70.0",
"oxlint-tsgolint": "^0.23.0",
"pino-pretty": "^13.1.3",
"tailwindcss": "^4.3.1",
"tailwindcss-animate": "^1.0.7",
"testcontainers": "^11.14.0",
"tsx": "^4.21.0",
"typescript": "^6.0.2",
"vite": "^8.0.8",
"testcontainers": "^12.0.3",
"tsx": "^4.22.4",
"typescript": "7.0.1-rc",
"vite": "^8.0.16",
"vitepress": "next",
"vitest": "^4.1.4"
"vitest": "^4.1.9"
},
"engines": {
"node": ">=24.2 <25",
@@ -105,7 +108,14 @@
"better-sqlite3"
],
"overrides": {
"@codemirror/state": "6.6.0"
"@codemirror/state": "6.6.0",
"mdast-util-to-hast@>=13.0.0 <13.2.1": ">=13.2.1",
"rollup@>=4.0.0 <4.59.0": ">=4.59.0",
"ip-address@<=10.1.0": ">=10.1.1",
"ws@>=8.0.0 <8.20.1": ">=8.20.1",
"esbuild@>=0.27.3 <0.28.1": ">=0.28.1",
"ws@>=8.0.0 <8.21.0": ">=8.21.0",
"form-data@>=4.0.0 <4.0.6": ">=4.0.6"
}
}
}
+21 -5
View File
@@ -1,9 +1,9 @@
Fix DERP WebSocket URL to include non-standard ports.
Fix DERP browser URLs to include non-standard ports.
Tailscale's derphttp client ignores DERPPort when building WebSocket
URLs, causing connections to fail when DERP servers run on non-443
ports (e.g. :8443). The TCP dial path correctly handles DERPPort
but the WebSocket path (used in WASM/browser builds) does not.
Tailscale's browser DERP paths ignore DERPPort when building WebSocket
and HTTP-only netcheck probe URLs, causing connections to fail when DERP
servers run on non-443 ports (e.g. :8443). The TCP dial path correctly
handles DERPPort but the browser paths used by WASM builds do not.
--- a/derp/derphttp/derphttp_client.go
+++ b/derp/derphttp/derphttp_client.go
@@ -28,3 +28,19 @@ but the WebSocket path (used in WASM/browser builds) does not.
}
// AddressFamilySelector decides whether IPv6 is preferred for
--- a/net/netcheck/netcheck.go
+++ b/net/netcheck/netcheck.go
@@ -1103,7 +1103,11 @@
go func() {
defer wg.Done()
node := rg.Nodes[0]
- req, _ := http.NewRequestWithContext(ctx, "HEAD", "https://"+node.HostName+"/derp/probe", nil)
+ host := node.HostName
+ if node.DERPPort != 0 {
+ host = net.JoinHostPort(node.HostName, fmt.Sprint(node.DERPPort))
+ }
+ req, _ := http.NewRequestWithContext(ctx, "HEAD", "https://"+host+"/derp/probe", nil)
// One warm-up one to get HTTP connection set
// up and get a connection from the browser's
// pool.
+1829 -1831
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -5,6 +5,5 @@ export default {
ssr: true,
future: {
unstable_optimizeDeps: true,
v8_splitRouteModules: "enforce",
},
} satisfies Config;
+18 -3
View File
@@ -19,16 +19,31 @@ import {
import { extname, normalize, resolve, sep } from "node:path";
import mime from "mime";
import pino from "pino";
export interface Logger {
info: (msg: string, ...args: unknown[]) => void;
error: (msg: string, ...args: unknown[]) => void;
}
// TODO: Replace with Pino!
const runtimeDestination = pino.destination({ dest: 1, sync: false });
process.on("exit", () => runtimeDestination.flushSync());
const runtimePino = pino(
{
base: { service: "headplane" },
messageKey: "message",
timestamp: () => `,"timestamp":"${new Date().toISOString()}"`,
formatters: {
level: (label) => ({ level: label }),
},
},
runtimeDestination,
);
const defaultLogger: Logger = {
info: (msg, ...args) => console.log(`[runtime] ${msg}`, ...args),
error: (msg, ...args) => console.error(`[runtime] ${msg}`, ...args),
info: (msg, ...args) => runtimePino.info({ component: "runtime" }, msg, ...args),
error: (msg, ...args) => runtimePino.error({ component: "runtime" }, msg, ...args),
};
export interface StaticOptions {
+49 -5
View File
@@ -1,19 +1,63 @@
import { describe, expect, test } from "vitest";
import { RouterContextProvider } from "react-router";
import { describe, expect, test, vi } from "vitest";
import { machineAction } from "~/routes/machines/machine-actions";
import { authContext, headscaleLiveStoreContext, requestApiContext } from "~/server/context";
import { Capabilities } from "~/server/web/roles";
import { getBootstrapClient, getNode, getRuntimeClient, HS_VERSIONS } from "../setup/env";
function registerRequest(registerKey: string) {
const form = new FormData();
form.set("action_id", "register");
form.set("register_key", registerKey);
form.set("user", "node-reg@");
return new Request("http://headplane.test/machines", {
method: "POST",
body: form,
});
}
function actionContext(api: Awaited<ReturnType<typeof getRuntimeClient>>) {
const auth = { can: vi.fn(() => true) };
const liveStore = { refresh: vi.fn() };
const principal = { id: "integration-user" };
const context = new RouterContextProvider();
context.set(authContext, auth as never);
context.set(headscaleLiveStoreContext, liveStore as never);
context.set(requestApiContext, vi.fn(async () => ({ principal, api })) as never);
return { auth, context, liveStore };
}
describe.sequential.for(HS_VERSIONS)("Headscale %s: Users", (version) => {
let workingNodeId: string;
test("nodes can register via their nodekey", async () => {
test("nodes can register from a Tailscale registration URL", async () => {
const client = await getRuntimeClient(version);
const tailnetNode = await getNode(version);
const { auth, context, liveStore } = actionContext(client);
const user = await client.users.create({ name: "node-reg@" });
const node = await client.nodes.register(user.name, tailnetNode.authCode);
expect(user.name).toBe("node-reg@");
const response = await machineAction({
request: registerRequest(tailnetNode.registerUrl),
context,
params: {},
} as never);
expect(auth.can).toHaveBeenCalledWith(expect.anything(), Capabilities.write_machines);
expect(liveStore.refresh).toHaveBeenCalledOnce();
expect(response).toBeInstanceOf(Response);
expect((response as Response).status).toBe(302);
const nodes = await client.nodes.list();
const node = nodes.find((n) => n.name === tailnetNode.nodeName);
expect(node).toBeDefined();
expect(node.registerMethod).toBe("REGISTER_METHOD_CLI");
expect(node.name).toBe(tailnetNode.nodeName);
expect(node?.registerMethod).toBe("REGISTER_METHOD_CLI");
});
test("nodes can be retrieved", async () => {
+1 -1
View File
@@ -26,7 +26,7 @@ describe.for(HS_VERSIONS)("Headscale %s: Runtime Client", (version) => {
expect(bootstrapper.capabilities.nodeOwnerIsImmutable).toBe(gte(v, "0.28.0"));
// If a future version is added to HS_VERSIONS before this test is
// updated, surface that explicitly rather than passing silently.
const known: Version[] = ["0.27.0", "0.27.1", "0.28.0"];
const known: Version[] = ["0.27.0", "0.27.1", "0.28.0", "0.29.0", "0.29.1"];
if (!known.includes(version)) {
context.skip();
}
+1
View File
@@ -10,6 +10,7 @@ import { afterAll, beforeAll, describe, expect, test } from "vitest";
// This is a little shim file that we use to execute the real proc-helper that
// is bundled through Vite in order to test against a real Linux /proc FS.
const testRunner = `
process.env.NODE_ENV = "test";
const { findHeadscaleServe } = require("./proc-helper.js");
async function main() {
-1
View File
@@ -71,4 +71,3 @@ unix_socket: /var/run/headscale/headscale.sock
unix_socket_permission: "0770"
logtail:
enabled: false
randomize_client_port: false
+2 -1
View File
@@ -6,7 +6,7 @@ import { startTailscaleNode, TailscaleNodeEnv } from "./start-tailscale";
// The set of Headscale versions integration tests run against. Listed
// explicitly (rather than derived from a generated manifest) so the
// supported version matrix lives next to the code that uses it.
export const HS_VERSIONS = ["0.27.0", "0.27.1", "0.28.0"] as const;
export const HS_VERSIONS = ["0.27.0", "0.27.1", "0.28.0", "0.29.0", "0.29.1"] as const;
export type Version = (typeof HS_VERSIONS)[number];
interface VersionStateEntry {
@@ -44,6 +44,7 @@ export async function getNode(version: Version) {
const { tailscaleNode } = await ensureVersion(version);
return {
authCode: tailscaleNode.authCode,
registerUrl: tailscaleNode.registerUrl,
nodeName: tailscaleNode.nodeName,
};
}
+5 -3
View File
@@ -9,6 +9,7 @@ export type Version = string;
export interface TailscaleNodeEnv {
container: tc.StartedTestContainer;
authCode: string;
registerUrl: string;
nodeName: string;
}
@@ -53,7 +54,7 @@ export async function startTailscaleNode(
if (!token) return;
authCodeResolved = true;
resolveAuthCode(token);
resolveAuthCode(`${prefix}${token}`);
rl.close();
});
@@ -68,7 +69,7 @@ export async function startTailscaleNode(
.withWaitStrategy(tc.Wait.forLogMessage(prefix).withStartupTimeout(30_000))
.start();
const authCode = await Promise.race<string>([
const registerUrl = await Promise.race<string>([
authCodePromise,
new Promise((_, reject) =>
setTimeout(
@@ -77,6 +78,7 @@ export async function startTailscaleNode(
),
),
]);
const authCode = registerUrl.slice(prefix.length);
return { container, authCode, nodeName };
return { container, authCode, registerUrl, nodeName };
}
+156
View File
@@ -30,6 +30,26 @@ describe("findOrCreateUser", () => {
expect(role).toBe("member");
});
test("second distinct user can receive a configured initial role", async () => {
await auth.findOrCreateUser("sub-owner", { name: "Owner" });
await auth.findOrCreateUser("sub-admin", { name: "Admin" }, { initialRole: "admin" });
const role = await auth.roleForSubject("sub-admin");
expect(role).toBe("admin");
});
test("first created user becomes owner even with a configured initial role", async () => {
await auth.findOrCreateUser("sub-owner", { name: "Owner" }, { initialRole: "viewer" });
const role = await auth.roleForSubject("sub-owner");
expect(role).toBe("owner");
});
test("configured initial roles cannot grant owner", async () => {
await auth.findOrCreateUser("sub-owner", { name: "Owner" });
await auth.findOrCreateUser("sub-other", { name: "Other" }, { initialRole: "owner" });
const role = await auth.roleForSubject("sub-other");
expect(role).toBe("member");
});
test("existing subject returns same id (idempotent)", async () => {
const id1 = await auth.findOrCreateUser("sub-1", { name: "Alice" });
const id2 = await auth.findOrCreateUser("sub-1", { name: "Alice" });
@@ -45,6 +65,35 @@ describe("findOrCreateUser", () => {
expect(user?.name).toBe("New");
expect(user?.email).toBe("new@test.com");
});
test("syncs an existing non-owner role when explicitly provided", async () => {
await auth.findOrCreateUser("sub-owner");
await auth.findOrCreateUser("sub-1", { name: "User" });
await auth.findOrCreateUser("sub-1", { name: "User" }, { syncRole: "admin" });
const role = await auth.roleForSubject("sub-1");
expect(role).toBe("admin");
});
test("does not apply initial role to an existing user", async () => {
await auth.findOrCreateUser("sub-owner");
await auth.findOrCreateUser("sub-1", { name: "User" });
await auth.findOrCreateUser("sub-1", { name: "User" }, { initialRole: "admin" });
const role = await auth.roleForSubject("sub-1");
expect(role).toBe("member");
});
test("does not sync the owner role", async () => {
await auth.findOrCreateUser("sub-owner");
await auth.findOrCreateUser("sub-owner", { name: "Owner" }, { syncRole: "admin" });
const role = await auth.roleForSubject("sub-owner");
expect(role).toBe("owner");
});
});
describe("linkHeadscaleUser", () => {
@@ -196,6 +245,113 @@ describe("session round-trip", () => {
});
});
describe("proxy authentication", () => {
test("creates a user-backed principal from trusted proxy headers", async () => {
const { auth } = createTestAuth({
headscaleApiKey: "configured-headscale-key",
proxyAuth: {
enabled: true,
allowedCidrs: ["10.10.0.0/16"],
emailHeader: "X-Forwarded-Email",
nameHeader: "X-Forwarded-Name",
},
});
const request = new Request("http://localhost/test", {
headers: {
"Remote-User": "alice",
"X-Forwarded-Email": "alice@example.com",
"X-Forwarded-Name": "Alice Example",
},
});
auth.registerRequestClientAddress(request, "10.10.42.9");
const principal = await auth.require(request);
expect(principal.kind).toBe("proxy");
if (principal.kind === "proxy") {
expect(principal.user.subject).toBe("proxy:alice");
expect(principal.user.role).toBe("owner");
expect(principal.profile.name).toBe("Alice Example");
expect(principal.profile.email).toBe("alice@example.com");
expect(principal.profile.username).toBe("alice");
}
expect(auth.getHeadscaleApiKey(principal)).toBe("configured-headscale-key");
});
test("uses localhost CIDRs by default when no allowed CIDRs are configured", async () => {
const { auth } = createTestAuth({
headscaleApiKey: "configured-headscale-key",
proxyAuth: { enabled: true },
});
const request = new Request("http://localhost/test", {
headers: { "Remote-User": "alice" },
});
auth.registerRequestClientAddress(request, "::ffff:127.0.0.1");
const principal = await auth.require(request);
expect(principal.kind).toBe("proxy");
expect(auth.getHeadscaleApiKey(principal)).toBe("configured-headscale-key");
});
test("falls back to normal session auth outside allowed CIDRs", async () => {
const { auth } = createTestAuth({
headscaleApiKey: "configured-headscale-key",
proxyAuth: { enabled: true, allowedCidrs: ["10.10.0.0/16"] },
});
const request = new Request("http://localhost/test");
auth.registerRequestClientAddress(request, "10.11.42.9");
await expect(auth.require(request)).rejects.toThrow("No session cookie found");
});
test("can check allowed CIDRs against a forwarded IP from a trusted proxy", async () => {
const { auth } = createTestAuth({
headscaleApiKey: "configured-headscale-key",
proxyAuth: {
enabled: true,
allowedCidrs: ["203.0.113.0/24"],
trustedProxyCidrs: ["10.0.0.0/24"],
ipHeader: "X-Forwarded-For",
},
});
const request = new Request("http://localhost/test", {
headers: {
"Remote-User": "alice",
"X-Forwarded-For": "203.0.113.42, 10.0.0.10",
},
});
auth.registerRequestClientAddress(request, "10.0.0.10");
const principal = await auth.require(request);
expect(principal.kind).toBe("proxy");
});
test("does not trust forwarded IP headers from untrusted direct peers", async () => {
const { auth } = createTestAuth({
headscaleApiKey: "configured-headscale-key",
proxyAuth: {
enabled: true,
allowedCidrs: ["203.0.113.0/24"],
trustedProxyCidrs: ["10.0.0.0/24"],
ipHeader: "X-Real-IP",
},
});
const request = new Request("http://localhost/test", {
headers: {
"Remote-User": "alice",
"X-Real-IP": "203.0.113.42",
},
});
auth.registerRequestClientAddress(request, "198.51.100.10");
await expect(auth.require(request)).rejects.toThrow("No session cookie found");
});
});
describe("authorization", () => {
let auth: AuthService;
+17 -1
View File
@@ -3,12 +3,28 @@ import { migrate } from "drizzle-orm/node-sqlite/migrator";
import { createAuthService } from "~/server/web/auth";
export function createTestAuth() {
export function createTestAuth(
options: {
headscaleApiKey?: string;
proxyAuth?: {
enabled: boolean;
allowedCidrs?: string[];
trustedProxyCidrs?: string[];
ipHeader?: string;
userHeader?: string;
emailHeader?: string;
nameHeader?: string;
pictureHeader?: string;
};
} = {},
) {
const db = drizzle(":memory:");
migrate(db, { migrationsFolder: "./drizzle" });
const auth = createAuthService({
secret: "test-secret-key-for-unit-tests",
headscaleApiKey: options.headscaleApiKey,
proxyAuth: options.proxyAuth,
db,
cookie: {
name: "_hp_test",
+45 -18
View File
@@ -1,5 +1,7 @@
import { describe, expect, test, vi } from "vitest";
import { authContext, headscaleContext } from "~/server/context";
// Helper to create a mock FormData with optional api_key
function mockFormData(apiKey?: string): FormData {
const formData = new FormData();
@@ -27,6 +29,31 @@ interface MockApiKey {
expiration: string | null;
}
interface MockHeadscale {
client: (apiKey?: string) => {
apiKeys: {
list: ReturnType<typeof vi.fn>;
};
};
}
interface MockAuth {
createApiKeySession: ReturnType<typeof vi.fn>;
}
// React Router 7 provides context values through context.get(contextKey).
// This helper creates a fake AppLoadContext that returns the correct mock
// depending on the context key that is requested.
function createMockContext({ headscale, auth }: { headscale: MockHeadscale; auth: MockAuth }) {
return {
get: (context: typeof authContext | typeof headscaleContext) => {
if (context === authContext) return auth;
if (context === headscaleContext) return headscale;
return undefined;
},
};
}
// Mock the log module to avoid console spam during tests
vi.mock("~/utils/log", () => ({
default: {
@@ -42,10 +69,10 @@ describe("Login action validation", () => {
const formData = mockFormData(); // no api_key
const request = mockRequest(formData);
const mockContext = {
const mockContext = createMockContext({
headscale: { client: vi.fn() },
sessions: { createSession: vi.fn() },
};
auth: { createApiKeySession: vi.fn() },
});
const result = (await loginAction({
request,
@@ -62,10 +89,10 @@ describe("Login action validation", () => {
const formData = mockFormData("");
const request = mockRequest(formData);
const mockContext = {
const mockContext = createMockContext({
headscale: { client: vi.fn() },
sessions: { createSession: vi.fn() },
};
auth: { createApiKeySession: vi.fn() },
});
const result = (await loginAction({
request,
@@ -86,12 +113,12 @@ describe("Login action validation", () => {
.fn()
.mockResolvedValue([{ prefix: "other-prefix", expiration: "2030-01-01T00:00:00Z" }]);
const mockContext = {
const mockContext = createMockContext({
headscale: {
client: () => ({ apiKeys: { list: mockGetApiKeys } }),
},
sessions: { createSession: vi.fn() },
};
auth: { createApiKeySession: vi.fn() },
});
const result = (await loginAction({
request,
@@ -113,12 +140,12 @@ describe("Login action validation", () => {
.fn()
.mockResolvedValue([{ prefix: "expired-key-prefix", expiration: "2020-01-01T00:00:00Z" }]);
const mockContext = {
const mockContext = createMockContext({
headscale: {
client: () => ({ apiKeys: { list: mockGetApiKeys } }),
},
sessions: { createSession: vi.fn() },
};
auth: { createApiKeySession: vi.fn() },
});
const result = (await loginAction({
request,
@@ -140,12 +167,12 @@ describe("Login action validation", () => {
.fn()
.mockResolvedValue([{ prefix: "malformed-key", expiration: null } as MockApiKey]);
const mockContext = {
const mockContext = createMockContext({
headscale: {
client: () => ({ apiKeys: { list: mockGetApiKeys } }),
},
sessions: { createSession: vi.fn() },
};
auth: { createApiKeySession: vi.fn() },
});
const result = (await loginAction({
request,
@@ -172,12 +199,12 @@ describe("Login action validation", () => {
const mockCreateSession = vi.fn().mockResolvedValue("session-cookie");
const mockContext = {
const mockContext = createMockContext({
headscale: {
client: () => ({ apiKeys: { list: mockGetApiKeys } }),
},
sessions: { createSession: mockCreateSession },
};
auth: { createApiKeySession: mockCreateSession },
});
const result = await loginAction({
request,
+20
View File
@@ -148,6 +148,26 @@ describe("Configuration YAML file loading", () => {
expect(config.oidc?.subject_claims).toEqual(["open_id", "email"]);
});
test("oidc.default_role and oidc.role_claim can be configured from YAML", async () => {
const filePath = "/config/oidc-role-assignment.yaml";
writeYaml(filePath, {
headscale: { url: "http://localhost:8080" },
server: { cookie_secret: "thirtytwo-character-cookiesecret" },
oidc: {
issuer: "https://accounts.google.com",
client_id: "my-client-id",
client_secret: "my-client-secret",
headscale_api_key: "my-api-key",
default_role: "viewer",
role_claim: "headplane_role",
},
});
const config = await loadConfig(filePath);
expect(config.oidc?.default_role).toBe("viewer");
expect(config.oidc?.role_claim).toBe("headplane_role");
});
test("oidc.allow_weak_rsa_keys defaults to false", async () => {
const filePath = "/config/oidc-weak-rsa-default.yaml";
writeYaml(filePath, {
+179
View File
@@ -0,0 +1,179 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { parse } from "yaml";
import { loadHeadscaleConfig } from "~/server/headscale/config-loader";
describe("Headscale config loader", () => {
let dir: string;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "headplane-config-loader-"));
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
test("tolerates unknown Headscale keys while reading known defaults", async () => {
const path = join(dir, "config.yaml");
await writeFile(
path,
[
"server_url: http://localhost:8080",
"future_headscale_key:",
" nested: true",
"randomize_client_port: false",
"auto_update:",
" enabled: true",
].join("\n"),
);
const config = await loadHeadscaleConfig(path);
expect(config.readable()).toBe(true);
expect(config.getDNSConfig()).toMatchObject({
magicDns: true,
baseDomain: "",
nameservers: [],
splitDns: {},
searchDomains: [],
overrideDns: true,
});
});
test("falls back for invalid consumed values without rejecting the whole config", async () => {
const path = join(dir, "config.yaml");
await writeFile(
path,
[
"server_url: http://localhost:8080",
"dns:",
" magic_dns: maybe",
" base_domain: 1234",
" override_local_dns: nope",
" nameservers:",
" global: 1.1.1.1",
" split:",
" example.com: 1.1.1.1",
" search_domains: example.com",
"oidc:",
" issuer: https://issuer.example.com",
" allowed_domains: example.com",
].join("\n"),
);
const config = await loadHeadscaleConfig(path);
expect(config.readable()).toBe(true);
expect(config.getDNSConfig()).toMatchObject({
magicDns: true,
baseDomain: "",
nameservers: [],
splitDns: {},
searchDomains: [],
overrideDns: true,
});
expect(config.getOIDCConfig()).toMatchObject({
allowedDomains: [],
});
});
test("patches only requested paths and does not write effective defaults", async () => {
const path = join(dir, "config.yaml");
await writeFile(
path,
[
"server_url: http://localhost:8080",
"future_headscale_key: keep-me",
"dns:",
" base_domain: example.com",
].join("\n"),
);
const config = await loadHeadscaleConfig(path);
await config.patch([{ path: "dns.magic_dns", value: false }]);
const written = parse(await readFile(path, "utf8"));
expect(written.future_headscale_key).toBe("keep-me");
expect(written.dns).toEqual({
base_domain: "example.com",
magic_dns: false,
});
});
test("patches quoted split DNS domains", async () => {
const path = join(dir, "config.yaml");
await writeFile(
path,
["server_url: http://localhost:8080", "dns:", " nameservers:", " split: {}"].join("\n"),
);
const config = await loadHeadscaleConfig(path);
await config.patch([{ path: 'dns.nameservers.split."corp.example.com"', value: ["1.1.1.1"] }]);
const written = parse(await readFile(path, "utf8"));
expect(written.dns.nameservers.split).toEqual({
"corp.example.com": ["1.1.1.1"],
});
});
test("prefers extra records JSON over inline YAML records", async () => {
const path = join(dir, "config.yaml");
const recordsPath = join(dir, "extra-records.json");
await writeFile(recordsPath, JSON.stringify([{ name: "json", type: "A", value: "1.1.1.1" }]));
await writeFile(
path,
[
"server_url: http://localhost:8080",
"dns:",
" extra_records_path: " + recordsPath,
" extra_records:",
" - name: yaml",
" type: A",
" value: 2.2.2.2",
].join("\n"),
);
const config = await loadHeadscaleConfig(path);
expect(config.dnsRecords()).toEqual([{ name: "json", type: "A", value: "1.1.1.1" }]);
await config.addDNS({ name: "new", type: "A", value: "3.3.3.3" });
expect(JSON.parse(await readFile(recordsPath, "utf8"))).toEqual([
{ name: "json", type: "A", value: "1.1.1.1" },
{ name: "new", type: "A", value: "3.3.3.3" },
]);
expect(parse(await readFile(path, "utf8")).dns.extra_records).toEqual([
{ name: "yaml", type: "A", value: "2.2.2.2" },
]);
});
test("reads OIDC restrictions as empty arrays when unset", async () => {
const path = join(dir, "config.yaml");
await writeFile(
path,
["server_url: http://localhost:8080", "oidc:", " issuer: https://issuer.example.com"].join(
"\n",
),
);
const config = await loadHeadscaleConfig(path);
expect(config.getOIDCConfig()).toEqual({
issuer: "https://issuer.example.com",
allowedDomains: [],
allowedGroups: [],
allowedUsers: [],
});
});
test("does not treat an empty OIDC block as configured", async () => {
const path = join(dir, "config.yaml");
await writeFile(path, ["server_url: http://localhost:8080", "oidc: {}"].join("\n"));
const config = await loadHeadscaleConfig(path);
expect(config.getOIDCConfig()).toBeUndefined();
expect(config.hasOIDCConfig()).toBe(false);
});
});
@@ -39,6 +39,21 @@ describe("parseServerVersion", () => {
expect(v.unknown).toBe(true);
expect(v.raw).toBe("dev");
});
test("flags Go pseudo-versions from untagged builds as unknown", () => {
// Headscale's per-commit Docker images (main-*, development) report a
// Go pseudo-version instead of `dev`. Parsing it as semver 0.0.0 would
// deny every capability to a server running the newest code.
const v = parseServerVersion("v0.0.0-20260703052708-048308511c72");
expect(v.unknown).toBe(true);
expect(v.raw).toBe("v0.0.0-20260703052708-048308511c72");
});
test("does not flag a genuine 0.0.0 prerelease as unknown", () => {
const v = parseServerVersion("v0.0.0-beta.1");
expect(v.unknown).toBe(false);
expect(v.prerelease).toBe("beta.1");
});
});
describe("gte", () => {
@@ -82,6 +97,7 @@ describe("capabilitiesFor", () => {
preAuthKeysHaveStableIds: true,
nodeTagsAreFlat: true,
nodeOwnerIsImmutable: true,
registerKeyIncludesAuthReqPrefix: false,
});
});
@@ -91,10 +107,16 @@ describe("capabilitiesFor", () => {
);
});
test("0.29.0 enables the prefixed AuthID register key", () => {
const caps = capabilitiesFor(parseServerVersion("0.29.0"));
expect(caps.registerKeyIncludesAuthReqPrefix).toBe(true);
});
test("0.27.1 lacks every 0.28-gated capability", () => {
const caps = capabilitiesFor(parseServerVersion("0.27.1"));
expect(caps.preAuthKeysHaveStableIds).toBe(false);
expect(caps.nodeTagsAreFlat).toBe(false);
expect(caps.nodeOwnerIsImmutable).toBe(false);
expect(caps.registerKeyIncludesAuthReqPrefix).toBe(false);
});
});
+54
View File
@@ -771,6 +771,7 @@ describe("handleCallback", () => {
const idToken = await signIdToken({ sub: "user-123", name: "Test" }, flowState.nonce);
let callCount = 0;
let retryBody = "";
tokenHandler = async (req, res) => {
callCount++;
const body = await readBody(req);
@@ -782,6 +783,8 @@ describe("handleCallback", () => {
return;
}
retryBody = body;
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
@@ -798,6 +801,7 @@ describe("handleCallback", () => {
expect(result.ok).toBe(true);
expect(callCount).toBe(2);
expect(new URLSearchParams(retryBody).has("client_secret")).toBe(false);
});
test("token exchange uses client_secret_post by default", async () => {
@@ -846,8 +850,10 @@ describe("handleCallback", () => {
const idToken = await signIdToken({ sub: "user-123", name: "Test" }, flowState.nonce);
let receivedAuth: string | undefined;
let receivedBody = "";
tokenHandler = async (req, res) => {
receivedAuth = req.headers.authorization;
receivedBody = await readBody(req);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ access_token: "at", id_token: idToken, token_type: "Bearer" }));
};
@@ -858,6 +864,7 @@ describe("handleCallback", () => {
expect(receivedAuth).toBeDefined();
expect(receivedAuth!.startsWith("Basic ")).toBe(true);
expect(new URLSearchParams(receivedBody).has("client_secret")).toBe(false);
});
});
@@ -890,6 +897,53 @@ describe("identity resolution", () => {
expect(result.ok && result.value.name).toBe("Alice Smith");
});
test("resolves role from configured ID token claim", async () => {
const result = await flowWithClaims(
{ sub: "u1", headplane_role: "network_admin" },
{ roleClaim: "headplane_role" },
);
expect(result.ok && result.value.role).toBe("network_admin");
});
test("resolves strongest assignable role from configured array claim", async () => {
const result = await flowWithClaims(
{ sub: "u1", groups: ["member", "admin"] },
{ roleClaim: "groups" },
);
expect(result.ok && result.value.role).toBe("admin");
});
test("fetches userinfo when configured role claim is missing from ID token", async () => {
const svc = createOidcService(testConfig({ usePkce: false, roleClaim: "headplane_role" }));
const flowResult = await svc.startFlow();
if (!flowResult.ok) {
throw new Error("startFlow failed");
}
const { flowState } = flowResult.value;
const idToken = await signIdToken(
{ sub: "u1", name: "Alice Smith", email: "alice@example.com" },
flowState.nonce,
);
tokenHandler = async (_req, res) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ access_token: "at", id_token: idToken, token_type: "Bearer" }));
};
userinfoHandler = (_req, res) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ headplane_role: "viewer" }));
};
const params = new URLSearchParams({ code: "c", state: flowState.state });
const result = await svc.handleCallback(params, flowState);
expect(result.ok && result.value.role).toBe("viewer");
});
test("falls back to given_name + family_name", async () => {
const result = await flowWithClaims({
sub: "u1",
+59
View File
@@ -0,0 +1,59 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
const envSnapshot = { ...process.env };
async function loadLogger() {
vi.resetModules();
const mod = await import("~/utils/log");
return mod.default;
}
describe("structured logger", () => {
beforeEach(() => {
process.env = { ...envSnapshot };
vi.restoreAllMocks();
});
test("writes JSON log entries", async () => {
process.env.HEADPLANE_DEBUG_LOG = "false";
const spy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
const log = await loadLogger();
log.info("server", "Listening on %s://%s:%s", "http", "127.0.0.1", 3000);
expect(spy).toHaveBeenCalledOnce();
const entry = JSON.parse(spy.mock.calls[0][0] as string);
expect(entry).toMatchObject({
level: "info",
component: "server",
msg: "Listening on http://127.0.0.1:3000",
});
expect(entry.timestamp).toEqual(expect.any(String));
});
test("keeps debug logs disabled by default", async () => {
delete process.env.HEADPLANE_DEBUG_LOG;
const spy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
const log = await loadLogger();
log.debug("api", "GET %s", "/v1/node");
expect(log.debugEnabled).toBe(false);
expect(spy).not.toHaveBeenCalled();
});
test("writes debug entries when enabled", async () => {
process.env.HEADPLANE_DEBUG_LOG = "true";
const spy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
const log = await loadLogger();
log.debug("api", "GET %s", "/v1/node");
expect(log.debugEnabled).toBe(true);
expect(JSON.parse(spy.mock.calls[0][0] as string)).toMatchObject({
level: "debug",
component: "api",
msg: "GET /v1/node",
});
});
});
+49 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import { isNoExpiry } from "~/utils/node-info";
import { extractTagOwnerTags, isNoExpiry, sortAssignableTags } from "~/utils/node-info";
describe("isNoExpiry", () => {
test("returns true for null", () => {
@@ -27,3 +27,51 @@ describe("isNoExpiry", () => {
expect(isNoExpiry("2020-01-01T00:00:00Z")).toBe(false);
});
});
describe("extractTagOwnerTags", () => {
test("reads tags from HuJSON tagOwners", () => {
expect(
extractTagOwnerTags(`{
// comment
"tagOwners": {
"tag:prod": ["group:admins"],
"tag:server": [],
},
}`),
).toEqual(["tag:prod", "tag:server"]);
});
test("ignores invalid policies", () => {
expect(extractTagOwnerTags("not-json")).toEqual([]);
});
});
describe("sortAssignableTags", () => {
test("unions assigned node tags with ACL tag owners", () => {
expect(
sortAssignableTags(
[
{
id: "1",
givenName: "node",
name: "node",
machineKey: "mkey:test",
nodeKey: "nodekey:test",
discoKey: "discokey:test",
ipAddresses: [],
tags: ["tag:used"],
lastSeen: "",
expiry: null,
online: true,
registerMethod: "REGISTER_METHOD_AUTH_KEY",
createdAt: "",
availableRoutes: [],
approvedRoutes: [],
subnetRoutes: [],
},
],
'{"tagOwners":{"tag:declared":[]}}',
),
).toEqual(["tag:declared", "tag:used"]);
});
});
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, test } from "vitest";
import { normalizeRegistrationKey } from "~/utils/register-key";
const suffix = "ABCDEFGHIJKLMNOPQRSTUVWX";
const key = `hskey-authreq-${suffix}`;
const legacyKey = "mkey:0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
describe("normalizeRegistrationKey", () => {
test("keeps full registration keys", () => {
expect(normalizeRegistrationKey(key)).toBe(key);
});
test("keeps legacy machine keys", () => {
expect(normalizeRegistrationKey(legacyKey)).toBe(legacyKey);
});
test("trims surrounding whitespace", () => {
expect(normalizeRegistrationKey(` ${key}\n`)).toBe(key);
});
test("extracts keys from registration URLs", () => {
expect(normalizeRegistrationKey(`https://headscale.example.com/register/${key}`)).toBe(key);
});
test("extracts legacy machine keys from registration URLs", () => {
expect(normalizeRegistrationKey(`https://headscale.example.com/register/${legacyKey}`)).toBe(
legacyKey,
);
});
test("extracts keys from registration URLs with trailing URL parts", () => {
expect(normalizeRegistrationKey(`https://headscale.example.com/register/${key}?foo=bar`)).toBe(
key,
);
});
test("extracts auth request keys from URLs with trailing punctuation", () => {
expect(normalizeRegistrationKey(`https://headscale.example.com/register/${key}.`)).toBe(key);
});
test("accepts suffix-only input as a fallback", () => {
expect(normalizeRegistrationKey(suffix)).toBe(key);
});
test("does not require an exact suffix length for full keys", () => {
const longerKey = `${key}YZ12`;
expect(normalizeRegistrationKey(longerKey)).toBe(longerKey);
});
test("rejects empty or unrelated input", () => {
expect(normalizeRegistrationKey(" ")).toBeNull();
expect(normalizeRegistrationKey("not-a-registration-key")).toBeNull();
expect(normalizeRegistrationKey("hskey-authreq-short")).toBeNull();
expect(normalizeRegistrationKey("https://headscale.example.com/register/not-a-key")).toBeNull();
});
});
+66 -43
View File
@@ -10,6 +10,7 @@ import { headplaneDevServer } from "./runtime/vite-plugin";
const PROD_ENTRY = "./app/server/main.ts";
const DEV_ENTRY = "./app/server/app.ts";
const REACT_ROUTER_SSR_NO_EXTERNAL = ["@react-router/node", "react-router"];
const PREFIX = process.env.__INTERNAL_PREFIX || "/admin";
if (PREFIX.endsWith("/")) {
@@ -44,47 +45,69 @@ if (!VERSION) {
const config = await readFile("config.example.yaml", "utf-8");
const { server } = parse(config);
export default defineConfig(({ command, isSsrBuild }) => ({
base: command === "build" ? `${PREFIX}/` : undefined,
plugins: [
headplaneDevServer({
entry: DEV_ENTRY,
basename: PREFIX,
publicDir: new URL("./public", import.meta.url).pathname,
}),
reactRouter(),
tailwindcss(),
],
server: {
host: server.host,
port: server.port,
},
resolve: {
tsconfigPaths: true,
},
build: {
target: "baseline-widely-available",
sourcemap: true,
rollupOptions:
command === "build"
? {
// Override the SSR build entry so React Router emits the
// production bootstrap (`app/server/main.ts`) as
// `build/server/index.js`. It transitively imports the
// SSR entry, which pulls in the React Router server build
// via the virtual module `virtual:react-router/server-build`.
input: isSsrBuild ? PROD_ENTRY : undefined,
export default defineConfig(({ command }) => {
const ssrNoExternal = command === "build" ? true : REACT_ROUTER_SSR_NO_EXTERNAL;
// Exclude WASM from the client since it fetches from the server
external: isSsrBuild ? [] : [/\.wasm(\?url)?$/],
}
: undefined,
},
ssr: {
noExternal: command === "build" ? true : undefined,
},
define: {
__VERSION__: JSON.stringify(isNext ? `${VERSION}-next` : VERSION),
__PREFIX__: JSON.stringify(PREFIX),
},
}));
return {
base: command === "build" ? `${PREFIX}/` : undefined,
plugins: [
headplaneDevServer({
entry: DEV_ENTRY,
basename: PREFIX,
publicDir: new URL("./public", import.meta.url).pathname,
}),
reactRouter(),
tailwindcss(),
],
server: {
host: server.host,
port: server.port,
},
resolve: {
tsconfigPaths: true,
},
build: {
target: "baseline-widely-available",
sourcemap: true,
},
environments: {
client: {
build: {
rollupOptions:
command === "build"
? {
// Exclude WASM from the client since it fetches from the server
external: [/\.wasm(\?url)?$/],
}
: undefined,
},
},
ssr: {
resolve: {
noExternal: ssrNoExternal,
},
build: {
rollupOptions:
command === "build"
? {
// Override the SSR build entry so React Router emits the
// production bootstrap (`app/server/main.ts`) as
// `build/server/index.js`. It transitively imports the
// SSR entry, which pulls in the React Router server build
// via the virtual module `virtual:react-router/server-build`.
input: PROD_ENTRY,
external: [],
}
: undefined,
},
},
},
ssr: {
noExternal: ssrNoExternal,
},
define: {
__VERSION__: JSON.stringify(isNext ? `${VERSION}-next` : VERSION),
__PREFIX__: JSON.stringify(PREFIX),
},
};
});
+1 -1
View File
@@ -33,7 +33,7 @@ export default defineConfig({
name: "integration:api",
include: ["tests/integration/api/**/*.test.ts"],
setupFiles: ["tests/integration/setup/vitest-hook.ts"],
testTimeout: 15_000,
testTimeout: 60_000,
},
},
{