mirror of
https://github.com/tale/headplane.git
synced 2026-07-26 15:58:14 +00:00
Compare commits
37 Commits
v0.7.0-beta.4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b3c0c1c691 | |||
| 3d9b9b0f7e | |||
| f52c4161cc | |||
| fb73181cba | |||
| 17e7b35478 | |||
| 51a4613295 | |||
| 1345b7ca13 | |||
| 948cfad58c | |||
| 990e1e9ff5 | |||
| 18263e4878 | |||
| 12cee9763e | |||
| 4d0c0cacca | |||
| 83671ff3b0 | |||
| 476a0419f1 | |||
| 0439175e73 | |||
| a3cd8444a3 | |||
| 10055541cc | |||
| dc32427cff | |||
| 5aebc6d932 | |||
| e29221e5f7 | |||
| 846c030bc1 | |||
| 5d6eef5843 | |||
| 3f9dcd5eb4 | |||
| 0c4d175eb7 | |||
| 96f2721272 | |||
| c7822e3ec2 | |||
| 4179e3e604 | |||
| f00d8ab87e | |||
| 3252482e0b | |||
| 57c8046f99 | |||
| c8b1a30f34 | |||
| 8017436bb6 | |||
| dea19f9330 | |||
| 21806caa05 | |||
| 2f3a440de5 | |||
| e74e0d4542 | |||
| db39b5f2bd |
@@ -0,0 +1,57 @@
|
||||
# PULLFROG ACTION — DO NOT EDIT EXCEPT WHERE INDICATED
|
||||
name: Pullfrog
|
||||
run-name: ${{ inputs.name || github.workflow }}
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
prompt:
|
||||
type: string
|
||||
description: Agent prompt
|
||||
name:
|
||||
type: string
|
||||
description: Run name
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pullfrog:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- name: Run agent
|
||||
uses: pullfrog/pullfrog@v0
|
||||
with:
|
||||
prompt: ${{ inputs.prompt }}
|
||||
env:
|
||||
# add at least one provider API key
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
GOOGLE_GENERATIVE_AI_API_KEY:
|
||||
${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }}
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
|
||||
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
|
||||
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||
|
||||
# for Amazon Bedrock (https://docs.pullfrog.com/bedrock)
|
||||
# AWS_BEARER_TOKEN_BEDROCK: ${{ secrets.AWS_BEARER_TOKEN_BEDROCK }}
|
||||
# AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
# AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
# AWS_REGION: us-east-1
|
||||
# BEDROCK_MODEL_ID: <bedrock-model-id>
|
||||
|
||||
# for Google Vertex AI (https://docs.pullfrog.com/vertex)
|
||||
# VERTEX_SERVICE_ACCOUNT_JSON: ${{ secrets.VERTEX_SERVICE_ACCOUNT_JSON }}
|
||||
# GOOGLE_CLOUD_PROJECT: my-project
|
||||
# VERTEX_LOCATION: global
|
||||
# VERTEX_MODEL_ID: <vertex-model-id>
|
||||
@@ -1,3 +1,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.
|
||||
|
||||
@@ -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
@@ -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),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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("/");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,6 +12,17 @@ const renameSchema = type({
|
||||
name: "string > 0",
|
||||
});
|
||||
|
||||
const dnsLabelPattern = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||
|
||||
function validateMachineName(values: Record<string, unknown>) {
|
||||
const name = String(values.name ?? "").toLowerCase();
|
||||
if (!dnsLabelPattern.test(name)) {
|
||||
return {
|
||||
name: "Use a valid DNS label: lowercase letters, numbers, and hyphens only. It must start and end with a letter or number.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface RenameProps {
|
||||
machine: Machine;
|
||||
isOpen: boolean;
|
||||
@@ -23,12 +34,13 @@ export default function Rename({ machine, magic, isOpen, setIsOpen }: RenameProp
|
||||
const form = useForm({
|
||||
schema: renameSchema,
|
||||
defaultValues: { name: machine.givenName },
|
||||
validate: validateMachineName,
|
||||
});
|
||||
const name = form.values.name as string;
|
||||
|
||||
return (
|
||||
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogPanel>
|
||||
<DialogPanel isDisabled={!form.canSubmit}>
|
||||
<Title>Edit machine name for {machine.givenName}</Title>
|
||||
<Text className="mb-6">
|
||||
This name is shown in the admin panel, in Tailscale clients, and used when generating
|
||||
|
||||
@@ -24,6 +24,10 @@ export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsP
|
||||
const submittingRef = useRef(false);
|
||||
const [tags, setTags] = useState([...machine.tags]);
|
||||
const [tag, setTag] = useState("tag:");
|
||||
const tagOptions = useMemo(
|
||||
() => (existingTags ?? []).filter((existingTag) => !tags.includes(existingTag)),
|
||||
[existingTags, tags],
|
||||
);
|
||||
const tagIsInvalid = useMemo(
|
||||
() => tag.length === 0 || !tag.startsWith("tag:") || tags.includes(tag),
|
||||
[tag, tags],
|
||||
@@ -98,6 +102,7 @@ export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsP
|
||||
onClick={() => {
|
||||
setTags(tags.filter((tag) => tag !== item));
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<X className="p-1" />
|
||||
</Button>
|
||||
@@ -124,10 +129,26 @@ export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsP
|
||||
setTags([...tags, tag]);
|
||||
setTag("tag:");
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Plus className="p-1" size={30} />
|
||||
</Button>
|
||||
</div>
|
||||
{tagOptions.length > 0 ? (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{tagOptions.map((option) => (
|
||||
<Button
|
||||
className="px-2 py-1 font-mono text-xs"
|
||||
key={option}
|
||||
onClick={() => setTags([...tags, option])}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{option}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<p className="mt-2 text-sm opacity-50">
|
||||
Not seeing the tags you expect? Tags need to be defined in your access control policy
|
||||
before they can be assigned to machines.
|
||||
|
||||
@@ -1,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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -99,7 +99,7 @@ async function validateConfigPath(path: string) {
|
||||
try {
|
||||
await access(path, constants.F_OK | constants.W_OK);
|
||||
return { w: true, r: true };
|
||||
} catch (error) {
|
||||
} catch {
|
||||
log.warn("config", "Headscale DNS file at %s is not writable", path);
|
||||
return { w: false, r: true };
|
||||
}
|
||||
|
||||
@@ -1,258 +1,381 @@
|
||||
import { constants, access, readFile, writeFile } from "node:fs/promises";
|
||||
import { exit } from "node:process";
|
||||
import { setTimeout } from "node:timers/promises";
|
||||
|
||||
import { type } from "arktype";
|
||||
import * as v from "valibot";
|
||||
import { Document, parseDocument } from "yaml";
|
||||
|
||||
import log from "~/utils/log";
|
||||
|
||||
import { DNSRecord, HeadscaleDNSConfig, loadHeadscaleDNS } from "./config-dns";
|
||||
import { headscaleConfig } from "./config-schema";
|
||||
|
||||
interface PatchConfig {
|
||||
path: string;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
// We need a class for the config because we need to be able to
|
||||
// support retrieving it via a getter but also be able to
|
||||
// patch it and to query it for its mode
|
||||
class HeadscaleConfig {
|
||||
private config?: typeof headscaleConfig.infer;
|
||||
private document?: Document;
|
||||
private access: "rw" | "ro" | "no";
|
||||
private path?: string;
|
||||
private writeLock = false;
|
||||
private dns?: HeadscaleDNSConfig;
|
||||
interface DNSConfigView {
|
||||
magicDns: boolean;
|
||||
baseDomain: string;
|
||||
nameservers: string[];
|
||||
splitDns: Record<string, string[]>;
|
||||
searchDomains: string[];
|
||||
overrideDns: boolean;
|
||||
extraRecords: DNSRecord[];
|
||||
}
|
||||
|
||||
constructor(
|
||||
access: "rw" | "ro" | "no",
|
||||
dns?: HeadscaleDNSConfig,
|
||||
config?: typeof headscaleConfig.infer,
|
||||
document?: Document,
|
||||
path?: string,
|
||||
) {
|
||||
this.access = access;
|
||||
this.config = config;
|
||||
this.document = document;
|
||||
this.path = path;
|
||||
this.dns = dns;
|
||||
interface OIDCConfigView {
|
||||
issuer: string;
|
||||
allowedDomains: string[];
|
||||
allowedGroups: string[];
|
||||
allowedUsers: string[];
|
||||
}
|
||||
|
||||
interface ParsedDNSConfig {
|
||||
magic_dns: boolean;
|
||||
base_domain: string;
|
||||
nameservers: {
|
||||
global: string[];
|
||||
split: Record<string, string[]>;
|
||||
};
|
||||
search_domains: string[];
|
||||
override_local_dns: boolean;
|
||||
extra_records: DNSRecord[];
|
||||
extra_records_path?: string;
|
||||
}
|
||||
|
||||
const DNS_CONFIG_DEFAULTS: ParsedDNSConfig = {
|
||||
magic_dns: true,
|
||||
base_domain: "",
|
||||
nameservers: {
|
||||
global: [],
|
||||
split: {},
|
||||
},
|
||||
search_domains: [],
|
||||
override_local_dns: true,
|
||||
extra_records: [],
|
||||
};
|
||||
|
||||
const stringSchema = v.string();
|
||||
const stringArraySchema = v.array(v.string());
|
||||
const stringArrayRecordSchema = v.record(v.string(), stringArraySchema);
|
||||
const goBooleanSchema = v.pipe(
|
||||
v.union([v.boolean(), v.picklist(["true", "false"])]),
|
||||
v.transform((value) => value === true || value === "true"),
|
||||
);
|
||||
const dnsRecordsSchema = v.array(
|
||||
v.object({
|
||||
name: v.string(),
|
||||
type: v.string(),
|
||||
value: v.string(),
|
||||
}),
|
||||
);
|
||||
const nameserversSchema = v.object({
|
||||
global: v.optional(v.fallback(stringArraySchema, []), []),
|
||||
split: v.optional(v.fallback(stringArrayRecordSchema, {}), {}),
|
||||
});
|
||||
const dnsConfigSchema = v.object({
|
||||
magic_dns: v.optional(
|
||||
v.fallback(goBooleanSchema, DNS_CONFIG_DEFAULTS.magic_dns),
|
||||
DNS_CONFIG_DEFAULTS.magic_dns,
|
||||
),
|
||||
base_domain: v.optional(
|
||||
v.fallback(stringSchema, DNS_CONFIG_DEFAULTS.base_domain),
|
||||
DNS_CONFIG_DEFAULTS.base_domain,
|
||||
),
|
||||
nameservers: v.optional(
|
||||
v.fallback(nameserversSchema, DNS_CONFIG_DEFAULTS.nameservers),
|
||||
DNS_CONFIG_DEFAULTS.nameservers,
|
||||
),
|
||||
search_domains: v.optional(
|
||||
v.fallback(stringArraySchema, DNS_CONFIG_DEFAULTS.search_domains),
|
||||
DNS_CONFIG_DEFAULTS.search_domains,
|
||||
),
|
||||
override_local_dns: v.optional(
|
||||
v.fallback(goBooleanSchema, DNS_CONFIG_DEFAULTS.override_local_dns),
|
||||
DNS_CONFIG_DEFAULTS.override_local_dns,
|
||||
),
|
||||
extra_records: v.optional(
|
||||
v.fallback(dnsRecordsSchema, DNS_CONFIG_DEFAULTS.extra_records),
|
||||
DNS_CONFIG_DEFAULTS.extra_records,
|
||||
),
|
||||
extra_records_path: v.optional(v.string()),
|
||||
});
|
||||
const headscaleConfigSchema = v.fallback(
|
||||
v.object({
|
||||
dns: v.optional(v.fallback(dnsConfigSchema, DNS_CONFIG_DEFAULTS), DNS_CONFIG_DEFAULTS),
|
||||
}),
|
||||
{ dns: DNS_CONFIG_DEFAULTS },
|
||||
);
|
||||
const oidcConfigSchema = v.object({
|
||||
issuer: v.string(),
|
||||
allowed_domains: v.optional(v.fallback(stringArraySchema, []), []),
|
||||
allowed_groups: v.optional(v.fallback(stringArraySchema, []), []),
|
||||
allowed_users: v.optional(v.fallback(stringArraySchema, []), []),
|
||||
});
|
||||
const rawOIDCConfigSchema = v.fallback(
|
||||
v.object({
|
||||
oidc: v.optional(v.unknown()),
|
||||
}),
|
||||
{},
|
||||
);
|
||||
const extraRecordsConflictSchema = v.object({
|
||||
dns: v.optional(
|
||||
v.object({
|
||||
extra_records: v.optional(v.array(v.unknown())),
|
||||
extra_records_path: v.optional(v.string()),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
interface HeadscaleConfigState {
|
||||
document?: Document;
|
||||
config: unknown;
|
||||
access: "rw" | "ro" | "no";
|
||||
path?: string;
|
||||
writeQueue: Promise<void>;
|
||||
dns?: HeadscaleDNSConfig;
|
||||
}
|
||||
|
||||
interface HeadscaleConfig {
|
||||
readable: () => boolean;
|
||||
writable: () => boolean;
|
||||
getDNSConfig: () => DNSConfigView;
|
||||
getMagicDNSBaseDomain: () => string | undefined;
|
||||
getOIDCConfig: () => OIDCConfigView | undefined;
|
||||
hasOIDCConfig: () => boolean;
|
||||
dnsRecords: () => DNSRecord[];
|
||||
patch: (patches: PatchConfig[]) => Promise<void>;
|
||||
addDNS: (record: DNSRecord) => Promise<boolean | void>;
|
||||
removeDNS: (record: DNSRecord) => Promise<boolean | void>;
|
||||
}
|
||||
|
||||
function createHeadscaleConfig(
|
||||
access: "rw" | "ro" | "no",
|
||||
dns?: HeadscaleDNSConfig,
|
||||
document?: Document,
|
||||
path?: string,
|
||||
): HeadscaleConfig {
|
||||
const state: HeadscaleConfigState = {
|
||||
access,
|
||||
config: document?.toJSON() ?? {},
|
||||
document,
|
||||
path,
|
||||
writeQueue: Promise.resolve(),
|
||||
dns,
|
||||
};
|
||||
|
||||
return {
|
||||
readable: () => readable(state),
|
||||
writable: () => writable(state),
|
||||
getDNSConfig: () => getDNSConfig(state),
|
||||
getMagicDNSBaseDomain: () => getMagicDNSBaseDomain(state),
|
||||
getOIDCConfig: () => getOIDCConfig(state),
|
||||
hasOIDCConfig: () => hasOIDCConfig(state),
|
||||
dnsRecords: () => dnsRecords(state),
|
||||
patch: (patches) => patchHeadscaleConfig(state, patches),
|
||||
addDNS: (record) => addDNS(state, record),
|
||||
removeDNS: (record) => removeDNS(state, record),
|
||||
};
|
||||
}
|
||||
|
||||
function readable(config: HeadscaleConfigState) {
|
||||
return config.access !== "no";
|
||||
}
|
||||
|
||||
function writable(config: HeadscaleConfigState) {
|
||||
return config.access === "rw";
|
||||
}
|
||||
|
||||
function getDNSConfig(config: HeadscaleConfigState): DNSConfigView {
|
||||
const dns = v.parse(headscaleConfigSchema, config.config).dns;
|
||||
|
||||
return {
|
||||
magicDns: dns.magic_dns,
|
||||
baseDomain: dns.base_domain,
|
||||
nameservers: dns.nameservers.global,
|
||||
splitDns: dns.nameservers.split ?? {},
|
||||
searchDomains: dns.search_domains,
|
||||
overrideDns: dns.override_local_dns,
|
||||
extraRecords: dnsRecords(config),
|
||||
};
|
||||
}
|
||||
|
||||
function getMagicDNSBaseDomain(config: HeadscaleConfigState) {
|
||||
if (!readable(config)) return;
|
||||
const dns = getDNSConfig(config);
|
||||
return dns.magicDns && dns.baseDomain ? dns.baseDomain : undefined;
|
||||
}
|
||||
|
||||
function getOIDCConfig(config: HeadscaleConfigState): OIDCConfigView | undefined {
|
||||
const oidc = v.safeParse(oidcConfigSchema, v.parse(rawOIDCConfigSchema, config.config).oidc);
|
||||
if (!oidc.success) return;
|
||||
|
||||
return {
|
||||
issuer: oidc.output.issuer,
|
||||
allowedDomains: oidc.output.allowed_domains,
|
||||
allowedGroups: oidc.output.allowed_groups,
|
||||
allowedUsers: oidc.output.allowed_users,
|
||||
};
|
||||
}
|
||||
|
||||
function hasOIDCConfig(config: HeadscaleConfigState) {
|
||||
return getOIDCConfig(config) !== undefined;
|
||||
}
|
||||
|
||||
function dnsRecords(config: HeadscaleConfigState) {
|
||||
if (config.dns) {
|
||||
return config.dns.r;
|
||||
}
|
||||
|
||||
readable() {
|
||||
return this.access !== "no";
|
||||
}
|
||||
return v.parse(headscaleConfigSchema, config.config).dns.extra_records;
|
||||
}
|
||||
|
||||
writable() {
|
||||
return this.access === "rw";
|
||||
}
|
||||
|
||||
get c() {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
get d() {
|
||||
if (this.dns) {
|
||||
return this.dns.r;
|
||||
}
|
||||
|
||||
return this.config?.dns.extra_records ?? [];
|
||||
}
|
||||
|
||||
async patch(patches: PatchConfig[]) {
|
||||
if (!this.path || !this.document || !this.readable() || !this.writable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug("config", "Patching Headscale configuration");
|
||||
for (const patch of patches) {
|
||||
const { path, value } = patch;
|
||||
log.debug("config", "Patching %s with %o", path, value);
|
||||
|
||||
// If the key is something like `test.bar."foo.bar"`, then we treat
|
||||
// the foo.bar as a single key, and not as two keys, so that needs
|
||||
// to be split correctly.
|
||||
|
||||
// Iterate through each character, and if we find a dot, we check if
|
||||
// the next character is a quote, and if it is, we skip until the next
|
||||
// quote, and then we skip the next character, which should be a dot.
|
||||
// If it's not a quote, we split it.
|
||||
const key = [];
|
||||
let current = "";
|
||||
let quote = false;
|
||||
|
||||
for (const char of path) {
|
||||
if (char === '"') {
|
||||
quote = !quote;
|
||||
}
|
||||
|
||||
if (char === "." && !quote) {
|
||||
key.push(current);
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
current += char;
|
||||
}
|
||||
|
||||
key.push(current.replaceAll('"', ""));
|
||||
if (value === null) {
|
||||
this.document.deleteIn(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.document.setIn(key, value);
|
||||
}
|
||||
|
||||
// Revalidate our configuration and update the config
|
||||
// object with the new configuration
|
||||
log.info("config", "Revalidating Headscale configuration");
|
||||
const config = validateConfig(this.document.toJSON());
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug("config", "Writing updated Headscale configuration to %s", this.path);
|
||||
|
||||
// We need to lock the writeLock so that we don't try to write
|
||||
// to the file while we're already writing to it
|
||||
while (this.writeLock) {
|
||||
await setTimeout(100);
|
||||
}
|
||||
|
||||
this.writeLock = true;
|
||||
await writeFile(this.path, this.document.toString(), "utf8");
|
||||
this.config = config;
|
||||
this.writeLock = false;
|
||||
async function patchHeadscaleConfig(config: HeadscaleConfigState, patches: PatchConfig[]) {
|
||||
if (!config.path || !config.document || !readable(config) || !writable(config)) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a DNS record to the Headscale configuration.
|
||||
* Differentiates between the file mode and config mode automatically.
|
||||
* @param record The DNS record to add.
|
||||
* @returns True if we need to restart the integration.
|
||||
*/
|
||||
async addDNS(record: DNSRecord) {
|
||||
if (this.dns) {
|
||||
if (!this.dns.readable() || !this.dns.writable()) {
|
||||
log.debug("config", "DNS config is not writable");
|
||||
return;
|
||||
}
|
||||
const write = config.writeQueue.then(() => writePatches(config, patches));
|
||||
config.writeQueue = write.catch(() => undefined);
|
||||
await write;
|
||||
}
|
||||
|
||||
const records = this.dns.r;
|
||||
if (records.some((i) => i.name === record.name && i.type === record.type)) {
|
||||
log.debug("config", "DNS record already exists");
|
||||
return;
|
||||
}
|
||||
async function writePatches(config: HeadscaleConfigState, patches: PatchConfig[]) {
|
||||
if (!config.path || !config.document) return;
|
||||
|
||||
return this.dns.patch([...records, record]);
|
||||
log.debug("config", "Patching Headscale configuration");
|
||||
for (const patch of patches) {
|
||||
const { path, value } = patch;
|
||||
log.debug("config", "Patching %s with %o", path, value);
|
||||
|
||||
const key = splitPatchPath(path);
|
||||
if (value === null) {
|
||||
config.document.deleteIn(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
// If we get here, we need to add to the main config instead of
|
||||
// a separate file (which requires an integration restart)
|
||||
const existing = this.config?.dns.extra_records ?? [];
|
||||
if (existing.some((i) => i.name === record.name && i.type === record.type)) {
|
||||
config.document.setIn(key, value);
|
||||
}
|
||||
|
||||
log.debug("config", "Writing updated Headscale configuration to %s", config.path);
|
||||
await writeFile(config.path, config.document.toString(), "utf8");
|
||||
config.config = config.document.toJSON();
|
||||
}
|
||||
|
||||
function splitPatchPath(path: string) {
|
||||
const key = [];
|
||||
let current = "";
|
||||
let quote = false;
|
||||
|
||||
for (const char of path) {
|
||||
if (char === '"') {
|
||||
quote = !quote;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "." && !quote) {
|
||||
key.push(current);
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
current += char;
|
||||
}
|
||||
|
||||
key.push(current);
|
||||
return key;
|
||||
}
|
||||
|
||||
async function addDNS(config: HeadscaleConfigState, record: DNSRecord) {
|
||||
if (config.dns) {
|
||||
if (!config.dns.readable() || !config.dns.writable()) {
|
||||
log.debug("config", "DNS config is not writable");
|
||||
return;
|
||||
}
|
||||
|
||||
const records = config.dns.r;
|
||||
if (records.some((i) => i.name === record.name && i.type === record.type)) {
|
||||
log.debug("config", "DNS record already exists");
|
||||
return;
|
||||
}
|
||||
|
||||
await this.patch([
|
||||
{
|
||||
path: "dns.extra_records",
|
||||
value: Array.from(new Set([...existing, record])),
|
||||
},
|
||||
]);
|
||||
|
||||
return true;
|
||||
return config.dns.patch([...records, record]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a DNS record from the Headscale configuration.
|
||||
* Differentiates between the file mode and config mode automatically.
|
||||
* @param records The DNS record to remove.
|
||||
* @returns True if we need to restart the integration.
|
||||
*/
|
||||
async removeDNS(record: DNSRecord) {
|
||||
// In this case we need to check both the main config and the DNS config
|
||||
// to see if the record exists, and if it does, we need to remove it
|
||||
// from both places.
|
||||
const existing = dnsRecords(config);
|
||||
if (existing.some((i) => i.name === record.name && i.type === record.type)) {
|
||||
log.debug("config", "DNS record already exists");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.dns) {
|
||||
if (!this.dns.readable() || !this.dns.writable()) {
|
||||
log.debug("config", "DNS config is not writable");
|
||||
return;
|
||||
}
|
||||
await patchHeadscaleConfig(config, [
|
||||
{
|
||||
path: "dns.extra_records",
|
||||
value: [...existing, record],
|
||||
},
|
||||
]);
|
||||
|
||||
const records = this.dns.r.filter((i) => i.name !== record.name || i.type !== record.type);
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.dns.patch(records);
|
||||
}
|
||||
|
||||
// If we get here, we need to remove from the main config instead of
|
||||
// a separate file (which requires an integration restart)
|
||||
const existing = this.config?.dns.extra_records ?? [];
|
||||
const filtered = existing.filter((i) => i.name !== record.name || i.type !== record.type);
|
||||
|
||||
// If the length of the existing records is the same as the filtered
|
||||
// records, then we don't need to do anything
|
||||
if (existing.length === filtered.length) {
|
||||
async function removeDNS(config: HeadscaleConfigState, record: DNSRecord) {
|
||||
if (config.dns) {
|
||||
if (!config.dns.readable() || !config.dns.writable()) {
|
||||
log.debug("config", "DNS config is not writable");
|
||||
return;
|
||||
}
|
||||
|
||||
await this.patch([
|
||||
{
|
||||
path: "dns.extra_records",
|
||||
value: existing.filter((i) => i.name !== record.name || i.type !== record.type),
|
||||
},
|
||||
]);
|
||||
|
||||
return true;
|
||||
const records = config.dns.r.filter((i) => i.name !== record.name || i.type !== record.type);
|
||||
return config.dns.patch(records);
|
||||
}
|
||||
|
||||
const existing = dnsRecords(config);
|
||||
const filtered = existing.filter((i) => i.name !== record.name || i.type !== record.type);
|
||||
if (existing.length === filtered.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
await patchHeadscaleConfig(config, [
|
||||
{
|
||||
path: "dns.extra_records",
|
||||
value: filtered,
|
||||
},
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function loadHeadscaleConfig(path?: string, strict = true, dnsPath?: string) {
|
||||
export async function loadHeadscaleConfig(path?: string, dnsPath?: string) {
|
||||
if (!path) {
|
||||
log.debug("config", "No Headscale configuration file was provided");
|
||||
return new HeadscaleConfig("no");
|
||||
return createHeadscaleConfig("no");
|
||||
}
|
||||
|
||||
log.debug("config", "Loading Headscale configuration file: %s", path);
|
||||
const { r, w } = await validateConfigPath(path);
|
||||
if (!r) {
|
||||
return new HeadscaleConfig("no");
|
||||
return createHeadscaleConfig("no");
|
||||
}
|
||||
|
||||
const document = await loadConfigFile(path);
|
||||
if (!document) {
|
||||
return new HeadscaleConfig("no");
|
||||
return createHeadscaleConfig("no");
|
||||
}
|
||||
|
||||
if (!strict) {
|
||||
return new HeadscaleConfig(
|
||||
w ? "rw" : "ro",
|
||||
new HeadscaleDNSConfig("no"),
|
||||
augmentUnstrictConfig(document.toJSON()),
|
||||
document,
|
||||
path,
|
||||
const rawConfig = document.toJSON();
|
||||
const parsedConfig = v.parse(headscaleConfigSchema, rawConfig);
|
||||
const conflict = v.safeParse(extraRecordsConflictSchema, rawConfig);
|
||||
const extraRecordsPath = parsedConfig.dns.extra_records_path;
|
||||
|
||||
if (conflict.success && conflict.output.dns?.extra_records && extraRecordsPath) {
|
||||
log.warn(
|
||||
"config",
|
||||
"Both dns.extra_records and dns.extra_records_path are set; Headplane will use the JSON records file",
|
||||
);
|
||||
}
|
||||
|
||||
const config = validateConfig(document.toJSON());
|
||||
if (!config) {
|
||||
return new HeadscaleConfig("no");
|
||||
}
|
||||
|
||||
if (config.dns.extra_records && config.dns.extra_records_path) {
|
||||
log.warn("config", "Both extra_records and extra_records_path are set, Headscale will crash");
|
||||
|
||||
log.warn("config", "Please remove one of them from the configuration file");
|
||||
return new HeadscaleConfig("no");
|
||||
}
|
||||
|
||||
const dns = await loadHeadscaleDNS(dnsPath);
|
||||
if (dns && !config.dns.extra_records_path) {
|
||||
const dns = await loadHeadscaleDNS(dnsPath ?? extraRecordsPath);
|
||||
if (dns && !extraRecordsPath) {
|
||||
log.error(
|
||||
"config",
|
||||
"Using separate DNS config file but dns.extra_records_path is not set in Headscale config",
|
||||
@@ -263,7 +386,7 @@ export async function loadHeadscaleConfig(path?: string, strict = true, dnsPath?
|
||||
exit(1);
|
||||
}
|
||||
|
||||
return new HeadscaleConfig(w ? "rw" : "ro", dns, config, document, path);
|
||||
return createHeadscaleConfig(w ? "rw" : "ro", dns, document, path);
|
||||
}
|
||||
|
||||
async function validateConfigPath(path: string) {
|
||||
@@ -279,7 +402,7 @@ async function validateConfigPath(path: string) {
|
||||
try {
|
||||
await access(path, constants.F_OK | constants.W_OK);
|
||||
return { w: true, r: true };
|
||||
} catch (error) {
|
||||
} catch {
|
||||
log.warn("config", "Headscale configuration file at %s is not writable", path);
|
||||
return { w: false, r: true };
|
||||
}
|
||||
@@ -306,68 +429,3 @@ async function loadConfigFile(path: string) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function validateConfig(config: unknown) {
|
||||
log.debug("config", "Validating Headscale configuration");
|
||||
const result = headscaleConfig(config);
|
||||
if (result instanceof type.errors) {
|
||||
log.error("config", "Error validating Headscale configuration:");
|
||||
for (const [number, error] of result.entries()) {
|
||||
log.error("config", ` - (${number}): ${error.toString()}`);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// If config_strict is false, we set the defaults and disable
|
||||
// the schema checking for the values that are not present
|
||||
function augmentUnstrictConfig(loaded: Partial<typeof headscaleConfig.infer>) {
|
||||
log.debug("config", "Augmenting Headscale configuration in non-strict mode");
|
||||
const config = {
|
||||
...loaded,
|
||||
tls_letsencrypt_cache_dir: loaded.tls_letsencrypt_cache_dir ?? "/var/www/cache",
|
||||
tls_letsencrypt_challenge_type: loaded.tls_letsencrypt_challenge_type ?? "HTTP-01",
|
||||
grpc_listen_addr: loaded.grpc_listen_addr ?? ":50443",
|
||||
grpc_allow_insecure: loaded.grpc_allow_insecure ?? false,
|
||||
randomize_client_port: loaded.randomize_client_port ?? false,
|
||||
unix_socket: loaded.unix_socket ?? "/var/run/headscale/headscale.sock",
|
||||
unix_socket_permission: loaded.unix_socket_permission ?? "0770",
|
||||
|
||||
log: loaded.log ?? {
|
||||
level: "info",
|
||||
format: "text",
|
||||
},
|
||||
|
||||
logtail: loaded.logtail ?? {
|
||||
enabled: false,
|
||||
},
|
||||
|
||||
prefixes: loaded.prefixes ?? {
|
||||
allocation: "sequential",
|
||||
v4: "",
|
||||
v6: "",
|
||||
},
|
||||
|
||||
dns: loaded.dns ?? {
|
||||
nameservers: {
|
||||
global: [],
|
||||
split: {},
|
||||
},
|
||||
search_domains: [],
|
||||
extra_records: [],
|
||||
magic_dns: false,
|
||||
base_domain: "headscale.net",
|
||||
},
|
||||
};
|
||||
|
||||
log.warn("config", "Headscale configuration was loaded in non-strict mode");
|
||||
log.warn("config", "This is very dangerous and comes with a few caveats:");
|
||||
log.warn("config", " - Headplane could very easily crash");
|
||||
log.warn("config", " - Headplane could break your Headscale installation");
|
||||
log.warn("config", " - The UI could throw random errors/show incorrect data");
|
||||
|
||||
return config as typeof headscaleConfig.infer;
|
||||
}
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
import { type } from "arktype";
|
||||
|
||||
const goBool = type('boolean | "true" | "false"').pipe((v) => {
|
||||
if (v === "true") return true;
|
||||
if (v === "false") return false;
|
||||
return v;
|
||||
});
|
||||
|
||||
const goDuration = type("0 | string").pipe((v) => {
|
||||
return v.toString();
|
||||
});
|
||||
|
||||
const databaseConfig = type({
|
||||
type: '"sqlite" | "sqlite3"',
|
||||
sqlite: {
|
||||
path: "string",
|
||||
write_ahead_log: goBool.default(true),
|
||||
wal_autocheckpoint: "number = 1000",
|
||||
},
|
||||
})
|
||||
.or({
|
||||
type: '"postgres"',
|
||||
postgres: {
|
||||
host: "string",
|
||||
port: 'number | ""',
|
||||
name: "string",
|
||||
user: "string",
|
||||
pass: "string",
|
||||
max_open_conns: "number = 10",
|
||||
max_idle_conns: "number = 10",
|
||||
conn_max_idle_time_secs: "number = 3600",
|
||||
ssl: goBool.default(false),
|
||||
},
|
||||
})
|
||||
.merge({
|
||||
debug: goBool.default(false),
|
||||
"gorm?": {
|
||||
prepare_stmt: goBool.default(true),
|
||||
parameterized_queries: goBool.default(true),
|
||||
skip_err_record_not_found: goBool.default(true),
|
||||
slow_threshold: "number = 1000",
|
||||
},
|
||||
});
|
||||
|
||||
// Not as strict parsing because we just need the values
|
||||
// to be slightly truthy enough to safely modify them
|
||||
export type HeadscaleConfig = typeof headscaleConfig.infer;
|
||||
export const headscaleConfig = type({
|
||||
server_url: "string",
|
||||
listen_addr: "string",
|
||||
"metrics_listen_addr?": "string",
|
||||
grpc_listen_addr: 'string = ":50433"',
|
||||
grpc_allow_insecure: goBool.default(false),
|
||||
noise: {
|
||||
private_key_path: "string",
|
||||
},
|
||||
prefixes: {
|
||||
v4: "string?",
|
||||
v6: "string?",
|
||||
allocation: '"sequential" | "random" = "sequential"',
|
||||
},
|
||||
derp: {
|
||||
server: {
|
||||
enabled: goBool.default(true),
|
||||
region_id: "number?",
|
||||
region_code: "string?",
|
||||
region_name: "string?",
|
||||
stun_listen_addr: "string?",
|
||||
private_key_path: "string?",
|
||||
ipv4: "string?",
|
||||
ipv6: "string?",
|
||||
automatically_add_embedded_derp_region: goBool.default(true),
|
||||
},
|
||||
urls: "string[]?",
|
||||
paths: "string[]?",
|
||||
auto_update_enabled: goBool.default(true),
|
||||
update_frequency: goDuration.default("24h"),
|
||||
},
|
||||
|
||||
disable_check_updates: goBool.default(false),
|
||||
ephemeral_node_inactivity_timeout: goDuration.default("30m"),
|
||||
database: databaseConfig,
|
||||
|
||||
acme_url: 'string = "https://acme-v02.api.letsencrypt.org/directory"',
|
||||
acme_email: 'string = ""',
|
||||
tls_letsencrypt_hostname: 'string = ""',
|
||||
tls_letsencrypt_cache_dir: 'string = "/var/lib/headscale/cache"',
|
||||
tls_letsencrypt_challenge_type: 'string = "HTTP-01"',
|
||||
tls_letsencrypt_listen: 'string = ":http"',
|
||||
"tls_cert_path?": "string",
|
||||
"tls_key_path?": "string",
|
||||
|
||||
log: type({
|
||||
format: 'string = "text"',
|
||||
level: 'string = "info"',
|
||||
}).default(() => ({ format: "text", level: "info" })),
|
||||
|
||||
"policy?": {
|
||||
mode: '"database" | "file" = "file"',
|
||||
path: "string?",
|
||||
},
|
||||
|
||||
dns: {
|
||||
magic_dns: goBool.default(true),
|
||||
base_domain: 'string = "headscale.net"',
|
||||
override_local_dns: goBool.default(false),
|
||||
nameservers: type({
|
||||
global: type("string[]").default(() => []),
|
||||
split: type("Record<string, string[]>").default(() => ({})),
|
||||
}).default(() => ({ global: [], split: {} })),
|
||||
search_domains: type("string[]").default(() => []),
|
||||
extra_records: type({
|
||||
name: "string",
|
||||
value: "string",
|
||||
type: 'string | "A"',
|
||||
})
|
||||
.array()
|
||||
.optional(),
|
||||
extra_records_path: "string?",
|
||||
},
|
||||
|
||||
unix_socket: "string?",
|
||||
unix_socket_permission: 'string = "0770"',
|
||||
|
||||
"oidc?": {
|
||||
only_start_if_oidc_is_available: goBool.default(false),
|
||||
issuer: "string",
|
||||
client_id: "string",
|
||||
client_secret: "string?",
|
||||
client_secret_path: "string?",
|
||||
expiry: goDuration.default("180d"),
|
||||
use_expiry_from_token: goBool.default(false),
|
||||
scope: type("string[]").default(() => ["openid", "email", "profile"]),
|
||||
extra_params: "Record<string, string>?",
|
||||
allowed_domains: "string[]?",
|
||||
allowed_groups: "string[]?",
|
||||
allowed_users: "string[]?",
|
||||
"pkce?": {
|
||||
enabled: goBool.default(false),
|
||||
method: 'string = "S256"',
|
||||
},
|
||||
map_legacy_users: goBool.default(false),
|
||||
},
|
||||
|
||||
"logtail?": {
|
||||
enabled: goBool.default(false),
|
||||
},
|
||||
|
||||
randomize_client_port: goBool.default(false),
|
||||
});
|
||||
+67
-11
@@ -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,
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -1,44 +1,58 @@
|
||||
// MARK: Side-Effects
|
||||
// This module contains a side-effect because everything running here
|
||||
// is static and logger is later modified in `app/server/index.ts` to
|
||||
// disable debug logging if the `HEADPLANE_DEBUG_LOG` specifies as such.
|
||||
// This module contains a side-effect because log levels are read from
|
||||
// the environment once at module initialization.
|
||||
|
||||
import pino from "pino";
|
||||
|
||||
const levels = ["info", "warn", "error", "debug"] as const;
|
||||
type Category = "server" | "config" | "agent" | "api" | "auth" | "sse";
|
||||
type Level = (typeof levels)[number];
|
||||
|
||||
export interface Logger extends Record<
|
||||
(typeof levels)[number],
|
||||
Level,
|
||||
(category: Category, message: string, ...args: unknown[]) => void
|
||||
> {
|
||||
debugEnabled: boolean;
|
||||
}
|
||||
|
||||
const logLevels = getLogLevels();
|
||||
const rootLogger = createRootLogger();
|
||||
export default {
|
||||
debugEnabled: logLevels.includes("debug"),
|
||||
debug: (..._: Parameters<Logger["debug"]>) => {},
|
||||
...Object.fromEntries(
|
||||
logLevels.map((level) => [
|
||||
level,
|
||||
(category: Category, message: string, ...args: unknown[]) => {
|
||||
const date = new Date().toISOString();
|
||||
console.log(`${date} [${category}] ${level.toUpperCase()}: ${message}`, ...args);
|
||||
},
|
||||
]),
|
||||
),
|
||||
debugEnabled: rootLogger.isLevelEnabled("debug"),
|
||||
info: (category, msg, ...args) => rootLogger.info({ component: category }, msg, ...args),
|
||||
warn: (category, msg, ...args) => rootLogger.warn({ component: category }, msg, ...args),
|
||||
error: (category, msg, ...args) => rootLogger.error({ component: category }, msg, ...args),
|
||||
debug: (category, msg, ...args) => rootLogger.debug({ component: category }, msg, ...args),
|
||||
} as Logger;
|
||||
|
||||
function getLogLevels() {
|
||||
function createRootLogger() {
|
||||
const options = {
|
||||
level: getLogLevel(),
|
||||
timestamp: () => `,"timestamp":"${new Date().toISOString()}"`,
|
||||
formatters: {
|
||||
level: (label: string) => ({ level: label }),
|
||||
},
|
||||
} satisfies pino.LoggerOptions;
|
||||
|
||||
if (process.env.NODE_ENV === "test") {
|
||||
return pino(options);
|
||||
}
|
||||
|
||||
const destination = pino.destination({ dest: 1, sync: false });
|
||||
process.on("exit", () => destination.flushSync());
|
||||
return pino(options, destination);
|
||||
}
|
||||
|
||||
function getLogLevel(): Level {
|
||||
const debugLog = process.env.HEADPLANE_DEBUG_LOG;
|
||||
if (debugLog == null) {
|
||||
return ["info", "warn", "error"];
|
||||
return "info";
|
||||
}
|
||||
|
||||
const normalized = debugLog.trim().toLowerCase();
|
||||
const truthyValues = ["1", "true", "yes", "on"];
|
||||
if (!truthyValues.includes(normalized)) {
|
||||
return ["info", "warn", "error"];
|
||||
return "info";
|
||||
}
|
||||
|
||||
return ["info", "warn", "error", "debug"];
|
||||
return "debug";
|
||||
}
|
||||
|
||||
@@ -48,3 +48,94 @@ export function mapNodes(
|
||||
export function sortNodeTags(nodes: Machine[]): string[] {
|
||||
return Array.from(new Set(nodes.flatMap((node) => node.tags))).sort();
|
||||
}
|
||||
|
||||
export function sortAssignableTags(nodes: Machine[], policy?: string): string[] {
|
||||
return Array.from(new Set([...sortNodeTags(nodes), ...extractTagOwnerTags(policy)])).sort();
|
||||
}
|
||||
|
||||
export function extractTagOwnerTags(policy: string | undefined): string[] {
|
||||
if (!policy) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(stripJsonCommentsAndTrailingCommas(policy)) as unknown;
|
||||
if (parsed == null || typeof parsed !== "object" || !("tagOwners" in parsed)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const tagOwners = (parsed as { tagOwners?: unknown }).tagOwners;
|
||||
if (tagOwners == null || typeof tagOwners !== "object" || Array.isArray(tagOwners)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.keys(tagOwners)
|
||||
.filter((tag) => tag.startsWith("tag:"))
|
||||
.sort();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function stripJsonCommentsAndTrailingCommas(input: string): string {
|
||||
let output = "";
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
let inLineComment = false;
|
||||
let inBlockComment = false;
|
||||
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const char = input[i];
|
||||
const next = input[i + 1];
|
||||
|
||||
if (inLineComment) {
|
||||
if (char === "\n" || char === "\r") {
|
||||
inLineComment = false;
|
||||
output += char;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inBlockComment) {
|
||||
if (char === "*" && next === "/") {
|
||||
inBlockComment = false;
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inString) {
|
||||
output += char;
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
} else if (char === "\\") {
|
||||
escaped = true;
|
||||
} else if (char === '"') {
|
||||
inString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"') {
|
||||
inString = true;
|
||||
output += char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "/" && next === "/") {
|
||||
inLineComment = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "/" && next === "*") {
|
||||
inBlockComment = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
output += char;
|
||||
}
|
||||
|
||||
return output.replace(/,\s*([}\]])/g, "$1");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
const authRequestKeyPattern = /^hskey-authreq-[A-Za-z0-9_-]{16,}$/;
|
||||
const authRequestKeySearchPattern = /hskey-authreq-[A-Za-z0-9_-]{16,}/;
|
||||
const legacyMachineKeyPattern = /^mkey:[A-Za-z0-9_-]{16,}$/;
|
||||
const registerUrlPattern = /\/register\/([^\s?#]+)/;
|
||||
const registrationKeySuffixPattern = /^[A-Za-z0-9_-]{24,}$/;
|
||||
|
||||
export function normalizeRegistrationKey(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const registerUrlMatch = trimmed.match(registerUrlPattern);
|
||||
if (registerUrlMatch) {
|
||||
const token = normalizeRegistrationToken(registerUrlMatch[1]);
|
||||
if (token) {
|
||||
return token;
|
||||
}
|
||||
}
|
||||
|
||||
const authRequestKeyMatch = trimmed.match(authRequestKeySearchPattern);
|
||||
if (authRequestKeyMatch) {
|
||||
return authRequestKeyMatch[0];
|
||||
}
|
||||
|
||||
return normalizeRegistrationToken(trimmed);
|
||||
}
|
||||
|
||||
function normalizeRegistrationToken(value: string): string | null {
|
||||
let token: string;
|
||||
try {
|
||||
token = decodeURIComponent(value.trim());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (authRequestKeyPattern.test(token) || legacyMachineKeyPattern.test(token)) {
|
||||
return token;
|
||||
}
|
||||
|
||||
if (registrationKeySuffixPattern.test(token)) {
|
||||
return `hskey-authreq-${token}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -138,8 +138,8 @@ build_wasm() {
|
||||
"$(dirname "$WASM_OUTPUT")/wasm_exec.js"
|
||||
|
||||
# Vendor dependencies and apply the DERP port patch.
|
||||
# Tailscale's derphttp WebSocket URL builder ignores DERPPort,
|
||||
# which breaks WASM connections to non-443 DERP servers.
|
||||
# Tailscale's browser WebSocket and netcheck URL builders ignore
|
||||
# DERPPort, which breaks WASM connections to non-443 DERP servers.
|
||||
echo "==> Vendoring Go dependencies for WASM patch"
|
||||
go mod vendor
|
||||
|
||||
|
||||
@@ -30,6 +30,17 @@ func main() {
|
||||
}
|
||||
|
||||
log.SetDebug(cfg.Debug)
|
||||
|
||||
if cfg.TSAuthKey == "" {
|
||||
log.Info("No TS_AUTHKEY provided; tsnet will use existing state or prompt for interactive login")
|
||||
} else {
|
||||
prefix := cfg.TSAuthKey
|
||||
if len(prefix) > 16 {
|
||||
prefix = prefix[:16]
|
||||
}
|
||||
log.Info("TS_AUTHKEY provided (prefix: %s); connecting with pre-auth key", prefix)
|
||||
}
|
||||
|
||||
agent := tsnet.NewAgent(cfg)
|
||||
defer agent.Shutdown()
|
||||
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ services:
|
||||
- "./test/caddy/config:/config"
|
||||
- "./test/caddy/certs:/certs"
|
||||
headscale:
|
||||
image: "headscale/headscale:0.28.0"
|
||||
image: "headscale/headscale:0.29.0"
|
||||
container_name: "headscale"
|
||||
labels:
|
||||
me.tale.headplane.target: headscale
|
||||
|
||||
+50
-14
@@ -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.
|
||||
|
||||
@@ -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
@@ -85,15 +85,7 @@ The full (generated by `mise run nixos-docs`) list of `services.headplane.settin
|
||||
{config, pkgs, ...}:
|
||||
let
|
||||
format = pkgs.formats.yaml {};
|
||||
|
||||
# A workaround generate a valid Headscale config accepted by Headplane when `config_strict == true`.
|
||||
settings = lib.recursiveUpdate config.services.headscale.settings {
|
||||
tls_cert_path = "/dev/null";
|
||||
tls_key_path = "/dev/null";
|
||||
policy.path = "/dev/null";
|
||||
};
|
||||
|
||||
headscaleConfig = format.generate "headscale.yml" settings;
|
||||
headscaleConfig = format.generate "headscale.yml" config.services.headscale.settings;
|
||||
in {
|
||||
services.headplane = {
|
||||
enable = true;
|
||||
|
||||
+101
-4
@@ -73,9 +73,7 @@ _Example:_ `"/etc/headscale/config.yaml"`
|
||||
|
||||
## settings.headscale.config_strict
|
||||
|
||||
_Description:_ Headplane internally validates the Headscale configuration
|
||||
to ensure that it changes the configuration in a safe way.
|
||||
If you want to disable this validation, set this to false.
|
||||
_Description:_ Deprecated. Headplane no longer validates the complete Headscale configuration and this option has no effect.
|
||||
|
||||
_Type:_ boolean
|
||||
|
||||
@@ -83,7 +81,7 @@ _Default:_ `true`
|
||||
|
||||
## settings.headscale.dns_records_path
|
||||
|
||||
_Description:_ If you are using `dns.extra_records_path` in your Headscale configuration, you need to set this to the path for Headplane to be able to read the DNS records.
|
||||
_Description:_ If you are using `dns.extra_records_path` in your Headscale configuration, Headplane reads that path automatically. Set this only when Headplane needs to access the same file at a different path.
|
||||
Ensure that the file is both readable and writable by the Headplane process.
|
||||
When using this, Headplane will no longer need to automatically restart Headscale for DNS record changes.
|
||||
|
||||
@@ -252,6 +250,15 @@ _Type:_ boolean
|
||||
|
||||
_Default:_ `false`
|
||||
|
||||
## settings.oidc.default_role
|
||||
|
||||
_Description:_ Role assigned to newly created OIDC users after the first owner is bootstrapped.
|
||||
The owner role is reserved for the first-login bootstrap.
|
||||
|
||||
_Type:_ one of "admin", "network_admin", "it_admin", "auditor", "viewer", "member"
|
||||
|
||||
_Default:_ `"member"`
|
||||
|
||||
## settings.oidc.headscale_api_key_path
|
||||
|
||||
_Description:_ DEPRECATED: Use `headscale.api_key_path` instead.
|
||||
@@ -284,6 +291,17 @@ _Default:_ `""`
|
||||
|
||||
_Example:_ `"https://headscale.example.com/admin/oidc/callback"`
|
||||
|
||||
## settings.oidc.role_claim
|
||||
|
||||
_Description:_ Optional OIDC claim containing the Headplane role to assign to newly created users.
|
||||
A valid role claim takes precedence over default_role.
|
||||
|
||||
_Type:_ null or string
|
||||
|
||||
_Default:_ `null`
|
||||
|
||||
_Example:_ `"headplane_role"`
|
||||
|
||||
## settings.oidc.token_endpoint_auth_method
|
||||
|
||||
_Description:_ The token endpoint authentication method.
|
||||
@@ -350,3 +368,82 @@ _Description:_ The port to listen on.
|
||||
_Type:_ 16 bit unsigned integer; between 0 and 65535 (both inclusive)
|
||||
|
||||
_Default:_ `3000`
|
||||
|
||||
## settings.server.proxy_auth
|
||||
|
||||
_Description:_ Proxy authentication configuration.
|
||||
|
||||
_Type:_ submodule
|
||||
|
||||
_Default:_ `{ }`
|
||||
|
||||
## settings.server.proxy_auth.allowed_cidrs
|
||||
|
||||
_Description:_ Direct client CIDR ranges allowed to bypass Headplane's login flow.
|
||||
These should be the addresses your trusted reverse proxy uses to connect
|
||||
to Headplane. Requires headscale.api_key_path.
|
||||
|
||||
_Type:_ list of string
|
||||
|
||||
_Default:_ `[ "127.0.0.1/32" "::1/128" ]`
|
||||
|
||||
_Example:_ `[ "10.0.0.0/24" ]`
|
||||
|
||||
## settings.server.proxy_auth.email_header
|
||||
|
||||
_Description:_ Optional header containing the authenticated user's email address.
|
||||
|
||||
_Type:_ null or string
|
||||
|
||||
_Default:_ `null`
|
||||
|
||||
## settings.server.proxy_auth.enabled
|
||||
|
||||
_Description:_ Whether to trust reverse proxy authentication for allowed client CIDRs.
|
||||
|
||||
_Type:_ boolean
|
||||
|
||||
_Default:_ `false`
|
||||
|
||||
## settings.server.proxy_auth.ip_header
|
||||
|
||||
_Description:_ Optional header containing the original client IP, such as X-Forwarded-For or X-Real-IP.
|
||||
|
||||
_Type:_ null or string
|
||||
|
||||
_Default:_ `null`
|
||||
|
||||
## settings.server.proxy_auth.name_header
|
||||
|
||||
_Description:_ Optional header containing the authenticated user's display name.
|
||||
|
||||
_Type:_ null or string
|
||||
|
||||
_Default:_ `null`
|
||||
|
||||
## settings.server.proxy_auth.picture_header
|
||||
|
||||
_Description:_ Optional header containing the authenticated user's profile picture URL.
|
||||
|
||||
_Type:_ null or string
|
||||
|
||||
_Default:_ `null`
|
||||
|
||||
## settings.server.proxy_auth.user_header
|
||||
|
||||
_Description:_ Header containing the stable authenticated proxy user identity.
|
||||
|
||||
_Type:_ string
|
||||
|
||||
_Default:_ `"Remote-User"`
|
||||
|
||||
## settings.server.proxy_auth.trusted_proxy_cidrs
|
||||
|
||||
_Description:_ Direct proxy CIDR ranges trusted to supply ip_header.
|
||||
Only used when ip_header is set.
|
||||
|
||||
_Type:_ list of string
|
||||
|
||||
_Default:_ `[ "127.0.0.1/32" "::1/128" ]`
|
||||
|
||||
_Example:_ `[ "127.0.0.1/32" ]`
|
||||
|
||||
@@ -58,8 +58,8 @@ The following configuration options in Headplane are treated as secret paths:
|
||||
- _Note:_ Either `cookie_secret` or `cookie_secret_path` must be provided for web session security.
|
||||
|
||||
- **Headscale Connection Settings (`headscale.*`):**
|
||||
- `api_key_path` (Headscale API key for server-side operations like OIDC and agent sync)
|
||||
- _Note:_ Either `api_key` or `api_key_path` must be provided when using OIDC or the agent.
|
||||
- `api_key_path` (Headscale API key for server-side operations like OIDC, proxy authentication, and agent sync)
|
||||
- _Note:_ Either `api_key` or `api_key_path` must be provided when using OIDC, proxy authentication, or the agent.
|
||||
- `tls_cert_path` (custom TLS certificate for connecting to Headscale)
|
||||
- _Note:_ This is treated as a regular path, not a secret path, so it will not have its content loaded.
|
||||
|
||||
@@ -82,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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Generated
+1829
-1831
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,5 @@ export default {
|
||||
ssr: true,
|
||||
future: {
|
||||
unstable_optimizeDeps: true,
|
||||
v8_splitRouteModules: "enforce",
|
||||
},
|
||||
} satisfies Config;
|
||||
|
||||
+18
-3
@@ -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 {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -71,4 +71,3 @@ unix_socket: /var/run/headscale/headscale.sock
|
||||
unix_socket_permission: "0770"
|
||||
logtail:
|
||||
enabled: false
|
||||
randomize_client_port: false
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user